ux(audit): timeline redesign — dot rail, time gutter, event grouping
Replaces the row-of-cards layout (which looked spammy when several deletes happened in sequence) with a single dense timeline: - Each entry is a flat row with a left-hand HH:MM tabular-nums timestamp and a 1px vertical rail running through coloured dots (red=delete, green=create, amber=mutation, gray=system). - Consecutive events with the same actor+action+entity inside a day collapse into a single row: "rop удалил 6 лидов · показать". The expand link reveals the list of IDs + precise times. - Russian plural rules: 1 лид / 2..4 лида / 5+ лидов. - Action verb gets a tonal colour; entity word stays muted; ID is font-mono and links to the lead card. - Day heading remains a thin uppercase label; no more sticky bar. Drops the heavy outlined cards, the per-row big icon chip, and the "DIFF / Показать" column.
This commit is contained in:
parent
b79e99246e
commit
21ee17a831
@ -16,38 +16,48 @@ const filters = reactive<{ entity: string; entity_id: string }>({
|
|||||||
const entries = ref<AuditEntry[]>([]);
|
const entries = ref<AuditEntry[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
const ENTITY_LABELS: Record<string, string> = {
|
const ENTITY_LABELS_SINGULAR: Record<string, string> = {
|
||||||
lead: 'Лида',
|
lead: 'лида',
|
||||||
user: 'Пользователя',
|
user: 'пользователя',
|
||||||
system: 'Систему',
|
system: 'систему',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ENTITY_LABELS_PLURAL: Record<string, [string, string, string]> = {
|
||||||
|
// [1, 2..4, 5..]
|
||||||
|
lead: ['лида', 'лида', 'лидов'],
|
||||||
|
user: ['пользователя', 'пользователей', 'пользователей'],
|
||||||
|
system: ['систему', 'системы', 'систем'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function pluralEntity(entity: string, n: number): string {
|
||||||
|
const forms = ENTITY_LABELS_PLURAL[entity] ?? [entity, entity, entity];
|
||||||
|
const mod10 = n % 10;
|
||||||
|
const mod100 = n % 100;
|
||||||
|
if (mod100 >= 11 && mod100 <= 19) return forms[2];
|
||||||
|
if (mod10 === 1) return forms[0];
|
||||||
|
if (mod10 >= 2 && mod10 <= 4) return forms[1];
|
||||||
|
return forms[2];
|
||||||
|
}
|
||||||
|
|
||||||
interface ActionMeta {
|
interface ActionMeta {
|
||||||
label: string;
|
label: string;
|
||||||
tone: 'danger' | 'success' | 'neutral' | 'warning';
|
dot: string;
|
||||||
icon: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIONS: Record<string, ActionMeta> = {
|
const ACTIONS: Record<string, ActionMeta> = {
|
||||||
create: { label: 'создал', tone: 'success', icon: '+' },
|
create: { label: 'создал', dot: 'bg-emerald-500', text: 'text-emerald-700' },
|
||||||
delete: { label: 'удалил', tone: 'danger', icon: '×' },
|
delete: { label: 'удалил', dot: 'bg-red-500', text: 'text-red-700' },
|
||||||
reassign: { label: 'переназначил', tone: 'warning', icon: '↻' },
|
reassign: { label: 'переназначил', dot: 'bg-amber-500', text: 'text-amber-700' },
|
||||||
reset_password: { label: 'сменил пароль', tone: 'warning', icon: '⚿' },
|
reset_password: { label: 'сменил пароль', dot: 'bg-amber-500', text: 'text-amber-700' },
|
||||||
change_role: { label: 'изменил роль', tone: 'warning', icon: '⚙' },
|
change_role: { label: 'изменил роль', dot: 'bg-amber-500', text: 'text-amber-700' },
|
||||||
recalc: { label: 'пересчитал score', tone: 'neutral', icon: '∑' },
|
recalc: { label: 'пересчитал score', dot: 'bg-gray-500', text: 'text-gray-700' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMeta(a: string): ActionMeta {
|
function actionMeta(a: string): ActionMeta {
|
||||||
return ACTIONS[a] ?? { label: a, tone: 'neutral', icon: '•' };
|
return ACTIONS[a] ?? { label: a, dot: 'bg-gray-400', text: 'text-gray-600' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const TONE_CLASSES: Record<ActionMeta['tone'], { ring: string; bg: string; text: string }> = {
|
|
||||||
danger: { ring: 'ring-red-200', bg: 'bg-red-50', text: 'text-red-600' },
|
|
||||||
success: { ring: 'ring-emerald-200', bg: 'bg-emerald-50', text: 'text-emerald-600' },
|
|
||||||
warning: { ring: 'ring-amber-200', bg: 'bg-amber-50', text: 'text-amber-700' },
|
|
||||||
neutral: { ring: 'ring-gray-200', bg: 'bg-gray-50', text: 'text-gray-600' },
|
|
||||||
};
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@ -83,23 +93,8 @@ function fmtFullTime(s: string): string {
|
|||||||
return new Date(s).toLocaleString('ru-RU');
|
return new Date(s).toLocaleString('ru-RU');
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeTime(s: string): string {
|
function fmtClock(s: string): string {
|
||||||
const now = Date.now();
|
return new Date(s).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
const t = new Date(s).getTime();
|
|
||||||
const diff = Math.max(0, now - t);
|
|
||||||
const sec = Math.floor(diff / 1000);
|
|
||||||
if (sec < 60) return 'только что';
|
|
||||||
const min = Math.floor(sec / 60);
|
|
||||||
if (min < 60) return `${min} мин назад`;
|
|
||||||
const hr = Math.floor(min / 60);
|
|
||||||
if (hr < 24) return `${hr} ч назад`;
|
|
||||||
const day = Math.floor(hr / 24);
|
|
||||||
if (day < 7) return `${day} дн назад`;
|
|
||||||
return new Date(s).toLocaleDateString('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'short',
|
|
||||||
year: hr > 24 * 365 ? '2-digit' : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayBucket(s: string): string {
|
function dayBucket(s: string): string {
|
||||||
@ -118,28 +113,73 @@ function dayBucket(s: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = computed(() => {
|
interface TimelineItem {
|
||||||
const groups: { day: string; items: AuditEntry[] }[] = [];
|
// Group key: actor + action + entity. Holds 1..N audit entries
|
||||||
let cur: { day: string; items: AuditEntry[] } | null = null;
|
// collapsed into a single row.
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
entity: string;
|
||||||
|
items: AuditEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayGroup {
|
||||||
|
day: string;
|
||||||
|
rows: TimelineItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = computed<DayGroup[]>(() => {
|
||||||
|
const days: DayGroup[] = [];
|
||||||
|
let curDay: DayGroup | null = null;
|
||||||
|
let curRow: TimelineItem | null = null;
|
||||||
|
|
||||||
for (const e of entries.value) {
|
for (const e of entries.value) {
|
||||||
const d = dayBucket(e.created_at);
|
const d = dayBucket(e.created_at);
|
||||||
if (!cur || cur.day !== d) {
|
if (!curDay || curDay.day !== d) {
|
||||||
cur = { day: d, items: [] };
|
curDay = { day: d, rows: [] };
|
||||||
groups.push(cur);
|
days.push(curDay);
|
||||||
|
curRow = null;
|
||||||
}
|
}
|
||||||
cur.items.push(e);
|
if (
|
||||||
|
curRow &&
|
||||||
|
curRow.actor === e.actor &&
|
||||||
|
curRow.action === e.action &&
|
||||||
|
curRow.entity === e.entity
|
||||||
|
) {
|
||||||
|
curRow.items.push(e);
|
||||||
|
} else {
|
||||||
|
curRow = {
|
||||||
|
actor: e.actor,
|
||||||
|
action: e.action,
|
||||||
|
entity: e.entity,
|
||||||
|
items: [e],
|
||||||
|
};
|
||||||
|
curDay.rows.push(curRow);
|
||||||
}
|
}
|
||||||
return groups;
|
}
|
||||||
|
return days;
|
||||||
});
|
});
|
||||||
|
|
||||||
function shortId(id: string): string {
|
const expanded = ref<Set<string>>(new Set());
|
||||||
return id.length > 10 ? id.slice(0, 8) + '…' : id;
|
|
||||||
|
function rowKey(g: DayGroup, idx: number): string {
|
||||||
|
return g.day + '#' + idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gotoEntity(e: AuditEntry) {
|
function toggle(key: string) {
|
||||||
if (e.entity === 'lead') {
|
if (expanded.value.has(key)) expanded.value.delete(key);
|
||||||
router.push({ name: 'lead-card', params: { id: e.entity_id } });
|
else expanded.value.add(key);
|
||||||
|
expanded.value = new Set(expanded.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortId(id: string): string {
|
||||||
|
if (id.length <= 10) return id;
|
||||||
|
// UUID-looking? Take 8.
|
||||||
|
if (/^[0-9a-f-]{20,}$/i.test(id)) return id.slice(0, 8) + '…';
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoLead(id: string) {
|
||||||
|
router.push({ name: 'lead-card', params: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const empty = computed(() => !loading.value && entries.value.length === 0);
|
const empty = computed(() => !loading.value && entries.value.length === 0);
|
||||||
@ -181,7 +221,7 @@ onMounted(load);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="card p-4">
|
<div v-if="loading" class="card p-4">
|
||||||
<Skeleton :rows="6" height="44px" />
|
<Skeleton :rows="8" height="28px" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="empty"
|
v-else-if="empty"
|
||||||
@ -189,65 +229,109 @@ onMounted(load);
|
|||||||
>
|
>
|
||||||
Событий не найдено.
|
Событий не найдено.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-6">
|
|
||||||
<section
|
<div v-else class="space-y-8">
|
||||||
v-for="group in grouped"
|
<section v-for="group in grouped" :key="group.day">
|
||||||
:key="group.day"
|
|
||||||
class="space-y-2"
|
|
||||||
>
|
|
||||||
<h2
|
<h2
|
||||||
class="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-400 px-1"
|
class="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-400 mb-3 px-1"
|
||||||
>
|
>
|
||||||
{{ group.day }}
|
{{ group.day }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="card divide-y divide-gray-100">
|
<div class="relative">
|
||||||
<div
|
<div
|
||||||
v-for="e in group.items"
|
class="absolute left-[88px] top-0 bottom-0 w-px bg-gray-100"
|
||||||
:key="e.id"
|
aria-hidden="true"
|
||||||
class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50/70 transition"
|
/>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(row, idx) in group.rows"
|
||||||
|
:key="rowKey(group, idx)"
|
||||||
|
class="relative pl-[112px] pr-2 py-1.5 group"
|
||||||
>
|
>
|
||||||
<div
|
<time
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ring-1 shrink-0 leading-none"
|
class="absolute left-0 top-2 w-[80px] text-right text-[11px] text-gray-400 tabular-nums leading-snug"
|
||||||
:class="[
|
:title="fmtFullTime(row.items[0].created_at)"
|
||||||
TONE_CLASSES[actionMeta(e.action).tone].bg,
|
|
||||||
TONE_CLASSES[actionMeta(e.action).tone].ring,
|
|
||||||
TONE_CLASSES[actionMeta(e.action).tone].text,
|
|
||||||
]"
|
|
||||||
:title="actionMeta(e.action).label"
|
|
||||||
>
|
>
|
||||||
{{ actionMeta(e.action).icon }}
|
{{ fmtClock(row.items[0].created_at) }}
|
||||||
</div>
|
</time>
|
||||||
|
|
||||||
<p
|
<span
|
||||||
class="flex-1 min-w-0 text-sm text-gray-700 leading-snug truncate"
|
class="absolute left-[84px] top-[10px] w-2 h-2 rounded-full ring-2 ring-white"
|
||||||
|
:class="actionMeta(row.action).dot"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-baseline gap-1.5 flex-wrap">
|
||||||
|
<span class="font-semibold text-gray-900 text-sm">
|
||||||
|
{{ row.actor }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
:class="actionMeta(row.action).text"
|
||||||
>
|
>
|
||||||
<span class="font-semibold text-gray-900">{{ e.actor }}</span>
|
{{ actionMeta(row.action).label }}
|
||||||
<span class="mx-1.5">{{ actionMeta(e.action).label }}</span>
|
</span>
|
||||||
<span class="text-gray-500">{{ ENTITY_LABELS[e.entity] ?? e.entity }}</span>
|
<template v-if="row.items.length === 1">
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
{{ ENTITY_LABELS_SINGULAR[row.entity] ?? row.entity }}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="e.entity === 'lead'"
|
v-if="row.entity === 'lead'"
|
||||||
type="button"
|
type="button"
|
||||||
class="ml-1.5 font-mono text-xs text-sky-600 hover:underline align-middle"
|
class="font-mono text-xs text-sky-600 hover:underline"
|
||||||
@click="gotoEntity(e)"
|
@click="gotoLead(row.items[0].entity_id)"
|
||||||
>
|
>
|
||||||
{{ shortId(e.entity_id) }}
|
{{ shortId(row.items[0].entity_id) }}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="ml-1.5 font-mono text-xs text-gray-500 align-middle"
|
class="font-mono text-xs text-gray-600"
|
||||||
>
|
>
|
||||||
{{ e.entity_id }}
|
{{ row.items[0].entity_id }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</template>
|
||||||
|
<template v-else>
|
||||||
<time
|
<span class="text-sm text-gray-500">
|
||||||
class="text-[11px] text-gray-400 tabular-nums shrink-0 leading-none"
|
{{ row.items.length }} {{ pluralEntity(row.entity, row.items.length) }}
|
||||||
:title="fmtFullTime(e.created_at)"
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-[11px] text-gray-400 hover:text-gray-700 ml-1"
|
||||||
|
@click="toggle(rowKey(group, idx))"
|
||||||
>
|
>
|
||||||
{{ relativeTime(e.created_at) }}
|
{{ expanded.has(rowKey(group, idx)) ? 'свернуть' : 'показать' }}
|
||||||
</time>
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="row.items.length > 1 && expanded.has(rowKey(group, idx))"
|
||||||
|
class="mt-2 ml-1 space-y-1 border-l-2 border-gray-100 pl-3"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="it in row.items"
|
||||||
|
:key="it.id"
|
||||||
|
class="flex items-center gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<span class="text-gray-400 tabular-nums w-12 shrink-0">
|
||||||
|
{{ fmtClock(it.created_at) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="it.entity === 'lead'"
|
||||||
|
type="button"
|
||||||
|
class="font-mono text-sky-600 hover:underline"
|
||||||
|
@click="gotoLead(it.entity_id)"
|
||||||
|
>
|
||||||
|
{{ shortId(it.entity_id) }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="font-mono text-gray-600">
|
||||||
|
{{ it.entity_id }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user