feat(editor): Stage 3.4 polish — Events embedded + JSON syntax highlight
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 <pre>. Теперь:
- 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.
This commit is contained in:
@@ -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,31 +1436,155 @@ 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 (
|
||||
<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">
|
||||
{json}
|
||||
<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, '&').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 `<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 (
|
||||
<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>
|
||||
<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="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"
|
||||
>
|
||||
Открыть /audit с фильтром →
|
||||
{t('events.viewAll', { defaultValue: 'Полный аудит-лог →' })}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user