diff --git a/frontend/src/features/admin/AdminSystemConfigsPage.tsx b/frontend/src/features/admin/AdminSystemConfigsPage.tsx index 9d1605a..df375dd 100644 --- a/frontend/src/features/admin/AdminSystemConfigsPage.tsx +++ b/frontend/src/features/admin/AdminSystemConfigsPage.tsx @@ -13,7 +13,6 @@ import { type TableColumnsType, } from 'antd' import { - CodeOutlined, EditOutlined, ReloadOutlined, SafetyCertificateOutlined, @@ -23,6 +22,9 @@ import { fetchAdminSystemConfigs, updateAdminSystemConfig } from '@/features/adm import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' import { DataTable } from '@/features/admin/components/DataTable' import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import { describeJsonValue, parseJsonValue } from '@/features/admin/components/JsonEditor' +import { PublishOptionsEditor } from '@/features/admin/components/PublishOptionsEditor' +import { StructuredJsonEditor } from '@/features/admin/components/StructuredJsonEditor' import { systemConfigCategory, systemConfigCategoryLabel, @@ -39,18 +41,16 @@ function isSensitive(item: AdminSystemConfig) { return SENSITIVE_PATTERN.test(item.key) } -function parseJson(value: string): { valid: boolean; parsed?: unknown } { - const text = value.trim() - if (!text.startsWith('{') && !text.startsWith('[')) return { valid: false } - try { - return { valid: true, parsed: JSON.parse(text) } - } catch { - return { valid: false } - } +function isJsonConfig(item: AdminSystemConfig) { + const text = item.value.trim() + if (!text.startsWith('{') && !text.startsWith('[')) return false + return parseJsonValue(text).valid } -function isJsonConfig(item: AdminSystemConfig) { - return parseJson(item.value).valid +function jsonSummary(item: AdminSystemConfig) { + if (!isJsonConfig(item)) return '' + const parsed = parseJsonValue(item.value) + return parsed.valid ? describeJsonValue(parsed.parsed) : '' } function formatJson(value: string) { @@ -63,7 +63,7 @@ function formatJson(value: string) { function previewValue(item: AdminSystemConfig) { if (isSensitive(item)) return item.value ? '已配置,编辑时可见' : '未配置' - const json = parseJson(item.value) + const json = isJsonConfig(item) ? parseJsonValue(item.value) : { valid: false as const } const text = json.valid ? JSON.stringify(json.parsed) : item.value return text.length > 160 ? `${text.slice(0, 160)}…` : text || '空字符串' } @@ -121,7 +121,11 @@ function buildColumns( width: 110, render: (_, row) => ( - {isJsonConfig(row) ? JSON : 文本} + {isJsonConfig(row) ? ( + JSON{jsonSummary(row) ? ` · ${jsonSummary(row)}` : ''} + ) : ( + 文本 + )} {isSensitive(row) ? 敏感 : null} ), @@ -264,7 +268,8 @@ export function AdminSystemConfigsPage() { } const columns = useMemo(() => buildColumns(openEditor, canUpdate), [canUpdate]) - const editingJson = editing ? parseJson(draftValue).valid : false + const editingIsJson = editing ? isJsonConfig(editing) : false + const editingJsonValid = editingIsJson ? parseJsonValue(draftValue).valid : true return (
@@ -381,7 +386,7 @@ export function AdminSystemConfigsPage() { ) : null}
} - width={780} + width={editingIsJson ? 1040 : 780} onCancel={closeEditor} maskClosable={!saving} closable={!saving} @@ -392,13 +397,11 @@ export function AdminSystemConfigsPage() { 取消 - @@ -426,14 +429,30 @@ export function AdminSystemConfigsPage() {
配置值 - setDraftValue(event.target.value)} - /> + {editingIsJson ? ( + editing.key === 'listing.publish_options' ? ( + + ) : ( + + ) + ) : ( + setDraftValue(event.target.value)} + /> + )}
{isSensitive(editing) ? ( diff --git a/frontend/src/features/admin/components/JsonEditor.tsx b/frontend/src/features/admin/components/JsonEditor.tsx new file mode 100644 index 0000000..6977332 --- /dev/null +++ b/frontend/src/features/admin/components/JsonEditor.tsx @@ -0,0 +1,268 @@ +import { useMemo, useRef, useState } from 'react' +import type { KeyboardEvent } from 'react' +import { Button, Space, Tag, Typography } from 'antd' +import { + CodeOutlined, + CompressOutlined, + CopyOutlined, + UndoOutlined, +} from '@ant-design/icons' + +export interface JsonParseResult { + valid: boolean + parsed?: unknown + error?: string + line?: number + column?: number +} + +/** 解析 JSON,并从 V8 报错中提取行号/列号,便于定位。 */ +export function parseJsonValue(text: string): JsonParseResult { + const trimmed = text.trim() + if (!trimmed) return { valid: false, error: '内容为空' } + try { + return { valid: true, parsed: JSON.parse(trimmed) } + } catch (error) { + const message = error instanceof Error ? error.message : 'JSON 格式不正确' + const match = /position (\d+)(?: \(line (\d+) column (\d+)\))?/.exec(message) + return { + valid: false, + error: message, + line: match?.[2] ? Number(match[2]) : undefined, + column: match?.[3] ? Number(match[3]) : undefined, + } + } +} + +export function describeJsonValue(value: unknown) { + if (Array.isArray(value)) return `数组 ${value.length} 项` + if (value && typeof value === 'object') return `对象 ${Object.keys(value).length} 字段` + if (typeof value === 'string') return '字符串' + if (value === null) return 'null' + return typeof value +} + +function scalarClass(value: unknown) { + if (value === null) return 'is-null' + return `is-${typeof value}` +} + +function scalarText(value: unknown) { + if (typeof value === 'string') return `"${value}"` + if (value === null) return 'null' + return String(value) +} + +function JsonNode({ + name, + value, + depth, +}: { + name: string + value: unknown + depth: number +}) { + const [open, setOpen] = useState(depth < 1) + + if (Array.isArray(value)) { + return ( +
setOpen(event.currentTarget.open)} + > + + {name} + 数组 · {value.length} 项 + +
+ {value.slice(0, 100).map((item, index) => ( + + ))} + {value.length > 100 ? ( +
还有 {value.length - 100} 项未展示
+ ) : null} +
+
+ ) + } + + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + return ( +
setOpen(event.currentTarget.open)} + > + + {name} + 对象 · {entries.length} 字段 + +
+ {entries.map(([key, child]) => ( + + ))} +
+
+ ) + } + + return ( +
+ {name} + + {scalarText(value)} + +
+ ) +} + +interface JsonEditorProps { + value: string + onChange: (value: string) => void + originalValue?: string + minRows?: number + placeholder?: string +} + +/** + * 轻量 JSON 编辑器:行号、实时校验、格式化/压缩/复制、结构预览。 + * 不引入额外编辑器依赖,避免后台包体继续膨胀。 + */ +export function JsonEditor({ + value, + onChange, + originalValue, + minRows = 16, + placeholder, +}: JsonEditorProps) { + const parsed = useMemo(() => parseJsonValue(value), [value]) + const [copied, setCopied] = useState(false) + const gutterRef = useRef(null) + const inputRef = useRef(null) + + const actualLines = value.split('\n').length + const lineCount = Math.max(actualLines, minRows) + + const format = () => { + if (!parsed.valid) return + onChange(JSON.stringify(parsed.parsed, null, 2)) + } + + const compact = () => { + if (!parsed.valid) return + onChange(JSON.stringify(parsed.parsed)) + } + + const copy = async () => { + try { + await navigator.clipboard.writeText(value) + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } catch { + setCopied(false) + } + } + + const handleTab = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return + event.preventDefault() + const element = event.currentTarget + const start = element.selectionStart + const end = element.selectionEnd + onChange(`${value.slice(0, start)} ${value.slice(end)}`) + requestAnimationFrame(() => { + element.selectionStart = start + 2 + element.selectionEnd = start + 2 + }) + } + + const previewValue = parsed.valid ? parsed.parsed : undefined + const canPreview = + parsed.valid && previewValue !== null && typeof previewValue === 'object' + + return ( +
+
+ + + {parsed.valid ? 'JSON 有效' : 'JSON 有误'} + + + {actualLines} 行 · {value.length} 字符 + {parsed.valid ? ` · ${describeJsonValue(parsed.parsed)}` : ''} + + + + + + + {originalValue !== undefined && value !== originalValue ? ( + + ) : null} + +
+ +
+
+
+ {Array.from({ length: lineCount }, (_, index) => ( + {index + 1} + ))} +
+