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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user