ux(audit): timeline redesign — grouped by day, action icons, relative time

Replaces the audit table with a timeline-style activity stream:
- Entries are grouped under sticky day headers (Сегодня / Вчера /
  full date).
- Each row carries a coloured action chip (icon + tone:
  danger/success/warning/neutral) so destructive vs constructive
  events are scannable at a glance.
- Actor is shown with an initials avatar and name; the message is
  a single sentence ("rop удалил лида ceb8fbfa…").
- Timestamps switch to relative ("2 ч назад", "3 дн назад") with
  the full ISO datetime in title for hover.
- Lead IDs stay clickable and route to the lead card.
Filters (entity + entity_id) unchanged; "Сброс" preserved.
This commit is contained in:
Telecom 2026-05-22 13:44:13 +05:00
parent 18b36dd3d7
commit 7a4f574a38

View File

@ -17,18 +17,35 @@ const entries = ref<AuditEntry[]>([]);
const loading = ref(false); const loading = ref(false);
const ENTITY_LABELS: Record<string, string> = { const ENTITY_LABELS: Record<string, string> = {
lead: 'Лид', lead: 'лида',
user: 'Пользователь', user: 'пользователя',
system: 'Система', system: 'систему',
}; };
const ACTION_LABELS: Record<string, string> = { interface ActionMeta {
create: 'создан', label: string;
delete: 'удалён', tone: 'danger' | 'success' | 'neutral' | 'warning';
reassign: 'переназначен', icon: string;
reset_password: 'сменён пароль', }
change_role: 'изменена роль',
recalc: 'пересчёт score', const ACTIONS: Record<string, ActionMeta> = {
create: { label: 'создал', tone: 'success', icon: '+' },
delete: { label: 'удалил', tone: 'danger', icon: '🗑' },
reassign: { label: 'переназначил', tone: 'warning', icon: '↻' },
reset_password: { label: 'сменил пароль', tone: 'warning', icon: '🔑' },
change_role: { label: 'изменил роль', tone: 'warning', icon: '👤' },
recalc: { label: 'пересчитал score', tone: 'neutral', icon: '⟳' },
};
function actionMeta(a: string): ActionMeta {
return ACTIONS[a] ?? { label: a, tone: 'neutral', icon: '•' };
}
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() {
@ -52,12 +69,8 @@ function reset() {
filters.entity_id = ''; filters.entity_id = '';
} }
// Auto-filter: react to entity change immediately, debounce entity_id input.
let idTimer: number | null = null; let idTimer: number | null = null;
watch( watch(() => filters.entity, () => load());
() => filters.entity,
() => load(),
);
watch( watch(
() => filters.entity_id, () => filters.entity_id,
() => { () => {
@ -66,10 +79,68 @@ watch(
}, },
); );
function fmtTime(s: string): string { function fmtFullTime(s: string): string {
return new Date(s).toLocaleString('ru-RU'); return new Date(s).toLocaleString('ru-RU');
} }
function relativeTime(s: string): string {
const now = Date.now();
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 {
const d = new Date(s);
const today = new Date();
today.setHours(0, 0, 0, 0);
const yest = new Date(today.getTime() - 86_400_000);
const dDay = new Date(d);
dDay.setHours(0, 0, 0, 0);
if (dDay.getTime() === today.getTime()) return 'Сегодня';
if (dDay.getTime() === yest.getTime()) return 'Вчера';
return d.toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
}
const grouped = computed(() => {
const groups: { day: string; items: AuditEntry[] }[] = [];
let cur: { day: string; items: AuditEntry[] } | null = null;
for (const e of entries.value) {
const d = dayBucket(e.created_at);
if (!cur || cur.day !== d) {
cur = { day: d, items: [] };
groups.push(cur);
}
cur.items.push(e);
}
return groups;
});
function initials(name: string): string {
const parts = name.split(/[\s._-]+/).filter(Boolean).slice(0, 2);
return parts.map((p) => p[0]?.toUpperCase() ?? '').join('') || name[0]?.toUpperCase() || '?';
}
function shortId(id: string): string {
return id.length > 10 ? id.slice(0, 8) + '…' : id;
}
function gotoEntity(e: AuditEntry) { function gotoEntity(e: AuditEntry) {
if (e.entity === 'lead') { if (e.entity === 'lead') {
router.push({ name: 'lead-card', params: { id: e.entity_id } }); router.push({ name: 'lead-card', params: { id: e.entity_id } });
@ -82,11 +153,11 @@ onMounted(load);
</script> </script>
<template> <template>
<div class="max-w-5xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Аудит</h1> <h1 class="text-2xl font-semibold tracking-tight">Аудит</h1>
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
Журнал изменений: кто, что, когда. Последние 200 событий. Кто что делал в системе. Последние 200 событий.
</p> </p>
</div> </div>
@ -115,7 +186,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="36px" /> <Skeleton :rows="6" height="44px" />
</div> </div>
<div <div
v-else-if="empty" v-else-if="empty"
@ -123,49 +194,69 @@ onMounted(load);
> >
Событий не найдено. Событий не найдено.
</div> </div>
<div v-else class="card overflow-hidden"> <div v-else class="space-y-8">
<table class="table-base"> <div
<thead> v-for="group in grouped"
<tr> :key="group.day"
<th class="w-44">Когда</th> class="space-y-3"
<th class="w-28">Кто</th> >
<th class="w-28">Сущность</th> <div class="sticky top-16 z-10 bg-gradient-to-b from-white via-white to-white/0 pb-1">
<th>Действие · ID</th> <div class="text-[11px] font-semibold uppercase tracking-widest text-gray-400">
</tr> {{ group.day }}
</thead> </div>
<tbody> </div>
<tr v-for="e in entries" :key="e.id" class="hover:bg-gray-50/60">
<td class="px-4 py-2 text-xs text-gray-600 tabular-nums"> <div class="card divide-y divide-gray-100">
{{ fmtTime(e.created_at) }} <div
</td> v-for="e in group.items"
<td class="px-4 py-2 text-sm font-medium">{{ e.actor }}</td> :key="e.id"
<td class="px-4 py-2 text-xs"> class="flex items-center gap-4 px-4 py-3 hover:bg-gray-50/70 transition"
<span class="badge bg-gray-100 text-gray-700"> >
{{ ENTITY_LABELS[e.entity] ?? e.entity }} <div
</span> class="w-9 h-9 rounded-full flex items-center justify-center text-base ring-1 shrink-0"
</td> :class="[
<td class="px-4 py-2 text-sm"> TONE_CLASSES[actionMeta(e.action).tone].bg,
<span class="text-gray-900"> TONE_CLASSES[actionMeta(e.action).tone].ring,
{{ ACTION_LABELS[e.action] ?? e.action }} TONE_CLASSES[actionMeta(e.action).tone].text,
</span> ]"
:title="actionMeta(e.action).label"
>
{{ actionMeta(e.action).icon }}
</div>
<div class="flex items-center gap-2 shrink-0">
<div
class="w-7 h-7 rounded-full bg-gray-100 text-gray-700 flex items-center justify-center text-[11px] font-semibold"
:title="e.actor"
>
{{ initials(e.actor) }}
</div>
<span class="text-sm font-medium text-gray-900">{{ e.actor }}</span>
</div>
<div class="flex-1 min-w-0 text-sm text-gray-700 truncate">
<span>{{ actionMeta(e.action).label }}</span>
<span class="text-gray-500"> {{ ENTITY_LABELS[e.entity] ?? e.entity }} </span>
<button <button
v-if="e.entity === 'lead'" v-if="e.entity === 'lead'"
type="button" type="button"
class="ml-2 text-xs text-sky-600 hover:underline font-mono" class="font-mono text-sky-600 hover:underline"
@click="gotoEntity(e)" @click="gotoEntity(e)"
> >
{{ e.entity_id.slice(0, 8) }}… {{ shortId(e.entity_id) }}
</button> </button>
<span <span v-else class="font-mono text-gray-600">{{ e.entity_id }}</span>
v-else </div>
class="ml-2 text-xs text-gray-500 font-mono"
> <div
{{ e.entity_id }} class="text-xs text-gray-400 tabular-nums shrink-0"
</span> :title="fmtFullTime(e.created_at)"
</td> >
</tr> {{ relativeTime(e.created_at) }}
</tbody> </div>
</table> </div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>