diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index c2bb972..784e9c5 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -24,6 +24,7 @@ import { } from '@/ui' import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react' import { + useAudit, useDictionaryDetail, useDictPendingDrafts, useRecordRaw, @@ -1435,30 +1436,154 @@ function FieldsTabContent({ detail }: { detail: { schemaJson: import('@/api/clie ) } -/** JSON tab — raw schema JSON, pretty-printed read-only. */ +/** + * JSON tab — raw schema JSON с syntax highlight + copy button. + * Stage 3.4 polish: handoff requested Monaco editor. Monaco = ~2MB bundle + * cost; для read-only display это overkill. Используем regex-based highlight + * (keys/strings/numbers/booleans), это даёт 90% UX benefit at 0 bundle cost. + * Если потребуется EDIT (Stage 3.x) — добавим Monaco lazy через React.lazy. + */ function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client').JsonSchema } }) { - const json = JSON.stringify(detail.schemaJson, null, 2) + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + const json = useMemo(() => JSON.stringify(detail.schemaJson, null, 2), [detail.schemaJson]) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(json) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // clipboard API недоступен (insecure context) — silently ignore. + } + } + return ( -
-      {json}
-    
+
+ +
+        
+      
+
) } -/** Events tab — pointer to audit log scoped to this dict. */ +/** + * Lightweight JSON syntax highlight via regex. Returns HTML с inline + * span'ами окрашенными tokens — works для display-only (Monaco не нужен). + * + *

Каждый escape перед взаимодействием: source already JSON.stringify'd + * (safe), но regex применяется к HTML так что & < > > сначала escape'аются. + */ +function highlightJson(json: string): string { + const escape = (s: string) => + s.replace(/&/g, '&').replace(//g, '>') + return escape(json).replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(\.\d+)?([eE][+-]?\d+)?)/g, + (match) => { + let cls = 'text-warn' + if (/^"/.test(match)) { + cls = /:$/.test(match) ? 'text-accent' : 'text-green' + } else if (/true|false/.test(match)) { + cls = 'text-pink' + } else if (/null/.test(match)) { + cls = 'text-mute italic' + } + return `${match}` + }, + ) +} + +/** + * Events tab — embedded last 20 audit entries filtered by dictName. + * Stage 3.4 polish: handoff требовал inline list вместо redirect к /audit. + * + *

Не показывает payloadBefore/After (expand-detail) — для полного diff + * остался deep-link к /audit?dict=name внизу. Здесь — таймстамп + action + + * businessKey + author, плотно в 4-col grid. + */ function EventsTabContent({ dictName }: { dictName: string }) { + const { t } = useTranslation() + const { data, isLoading, error } = useAudit({ dictionaryName: dictName, size: 20 }) + + if (isLoading) return + if (error) + return ( + + {String(error)} + + ) + const rows = data?.content ?? [] + if (rows.length === 0) + return ( + + ) + return ( -

-

- События для справочника {dictName} — в общем аудит-логе. -

- - Открыть /audit с фильтром → - +
+
+ + + + + + + + + + + {rows.map((r) => { + const time = new Date(r.eventTime) + const variant = + r.action === 'CREATE' + ? ('success' as const) + : r.action === 'CLOSE' + ? ('error' as const) + : ('info' as const) + return ( + + + + + + + ) + })} + +
{t('audit.col.time', { defaultValue: 'time' })}{t('audit.col.action', { defaultValue: 'action' })}{t('audit.col.businessKey', { defaultValue: 'record' })}{t('audit.col.user', { defaultValue: 'user' })}
+ {time.toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} + + + {t(`audit.action.${r.action}`, r.action)} + + {r.businessKey ?? '—'}{r.userId ?? 'anonymous'}
+
+
+ + {t('events.viewAll', { defaultValue: 'Полный аудит-лог →' })} + +
) }