Показать понятный аудит в консоли оператора

Названия событий, акторов, параметров и значений вынесены в ресурсы ru.js (operator.event/actor/field/value) с заделом под языки. Таблица аудита показывает понятное название действия, сырой ключ вторично, изменения «было → стало» и сырой JSON под спойлером. В колонке пользователя основное — имя, id мелко под ним. Термин «тенант» в UI оператора заменён на «Пользователь».
This commit is contained in:
2026-09-13 19:18:06 +03:00
parent 02a4f9c749
commit ad5fa7c083
3 changed files with 192 additions and 37 deletions
@@ -1,5 +1,6 @@
<script setup>
// Фильтры лент аудита/действий (общие для разделов «Аудит» и «Аналитика»).
import { t } from '@/i18n/index.js'
import { AUDIT_EVENT_TYPES, ACTOR_TYPES } from '../../store/operator.js'
import SelectInput from '../ui/SelectInput.vue'
import TextInput from '../ui/TextInput.vue'
@@ -10,8 +11,20 @@ const props = defineProps({
const emit = defineEmits(['update:filters'])
const eventOptions = AUDIT_EVENT_TYPES.map((t) => ({ value: t, label: t }))
const actorOptions = ACTOR_TYPES.map((t) => ({ value: t, label: t }))
// Локализует код по ключу, возвращая сам код при отсутствии перевода.
function translate(key, fallback) {
const label = t(key)
return label === key ? fallback : label
}
const eventOptions = AUDIT_EVENT_TYPES.map((code) => ({
value: code,
label: translate(`operator.event.${code}`, code),
}))
const actorOptions = ACTOR_TYPES.map((code) => ({
value: code,
label: translate(`operator.actor.${code}`, code),
}))
function set(key, value) {
emit('update:filters', { ...props.filters, [key]: value })
@@ -1,7 +1,7 @@
<script setup>
import { t } from '@/i18n/index.js'
// Таблица записей аудита (AuditRecordDto): общая для /audit и analytics/activity.
// detailJson — строка JSON; раскрывается по клику на тип события.
// Таблица записей аудита (AuditRecordViewDto): общая для /audit и analytics/activity.
// Названия событий/акторов/параметров — из ресурсов; сырой ключ события — вторично.
import { ref } from 'vue'
import { fmtDateTimeCompact } from '../../store/core.js'
import Badge from '../ui/Badge.vue'
@@ -21,12 +21,43 @@ const COLUMNS = [
{ key: 'at', label: t('operator.vremya'), align: 'right' },
]
const expanded = ref(-1)
const ACTOR_TONES = { operator: 'brand', tenant: 'neutral', system: 'warn' }
const expanded = ref(-1)
const jsonOpen = ref(-1)
function toggle(index) {
expanded.value = expanded.value === index ? -1 : index
if (expanded.value !== index) jsonOpen.value = -1
}
function toggleJson(index) {
jsonOpen.value = jsonOpen.value === index ? -1 : index
}
// Локализует код по ключу, возвращая сам код при отсутствии перевода.
function translate(key, fallback) {
const label = t(key)
return label === key ? fallback : label
}
const eventLabel = (code) => translate(`operator.event.${code}`, code)
const actorLabel = (code) => translate(`operator.actor.${code}`, code)
const fieldLabel = (field) => translate(`operator.field.${field}`, field)
function valueLabel(field, raw) {
if (raw === null || raw === undefined || raw === '') return '—'
if (raw === 'true') return t('operator.da')
if (raw === 'false') return t('operator.net')
return translate(`operator.value.${field}.${raw}`, raw)
}
function changesOf(row) {
return Array.isArray(row.changes) ? row.changes : []
}
function hasFrom(change) {
return change.from !== null && change.from !== undefined
}
function pretty(json) {
@@ -52,19 +83,47 @@ function pretty(json) {
<span class="inline-block text-[10px]" :class="expanded === index ? 'rotate-90' : ''"></span>
</button>
<div class="min-w-0">
<div class="font-mono text-[12px] text-hi">{{ row.eventType }}</div>
<pre
v-if="expanded === index && row.detailJson"
class="mt-1.5 max-w-[420px] max-h-40 overflow-auto rounded-md bg-ink/70 border border-white/8 p-2 text-[10.5px] text-mid"
>{{ pretty(row.detailJson) }}</pre>
<div class="text-[12.5px] text-hi">{{ eventLabel(row.eventType) }}</div>
<div class="font-mono text-[10.5px] text-low mt-0.5">{{ row.eventType }}</div>
<div
v-if="expanded === index"
class="mt-1.5 max-w-[460px] rounded-md bg-ink/70 border border-white/8 p-2"
>
<div v-if="changesOf(row).length" class="space-y-0.5">
<div v-for="(change, i) in changesOf(row)" :key="i" class="text-[11px] text-mid">
<span class="text-low">{{ fieldLabel(change.field) }}:</span>
<template v-if="hasFrom(change)">
<span class="text-low">{{ valueLabel(change.field, change.from) }}</span>
<span class="text-low/60"> </span>
</template>
<span class="text-hi">{{ valueLabel(change.field, change.to) }}</span>
</div>
</div>
<div v-else class="text-[11px] text-low">{{ $t('operator.dopolnitelnyh-parametrov-net') }}</div>
<button
v-if="row.detailJson"
class="mt-1.5 text-[10.5px] text-low hover:text-mid transition-colors"
@click.stop="toggleJson(index)"
>
{{ jsonOpen === index ? $t('operator.skryt-json') : $t('operator.pokazat-json') }}
</button>
<pre
v-if="jsonOpen === index"
class="mt-1 max-w-[420px] max-h-40 overflow-auto rounded-md bg-ink/70 border border-white/8 p-2 text-[10.5px] text-mid"
>{{ pretty(row.detailJson) }}</pre>
</div>
</div>
</div>
</template>
<template #cell-actorType="{ row }">
<Badge :tone="ACTOR_TONES[row.actorType] || 'neutral'">{{ row.actorType }}</Badge>
<Badge :tone="ACTOR_TONES[row.actorType] || 'neutral'">{{ actorLabel(row.actorType) }}</Badge>
</template>
<template #cell-tenantId="{ row }">
<span class="font-mono text-[11px] text-mid">{{ row.tenantId || '—' }}</span>
<div v-if="row.tenantName" class="min-w-0">
<div class="text-[12px] text-hi truncate">{{ row.tenantName }}</div>
<div class="font-mono text-[10.5px] text-low truncate">{{ row.tenantId }}</div>
</div>
<span v-else class="font-mono text-[11px] text-low">{{ row.tenantId || '—' }}</span>
</template>
<template #cell-ip="{ row }">
<span class="font-mono text-[11px] text-low">{{ row.ip || '—' }}</span>
+107 -24
View File
@@ -773,7 +773,7 @@ export const ru = {
"nedostupna": "недоступна",
"da": "да",
"net": "нет",
"tenant": "Тенант",
"tenant": "Пользователь",
"razreshen": "Разрешён",
"zamorozhen": "Заморожен",
"operator": "оператор",
@@ -784,7 +784,7 @@ export const ru = {
"vse-sobytiya": "Все события",
"aktor": "Актор",
"vse-aktory": "Все акторы",
"id-tenanta": "ID тенанта",
"id-tenanta": "ID пользователя",
"id-aktora": "ID актора",
"s": "С",
"po": "По",
@@ -796,10 +796,10 @@ export const ru = {
"tokeny": "Токены",
"dejstviya": "Действия",
"po-dnyam": "По дням",
"po-tenantam": "По тенантам",
"po-tenantam": "По пользователям",
"po-provajderam": "По провайдерам",
"po-modelyam": "По моделям",
"tenanty": "Тенанты",
"tenanty": "Пользователи",
"aktivnyh-vsego": "активных / всего",
"vsego-tokenov": "Всего токенов",
"tokeny-zaprosa": "Токены запроса",
@@ -814,7 +814,7 @@ export const ru = {
"period": "Период",
"rashod-tokenov": "Расход токенов",
"gruppirovka": "Группировка",
"id-tenanta-filtr": "ID тенанта (фильтр)",
"id-tenanta-filtr": "ID пользователя (фильтр)",
"itogo-tokenov": "Итого токенов",
"zapros": "запрос",
"otvet": "·\r\n ответ",
@@ -826,6 +826,89 @@ export const ru = {
"audit": "Аудит",
"zhurnal-dejstvij-polzovatelej-operatorov": "Журнал действий пользователей, операторов и системы",
"sobytij-po-filtru-net": "Событий по фильтру нет",
"pokazat-json": "Показать JSON",
"skryt-json": "Скрыть JSON",
"dopolnitelnyh-parametrov-net": "Дополнительных параметров нет",
"actor": {
"operator": "Оператор",
"tenant": "Пользователь",
"system": "Система"
},
"event": {
"tenant_login_ok": "Вход пользователя",
"tenant_login_failed": "Неудачный вход пользователя",
"tenant_logout": "Выход пользователя",
"operator_login_ok": "Вход оператора",
"operator_login_failed": "Неудачный вход оператора",
"operator_logout": "Выход оператора",
"invite_joined": "Регистрация по приглашению",
"invite_created": "Создано приглашение",
"invite_revoked": "Отозвано приглашение",
"invite_activated": "Приглашение активировано",
"card_created": "Создана карточка",
"card_moved": "Карточка перемещена",
"card_trashed": "Карточка в корзине",
"card_restored": "Карточка восстановлена",
"card_deleted": "Карточка удалена",
"card_comment_added": "Комментарий к карточке",
"card_reclassified": "Карточки переклассифицированы",
"container_created": "Создана колонка",
"container_updated": "Изменена колонка",
"container_deleted": "Удалена колонка",
"settings_updated": "Изменены настройки",
"channel_enabled": "Включён мониторинг каналов",
"channel_created": "Канал добавлен",
"telegram_linked": "Подключён Telegram",
"telegram_keys_changed": "Изменены ключи Telegram",
"tenant_created": "Создан пользователь",
"tenant_status_changed": "Изменён статус пользователя",
"tenant_limit_changed": "Изменён лимит ИИ",
"impersonation_started": "Вход от имени пользователя",
"impersonation_stopped": "Завершён вход от имени пользователя"
},
"field": {
"login": "Логин",
"email": "Email",
"codeHash": "Код (хэш)",
"budget": "Бюджет токенов",
"period": "Период",
"apiId": "api_id",
"apiHashSet": "api_hash задан",
"name": "Имя",
"status": "Статус",
"targetLogin": "Вход",
"cardId": "Карточка",
"to": "Куда",
"col": "Колонка",
"containerId": "Колонка",
"attempted": "Обработано",
"reclassified": "Переклассифицировано",
"moved": "Перемещено",
"trashed": "Отправлено в корзину",
"fields": "Изменённые поля",
"all": "Все каналы",
"count": "Каналов",
"phase": "Этап",
"dialogId": "Диалог"
},
"value": {
"status": {
"active": "Активен",
"suspended": "Приостановлен"
},
"period": {
"month": "Месяц",
"day": "День"
},
"phase": {
"ready": "Готово",
"idle": "Не подключён",
"qr": "Ожидает QR",
"code": "Ожидает код",
"password": "Ожидает пароль",
"phone": "Ожидает телефон"
}
},
"v-poryadke": "в порядке",
"nezdorov": "нездоров",
"nedostupen": "недоступен",
@@ -849,7 +932,7 @@ export const ru = {
"priglasheniya": "Приглашения",
"sozdat-priglashenie": "Создать приглашение",
"priglashenij-poka-net": "Приглашений пока нет",
"novyj-tenant": "Новый тенант",
"novyj-tenant": "Новый пользователь",
"ssylka": "Ссылка",
"novoe-priglashenie": "Новое приглашение",
"kod-dejstvuet-72-chasa-ssylka-vedet-na": "Код действует 72 часа. Ссылка ведёт на страницу активации.",
@@ -857,7 +940,7 @@ export const ru = {
"sozdano-2": "создано.",
"ssylka-aktivacii": "Ссылка активации",
"email-priglashennogo": "Email приглашённого",
"sozdat-novyj-tenant-pri-aktivacii": "Создать новый тенант при активации",
"sozdat-novyj-tenant-pri-aktivacii": "Создать нового пользователя при активации",
"sozdat": "Создать",
"mesyac": "Месяц",
"den": "День",
@@ -876,32 +959,32 @@ export const ru = {
"perejti-v-osnovnoe-prilozhenie": "Перейти в основное приложение",
"v-prilozhenie": "В приложение",
"nevernyj-login-ili-parol-operatora": "Неверный логин или пароль оператора",
"upravlenie-tenantami-priglasheniyami": "Управление тенантами, приглашениями, лимитами и аналитикой",
"upravlenie-tenantami-priglasheniyami": "Управление пользователями, приглашениями, лимитами и аналитикой",
"dev-defolt": "Dev-дефолт:",
"polzovatelej": "Пользователей",
"sozdan": "Создан",
"priostanovit-tenant": "Приостановить тенант?",
"vozobnovit-tenant": "Возобновить тенант?",
"sozdat-tenanta": "Создать тенанта",
"tenantov-poka-net": "Тенантов пока нет",
"priostanovit-tenant": "Приостановить пользователя?",
"vozobnovit-tenant": "Возобновить пользователя?",
"sozdat-tenanta": "Создать пользователя",
"tenantov-poka-net": "Пользователей пока нет",
"detali": "Детали",
"email-vladelca-opcionalen-bez-nego": "Email владельца опционален: без него владелец заводится через приглашение.",
"tenant-2": "Тенант «",
"tenant-2": "Пользователь «",
"sozdan-2": "» создан.",
"odnorazovyj-parol-vladelca-pokazhetsya": "Одноразовый пароль владельца — покажется только сейчас",
"login": "Логин:",
"parol": "Пароль:",
"gotovo": "Готово",
"imya-tenanta": "Имя тенанта",
"imya-tenanta": "Имя пользователя",
"ooo-romashka": "ООО «Ромашка»",
"email-vladelca": "Email владельца",
"esli-ukazat-vladelec-s-odnorazovym": "Если указать — владелец с одноразовым паролем создаётся сразу",
"polzovateli": "Пользователи (",
"v-tenante-net-polzovatelej": "В тенанте нет пользователей.",
"vojti-pod-etim-polzovatelem-impersonation": "Войти под этим пользователем (impersonation)",
"vhod-ot-imeni-polzovatelya": "Вход от имени пользователя",
"polzovatel": "Пользователь:",
"servernaya-sessiya-tenant-polzovatelya": "Серверная сессия тенант-пользователя активна в этом браузере. Выход из неё — обычная кнопка\r\n выхода в приложении.",
"polzovateli": "Учётные записи (",
"v-tenante-net-polzovatelej": "Нет связанных учётных записей.",
"vojti-pod-etim-polzovatelem-impersonation": "Войти под этой учётной записью (impersonation)",
"vhod-ot-imeni-polzovatelya": "Вход от имени учётной записи",
"polzovatel": "Учётная запись:",
"servernaya-sessiya-tenant-polzovatelya": "Серверная сессия учётной записи активна в этом браузере. Выход из неё — обычная кнопка\r\n выхода в приложении.",
"perejti-v-prilozhenie": "Перейти в приложение",
"tokensLine": "запрос {prompt} · ответ {completion} · событий {events}",
"servicesCount": "Автономные сервисы: {count}",
@@ -926,14 +1009,14 @@ export const ru = {
"suspicious-failed-logins-ip": "Всплеск неудачных входов с одного IP",
"suspicious-failed-logins-login": "Всплеск неудачных входов по одному логину",
"suspicious-many-ips-actor": "Входы одного актора с множества IP",
"suspicious-auth-failures-tenant": "Повторные неудачные входы по тенанту",
"suspicious-auth-failures-tenant": "Повторные неудачные входы по пользователю",
"telegram": "Telegram",
"telegram-desc": "Глобальные ключи приложения Telegram для подключения аккаунтов тенантов",
"telegram-desc": "Глобальные ключи приложения Telegram для подключения аккаунтов пользователей",
"globalnye-klyuchi-telegram": "Глобальные ключи приложения",
"globalnye-klyuchi-telegram-desc": "Едины для всех тенантов: тенант подключает аккаунт, не видя сами ключи",
"globalnye-klyuchi-telegram-desc": "Едины для всех пользователей: пользователь подключает аккаунт, не видя сами ключи",
"klyuchi-zadany": "Ключи заданы",
"klyuchi-ne-zadany": "Ключи не заданы",
"klyuchi-ne-zadany-poyasnenie": "Пока оператор не задаст ключи, подключение аккаунтов Telegram у тенантов недоступно.",
"klyuchi-ne-zadany-poyasnenie": "Пока оператор не задаст ключи, подключение аккаунтов Telegram у пользователей недоступно.",
"api-id-placeholder": "числовой api_id из my.telegram.org",
"api-hash-placeholder": "буквенно-цифровой api_hash",
"api-id-hint": "5–9 цифр — идентификатор приложения из my.telegram.org",