From f0392af0e37a06f784cb18558f8fb79c1b8bce24 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 15:28:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(editor):=20Stage=203.4=20polish=20?= =?UTF-8?q?=E2=80=94=20Events=20embedded=20+=20JSON=20syntax=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per handoff Stage 3.4 polish list (Records/Relations/Fields/JSON/Events/ History tabs). Events tab — раньше был redirect к /audit?dict=name. Теперь embedded: - useAudit({dictionaryName, size: 20}) → last 20 entries - 4-col compact table: time / action / businessKey / user - Empty state когда нет событий - Bottom link → /audit с filter для full diff view JSON tab — раньше был plain
. Теперь:
- Regex-based syntax highlight (keys=accent, strings=green, numbers=warn,
  booleans=pink, null=mute italic)
- Copy-to-clipboard button с visual feedback
- Bundle cost: 0 (нет Monaco). Если потребуется EDIT — Monaco lazy
  через React.lazy в Stage 3.x followup.

Fields tab — пока read-only table остаётся. Inline edit требует backend
PATCH /dictionaries/{name}/schema endpoint; deferred.

History tab — placeholder remains. Требует backend changelog endpoint;
deferred.

TS strict + 116 tests pass.
---
 .../src/routes/dictionaries.$name.tsx         | 159 ++++++++++++++++--
 1 file changed, 142 insertions(+), 17 deletions(-)

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: 'Полный аудит-лог →' })} + +
) }