From ac4f554e34db32598388b3c05d7460f01e716b6b Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 25 May 2026 14:55:51 +0300 Subject: [PATCH] =?UTF-8?q?fix(admin-ui):=20reviews=20drawer=20=E2=80=94?= =?UTF-8?q?=20dict=20link,=20live=20FK,=20JSON=20syntax+diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три проблемы по reviews-drawer (по фидбеку: «Открыть ломается / Согласование не показывает справочник / не показывает live / JSON не подсвечен»): 1. useLiveRecord(draft.dictionaryId, ...) → useLiveRecord(dictName, ...). Endpoint /api/v1/dictionaries/{name}/records/{bk} ожидает name, не UUID; до фикса валился с 404 «Dictionary not found: » → live-блок был пустой и показывался "noLive" даже для UPDATE/CLOSE drafts. 2. Header drawer'а теперь рендерит " / " вместо просто businessKey. dictName — link на /dictionaries/$name. Fallback на UUID-prefix без link'а если dict не виден (scope-hide). 3. JsonView (новый компонент, ~120 строк, без deps) заменяет голые
   JSON.stringify(...):
   - syntax highlight: ключи (aurora), строки (pink), числа (warn),
     true/false/null (accent), пунктуация (mute)
   - diff highlight: top-level ключи из shallowDiffSummary получают
     фон+border (зелёный added / красный removed / жёлтый changed).
     В live-колонке — removed+changed, в proposed — added+changed.

Также добавлен error-state для liveQ (раньше 404 от useLiveRecord на UUID
молча превращался в "noLive"; теперь explicit "не удалось загрузить").
---
 ordinis-admin-ui/src/components/JsonView.tsx | 134 +++++++++++++++++++
 ordinis-admin-ui/src/routes/reviews.tsx      | 117 +++++++++++-----
 2 files changed, 217 insertions(+), 34 deletions(-)
 create mode 100644 ordinis-admin-ui/src/components/JsonView.tsx

diff --git a/ordinis-admin-ui/src/components/JsonView.tsx b/ordinis-admin-ui/src/components/JsonView.tsx
new file mode 100644
index 0000000..36270eb
--- /dev/null
+++ b/ordinis-admin-ui/src/components/JsonView.tsx
@@ -0,0 +1,134 @@
+import { useMemo, type ReactNode } from 'react'
+
+/**
+ * Лёгкий JSON viewer с token-based syntax highlight + diff-подсветкой
+ * top-level ключей. Никаких внешних зависимостей: regex-проход по
+ * `JSON.stringify(..., null, 2)`.
+ *
+ * 

Syntax: ключи (aurora), строки (pink), числа (warn), true/false/null + * (accent), пунктуация (mute). + * + *

Diff (опционально): передай diff — массив { kind, keys }. Строки + * JSON где key в added/removed/changed получают bg-цвет (зелёный / + * красный / жёлтый). Подсветка — top-level only (для глубокого diff'а + * есть DraftDiffSummary). + */ +export type JsonDiff = { + added?: Set | readonly string[] + removed?: Set | readonly string[] + changed?: Set | readonly string[] +} + +type JsonViewProps = { + value: unknown + diff?: JsonDiff + className?: string +} + +const TOKEN_RE = + /("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(true|false|null)|([{}[\],])/g + +const toSet = (v: Set | readonly string[] | undefined): Set => + v instanceof Set ? v : new Set(v ?? []) + +const tokenize = (line: string): ReactNode[] => { + const out: ReactNode[] = [] + let last = 0 + let i = 0 + for (const m of line.matchAll(TOKEN_RE)) { + const idx = m.index ?? 0 + if (idx > last) out.push(line.slice(last, idx)) + const [whole, str, colon, num, lit, punct] = m + if (str && colon) { + out.push( + + {str} + , + ) + out.push( + + {colon} + , + ) + } else if (str) { + out.push( + + {str} + , + ) + } else if (num) { + out.push( + + {num} + , + ) + } else if (lit) { + out.push( + + {lit} + , + ) + } else if (punct) { + out.push( + + {punct} + , + ) + } else { + out.push(whole) + } + last = idx + whole.length + i++ + } + if (last < line.length) out.push(line.slice(last)) + return out +} + +const LINE_KEY_RE = /^ {2}"((?:\\.|[^"\\])*)":/ + +const lineDiffClass = ( + line: string, + added: Set, + removed: Set, + changed: Set, +): string => { + const m = LINE_KEY_RE.exec(line) + if (!m) return '' + const k = m[1] + if (added.has(k)) return 'bg-aurora/15 -mx-2 px-2 border-l-2 border-aurora' + if (removed.has(k)) return 'bg-danger/15 -mx-2 px-2 border-l-2 border-danger' + if (changed.has(k)) return 'bg-warn/15 -mx-2 px-2 border-l-2 border-warn' + return '' +} + +export const JsonView = ({ value, diff, className }: JsonViewProps) => { + const added = useMemo(() => toSet(diff?.added), [diff?.added]) + const removed = useMemo(() => toSet(diff?.removed), [diff?.removed]) + const changed = useMemo(() => toSet(diff?.changed), [diff?.changed]) + + const lines = useMemo(() => { + try { + return JSON.stringify(value, null, 2).split('\n') + } catch { + return [String(value)] + } + }, [value]) + + return ( +

+      {lines.map((line, i) => {
+        const dc = lineDiffClass(line, added, removed, changed)
+        return (
+          
+ {tokenize(line)} +
+ ) + })} +
+ ) +} diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index 1d0e84c..6481435 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -45,6 +45,7 @@ import { classifyRecordStatus, classifySchemaStatus, } from '@/components/reviews/MyDraftsPanel' +import { JsonView } from '@/components/JsonView' export const Route = createFileRoute('/reviews')({ component: ReviewsPage, @@ -638,12 +639,21 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) { const currentSub = auth.user?.profile?.sub const draftQ = useDraft(draftId) const draft = draftQ.data + // DraftResponse несёт только dictionaryId (UUID); /api/v1/dictionaries/{name}/... + // ожидает имя словаря. Резолвим через useDictionaries (TanStack кеш). + // До этого useLiveRecord(draft.dictionaryId, ...) валился с 404 + // "Dictionary not found: " → live-блок drawer'а оставался пустым. + const dictionariesQ = useDictionaries() + const dictName = useMemo( + () => dictionariesQ.data?.find((d) => d.id === draft?.dictionaryId)?.name, + [dictionariesQ.data, draft?.dictionaryId], + ) // CREATE-черновик не имеет live-записи по определению — не пробуем её - // загружать (GET /dictionaries/{id}/records/{key} → 404, красный шум в + // загружать (GET /dictionaries/{name}/records/{key} → 404, красный шум в // консоли на каждый CREATE-draft). Для UPDATE/CLOSE — грузим как раньше. const isCreateDraft = draft?.operation === 'CREATE' const liveQ = useLiveRecord( - draft && !isCreateDraft ? draft.dictionaryId : undefined, + draft && !isCreateDraft ? dictName : undefined, draft && !isCreateDraft ? draft.businessKey : undefined, ) const approveMut = useApproveDraft() @@ -773,6 +783,20 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
{draft.operation} + {dictName ? ( + + {dictName} + + ) : ( + + {draft.dictionaryId.slice(0, 8)}… + + )} + / {draft.businessKey}
@@ -832,38 +856,63 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) { operation={draft.operation} /> -
-
-

- {t('reviews.drawer.live')} -

- {liveQ.isLoading && } - {(liveQ.data === null || isCreateDraft) && ( -

- {t('reviews.drawer.noLive')} -

- )} - {liveQ.data && ( -
-                    {JSON.stringify(liveQ.data.data, null, 2)}
-                  
- )} -
-
-

- {t('reviews.drawer.proposed')} -

- {draft.operation === 'CLOSE' ? ( -

- {t('reviews.drawer.closeNote')} -

- ) : ( -
-                    {JSON.stringify(draft.data, null, 2)}
-                  
- )} -
-
+ {(() => { + // Считаем diff один раз для обеих колонок: changed/added/removed + // keys (top-level) подсветим через JsonView в обоих side-by-side + // блоках — reviewer видит точку изменения визуально, не сравнивая + // глазами длинные JSON-объекты. + const liveData = + (liveQ.data?.data as Record | null | undefined) ?? null + const proposedData = + (draft.data as Record | null | undefined) ?? null + const diff = + draft.operation === 'CLOSE' + ? null + : shallowDiffSummary(liveData, proposedData) + const liveDiff = diff + ? { removed: diff.removed, changed: diff.changed } + : undefined + const proposedDiff = diff + ? { added: diff.added, changed: diff.changed } + : undefined + return ( +
+
+

+ {t('reviews.drawer.live')} +

+ {liveQ.isLoading && } + {liveQ.error && ( +

+ {t('reviews.drawer.liveError', { + defaultValue: 'Не удалось загрузить текущую запись', + })} +

+ )} + {!liveQ.isLoading && !liveQ.error && (liveQ.data === null || isCreateDraft) && ( +

+ {t('reviews.drawer.noLive')} +

+ )} + {liveQ.data && ( + + )} +
+
+

+ {t('reviews.drawer.proposed')} +

+ {draft.operation === 'CLOSE' ? ( +

+ {t('reviews.drawer.closeNote')} +

+ ) : ( + + )} +
+
+ ) + })()} {(approveMut.error || rejectMut.error || withdrawMut.error) && (() => { const err = approveMut.error || rejectMut.error || withdrawMut.error