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}
-
+
+
+
+ Каждый 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
- События для справочника {dictName} — в общем аудит-логе. -
- - Открыть /audit с фильтром → - +| {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', + })} + | +
+ |
+ {r.businessKey ?? '—'} | +{r.userId ?? 'anonymous'} | +