Merge branch 'feat/stage-3.4-events-json-polish' into 'main'

feat(editor): Stage 3.4 polish — Events embedded + JSON highlight

See merge request 2-6/2-6-4/terravault/ordinis!54
This commit is contained in:
Александр Зимин
2026-05-11 12:28:37 +00:00
@@ -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 (
<pre className="rounded-lg border border-line bg-surface-2 p-4 overflow-x-auto text-[12px] font-mono text-ink whitespace-pre">
{json}
</pre>
<div className="relative">
<button
type="button"
onClick={handleCopy}
className="absolute top-2 right-2 z-10 px-2.5 py-1 rounded-sm border border-line bg-surface text-cap hover:border-accent transition-colors"
>
{copied ? t('json.copied', { defaultValue: 'Скопировано' }) : t('json.copy', { defaultValue: 'Копировать' })}
</button>
<pre className="rounded-lg border border-line bg-surface-2 p-4 overflow-x-auto text-[12px] font-mono text-ink whitespace-pre">
<code dangerouslySetInnerHTML={{ __html: highlightJson(json) }} />
</pre>
</div>
)
}
/** 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 не нужен).
*
* <p>Каждый escape перед взаимодействием: source already JSON.stringify'd
* (safe), но regex применяется к HTML так что & < > > сначала escape'аются.
*/
function highlightJson(json: string): string {
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 `<span class="${cls}">${match}</span>`
},
)
}
/**
* Events tab — embedded last 20 audit entries filtered by dictName.
* Stage 3.4 polish: handoff требовал inline list вместо redirect к /audit.
*
* <p>Не показывает 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 <LoadingBlock size="md" label={t('loading')} />
if (error)
return (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)
const rows = data?.content ?? []
if (rows.length === 0)
return (
<EmptyState
title={t('events.empty.title', { defaultValue: 'Событий пока нет' })}
description={t('events.empty.description', {
defaultValue: 'Любая правка записей справочника появится здесь.',
})}
/>
)
return (
<div className="rounded-lg border border-line bg-surface p-6 text-center space-y-3">
<p className="text-body text-ink-2">
События для справочника <span className="font-mono">{dictName}</span> в общем аудит-логе.
</p>
<Link
to="/audit"
search={{ dict: dictName }}
className="inline-flex items-center gap-1 text-accent hover:underline text-body"
>
Открыть /audit с фильтром
</Link>
<div className="space-y-3">
<div className="rounded-lg border border-line overflow-hidden">
<table className="w-full">
<thead className="bg-surface-2">
<tr>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.time', { defaultValue: 'time' })}</th>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.action', { defaultValue: 'action' })}</th>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.businessKey', { defaultValue: 'record' })}</th>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.user', { defaultValue: 'user' })}</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={`${r.id}`} className="border-t border-line-2 hover:bg-surface-2/40">
<td className="text-cell tabular-nums px-3 py-1.5">
{time.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</td>
<td className="px-3 py-1.5">
<Badge variant={variant}>
{t(`audit.action.${r.action}`, r.action)}
</Badge>
</td>
<td className="text-mono px-3 py-1.5">{r.businessKey ?? '—'}</td>
<td className="text-cell px-3 py-1.5 text-ink-2">{r.userId ?? 'anonymous'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="text-right">
<Link
to="/audit"
search={{ dict: dictName }}
className="inline-flex items-center gap-1 text-accent hover:underline text-body"
>
{t('events.viewAll', { defaultValue: 'Полный аудит-лог →' })}
</Link>
</div>
</div>
)
}