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:
parent
18b36dd3d7
commit
7a4f574a38
@ -17,18 +17,35 @@ const entries = ref<AuditEntry[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
lead: 'Лид',
|
||||
user: 'Пользователь',
|
||||
system: 'Система',
|
||||
lead: 'лида',
|
||||
user: 'пользователя',
|
||||
system: 'систему',
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: 'создан',
|
||||
delete: 'удалён',
|
||||
reassign: 'переназначен',
|
||||
reset_password: 'сменён пароль',
|
||||
change_role: 'изменена роль',
|
||||
recalc: 'пересчёт score',
|
||||
interface ActionMeta {
|
||||
label: string;
|
||||
tone: 'danger' | 'success' | 'neutral' | 'warning';
|
||||
icon: string;
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -52,12 +69,8 @@ function reset() {
|
||||
filters.entity_id = '';
|
||||
}
|
||||
|
||||
// Auto-filter: react to entity change immediately, debounce entity_id input.
|
||||
let idTimer: number | null = null;
|
||||
watch(
|
||||
() => filters.entity,
|
||||
() => load(),
|
||||
);
|
||||
watch(() => filters.entity, () => load());
|
||||
watch(
|
||||
() => 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');
|
||||
}
|
||||
|
||||
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) {
|
||||
if (e.entity === 'lead') {
|
||||
router.push({ name: 'lead-card', params: { id: e.entity_id } });
|
||||
@ -82,11 +153,11 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto space-y-6">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Аудит</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Журнал изменений: кто, что, когда. Последние 200 событий.
|
||||
Кто что делал в системе. Последние 200 событий.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -115,7 +186,7 @@ onMounted(load);
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card p-4">
|
||||
<Skeleton :rows="6" height="36px" />
|
||||
<Skeleton :rows="6" height="44px" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="empty"
|
||||
@ -123,49 +194,69 @@ onMounted(load);
|
||||
>
|
||||
Событий не найдено.
|
||||
</div>
|
||||
<div v-else class="card overflow-hidden">
|
||||
<table class="table-base">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-44">Когда</th>
|
||||
<th class="w-28">Кто</th>
|
||||
<th class="w-28">Сущность</th>
|
||||
<th>Действие · ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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">
|
||||
{{ fmtTime(e.created_at) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm font-medium">{{ e.actor }}</td>
|
||||
<td class="px-4 py-2 text-xs">
|
||||
<span class="badge bg-gray-100 text-gray-700">
|
||||
{{ ENTITY_LABELS[e.entity] ?? e.entity }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm">
|
||||
<span class="text-gray-900">
|
||||
{{ ACTION_LABELS[e.action] ?? e.action }}
|
||||
</span>
|
||||
<div v-else class="space-y-8">
|
||||
<div
|
||||
v-for="group in grouped"
|
||||
:key="group.day"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="sticky top-16 z-10 bg-gradient-to-b from-white via-white to-white/0 pb-1">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-widest text-gray-400">
|
||||
{{ group.day }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card divide-y divide-gray-100">
|
||||
<div
|
||||
v-for="e in group.items"
|
||||
:key="e.id"
|
||||
class="flex items-center gap-4 px-4 py-3 hover:bg-gray-50/70 transition"
|
||||
>
|
||||
<div
|
||||
class="w-9 h-9 rounded-full flex items-center justify-center text-base ring-1 shrink-0"
|
||||
:class="[
|
||||
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 }}
|
||||
</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
|
||||
v-if="e.entity === 'lead'"
|
||||
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)"
|
||||
>
|
||||
{{ e.entity_id.slice(0, 8) }}…
|
||||
{{ shortId(e.entity_id) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="ml-2 text-xs text-gray-500 font-mono"
|
||||
<span v-else class="font-mono text-gray-600">{{ e.entity_id }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-xs text-gray-400 tabular-nums shrink-0"
|
||||
:title="fmtFullTime(e.created_at)"
|
||||
>
|
||||
{{ e.entity_id }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{ relativeTime(e.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user