feat(admin): 系统配置支持表单化编辑全部 JSON

This commit is contained in:
yml2213
2026-10-31 13:55:13 +08:00
parent 3113426d8f
commit 14003a9764
5 changed files with 1229 additions and 29 deletions
@@ -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) => (
<Space size={4} wrap>
{isJsonConfig(row) ? <Tag color="blue">JSON</Tag> : <Tag>文本</Tag>}
{isJsonConfig(row) ? (
<Tag color="blue">JSON{jsonSummary(row) ? ` · ${jsonSummary(row)}` : ''}</Tag>
) : (
<Tag>文本</Tag>
)}
{isSensitive(row) ? <Tag color="red">敏感</Tag> : null}
</Space>
),
@@ -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 (
<div className="admin-page">
@@ -381,7 +386,7 @@ export function AdminSystemConfigsPage() {
) : null}
</div>
}
width={780}
width={editingIsJson ? 1040 : 780}
onCancel={closeEditor}
maskClosable={!saving}
closable={!saving}
@@ -392,13 +397,11 @@ export function AdminSystemConfigsPage() {
取消
</Button>
<Button
icon={<CodeOutlined />}
disabled={!editingJson || saving}
onClick={() => setDraftValue(formatJson(draftValue))}
type="primary"
loading={saving}
onClick={submit}
disabled={!canUpdate || (editingIsJson && !editingJsonValid)}
>
格式化 JSON
</Button>
<Button type="primary" loading={saving} onClick={submit} disabled={!canUpdate}>
保存配置
</Button>
</Space>
@@ -426,14 +429,30 @@ export function AdminSystemConfigsPage() {
<div className="admin-filter-field">
<span className="admin-filter-field__label">配置值</span>
<Input.TextArea
value={draftValue}
autoSize={{ minRows: editingJson ? 12 : 4, maxRows: 20 }}
spellCheck={false}
className="admin-config-editor"
placeholder={editing.key === EMPTY_TOKEN_KEY ? '可留空' : '请输入配置值'}
onChange={event => setDraftValue(event.target.value)}
/>
{editingIsJson ? (
editing.key === 'listing.publish_options' ? (
<PublishOptionsEditor
value={draftValue}
originalValue={editing.value}
onChange={setDraftValue}
/>
) : (
<StructuredJsonEditor
value={draftValue}
originalValue={editing.value}
onChange={setDraftValue}
/>
)
) : (
<Input.TextArea
value={draftValue}
autoSize={{ minRows: 4, maxRows: 20 }}
spellCheck={false}
className="admin-config-editor"
placeholder={editing.key === EMPTY_TOKEN_KEY ? '可留空' : '请输入配置值'}
onChange={event => setDraftValue(event.target.value)}
/>
)}
</div>
{isSensitive(editing) ? (
@@ -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 (
<details
className="admin-json-node"
open={open}
onToggle={event => setOpen(event.currentTarget.open)}
>
<summary>
<span className="admin-json-node__key">{name}</span>
<span className="admin-json-node__meta">数组 · {value.length} 项</span>
</summary>
<div className="admin-json-node__children">
{value.slice(0, 100).map((item, index) => (
<JsonNode key={index} name={`[${index}]`} value={item} depth={depth + 1} />
))}
{value.length > 100 ? (
<div className="admin-json-node__more">还有 {value.length - 100} 项未展示</div>
) : null}
</div>
</details>
)
}
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
return (
<details
className="admin-json-node"
open={open}
onToggle={event => setOpen(event.currentTarget.open)}
>
<summary>
<span className="admin-json-node__key">{name}</span>
<span className="admin-json-node__meta">对象 · {entries.length} 字段</span>
</summary>
<div className="admin-json-node__children">
{entries.map(([key, child]) => (
<JsonNode key={key} name={key} value={child} depth={depth + 1} />
))}
</div>
</details>
)
}
return (
<div className="admin-json-node admin-json-node--leaf">
<span className="admin-json-node__key">{name}</span>
<span className={`admin-json-node__value ${scalarClass(value)}`}>
{scalarText(value)}
</span>
</div>
)
}
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<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<div className="admin-json-editor">
<div className="admin-json-editor__toolbar">
<Space size={8} wrap>
<Tag color={parsed.valid ? 'green' : 'red'}>
{parsed.valid ? 'JSON 有效' : 'JSON 有误'}
</Tag>
<Typography.Text type="secondary" className="admin-json-editor__stats">
{actualLines} 行 · {value.length} 字符
{parsed.valid ? ` · ${describeJsonValue(parsed.parsed)}` : ''}
</Typography.Text>
</Space>
<Space size={6} wrap>
<Button size="small" icon={<CodeOutlined />} disabled={!parsed.valid} onClick={format}>
格式化
</Button>
<Button
size="small"
icon={<CompressOutlined />}
disabled={!parsed.valid}
onClick={compact}
>
压缩
</Button>
<Button size="small" icon={<CopyOutlined />} onClick={() => void copy()}>
{copied ? '已复制' : '复制'}
</Button>
{originalValue !== undefined && value !== originalValue ? (
<Button size="small" icon={<UndoOutlined />} onClick={() => onChange(originalValue)}>
恢复
</Button>
) : null}
</Space>
</div>
<div className="admin-json-editor__body">
<div className="admin-json-editor__pane">
<div ref={gutterRef} className="admin-json-editor__gutter" aria-hidden>
{Array.from({ length: lineCount }, (_, index) => (
<span key={index}>{index + 1}</span>
))}
</div>
<textarea
ref={inputRef}
value={value}
spellCheck={false}
className="admin-json-editor__input"
style={{ height: minRows * 21 + 24 }}
placeholder={placeholder}
onChange={event => onChange(event.target.value)}
onKeyDown={handleTab}
onScroll={event => {
if (gutterRef.current) {
gutterRef.current.scrollTop = event.currentTarget.scrollTop
}
}}
/>
</div>
{canPreview ? (
<div className="admin-json-editor__preview">
<div className="admin-json-editor__preview-head">结构预览</div>
<div className="admin-json-editor__tree">
<JsonNode
name={Array.isArray(previewValue) ? 'array' : 'object'}
value={previewValue}
depth={0}
/>
</div>
</div>
) : null}
</div>
{!parsed.valid ? (
<div className="admin-json-editor__error">
{parsed.line
? `第 ${parsed.line} 行${parsed.column ? `,第 ${parsed.column} 列` : ''}:`
: ''}
{parsed.error}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,214 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, InputNumber, Select, Space, Switch, Tabs, Tag, Typography } from 'antd'
import { JsonEditor, parseJsonValue } from './JsonEditor'
interface PublishOptionsEditorProps {
value: string
originalValue?: string
onChange: (value: string) => void
}
type OptionsObject = Record<string, unknown>
const LIST_FIELDS: {
key: string
label: string
hint: string
placeholder: string
wide?: boolean
}[] = [
{
key: 'server_options',
label: '区服',
hint: '发布页可选的区服,输入后回车新增。',
placeholder: '例如 QQ、微信、Steam',
},
{
key: 'rank_options',
label: '段位',
hint: '发布页可选的段位。',
placeholder: '例如 青铜、白银、黄金',
},
{
key: 'insurance_options',
label: '赛季保险',
hint: '发布页可选的保险格数。',
placeholder: '例如 2*1、2*2、3*3',
},
{
key: 'level_options',
label: '体力 / 负重等级',
hint: '体力等级和负重等级共用这一组选项。',
placeholder: '例如 1级、2级、7级',
},
{
key: 'face_options',
label: '人脸选项',
hint: '账号人脸归属的可选值。',
placeholder: '例如 是、否',
},
{
key: 'login_method_options',
label: '上号方式',
hint: '发布页可选的上号方式;微信区会自动追加扫码。',
placeholder: '例如 扫码、账号密码',
},
{
key: 'region_options',
label: '常用登录地区',
hint: '发布时常用登录地区的候选值。',
placeholder: '例如 北京、天津、河北',
},
{
key: 'ban_record_options',
label: '封禁记录',
hint: '封禁情况的可选值,需与安全截图规则配合。',
placeholder: '例如 无封禁记录、有封禁记录',
},
{
key: 'ban_evidence_options',
label: '需上传安全截图的封禁记录',
hint: '命中这些封禁记录时必须上传腾讯安全中心截图;留空表示不强制。',
placeholder: '例如 有封禁记录',
wide: true,
},
]
function asStringList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.map(item => String(item)).filter(Boolean)
}
function asObject(value: string): OptionsObject | null {
const parsed = parseJsonValue(value)
if (!parsed.valid) return null
if (!parsed.parsed || typeof parsed.parsed !== 'object' || Array.isArray(parsed.parsed)) return null
return parsed.parsed as OptionsObject
}
/**
* listing.publish_options 的结构化编辑器。
* 只覆盖常用发布选项,皮肤组、消耗品、价格规则等复杂字段原样保留,可切到 JSON 源码编辑。
*/
export function PublishOptionsEditor({
value,
originalValue,
onChange,
}: PublishOptionsEditorProps) {
const object = useMemo(() => asObject(value), [value])
const [tab, setTab] = useState<'form' | 'json'>('form')
useEffect(() => {
if (!object) setTab('json')
}, [object])
const update = (key: string, next: unknown) => {
if (!object) return
onChange(JSON.stringify({ ...object, [key]: next }, null, 2))
}
const fireLevelMin = Number(object?.fire_level_min)
const nextSeasonPass = object?.next_season_pass_enabled === true
return (
<Tabs
activeKey={tab}
onChange={key => setTab(key as 'form' | 'json')}
items={[
{
key: 'form',
label: '表单配置',
children: object ? (
<div className="admin-config-gui">
<Space size={6} wrap>
{LIST_FIELDS.map(field => (
<Tag key={field.key} bordered={false}>
{field.label} {asStringList(object[field.key]).length}
</Tag>
))}
</Space>
<div className="admin-config-gui__grid">
{LIST_FIELDS.map(field => (
<label
key={field.key}
className={`admin-config-field${field.wide ? ' admin-config-field--wide' : ''}`}
>
<span className="admin-config-field__label">{field.label}</span>
<Select
mode="tags"
value={asStringList(object[field.key])}
placeholder={field.placeholder}
tokenSeparators={[',', ',', '\n']}
options={asStringList(object[field.key]).map(item => ({
value: item,
label: item,
}))}
onChange={next => update(field.key, next)}
/>
<small>{field.hint}</small>
</label>
))}
<label className="admin-config-field">
<span className="admin-config-field__label">最低烽火等级</span>
<InputNumber
min={0}
max={100}
precision={0}
value={Number.isFinite(fireLevelMin) ? fireLevelMin : null}
onChange={next => update('fire_level_min', next ?? 0)}
style={{ width: '100%' }}
/>
<small>低于该等级的账号无法发布。</small>
</label>
<div className="admin-config-field">
<span className="admin-config-field__label">下赛季通行证</span>
<div className="admin-config-switch-row">
<Typography.Text type="secondary">
开启后发布页会显示「下赛季是否购买通行证」选项。
</Typography.Text>
<Switch
checked={nextSeasonPass}
checkedChildren="显示"
unCheckedChildren="隐藏"
onChange={checked => update('next_season_pass_enabled', checked)}
/>
</div>
</div>
</div>
<Alert
type="info"
showIcon
message="其他字段会原样保留"
description="皮肤组、额外消耗品、截图槽位、价格与押金规则等复杂字段不在表单中展示;如需调整,请切换到「JSON 源码」,保存时不会被覆盖。"
/>
</div>
) : (
<Alert
type="warning"
showIcon
message="当前 JSON 无法解析,暂时不能使用表单配置"
description="请切换到「JSON 源码」修正语法后再回来。"
/>
),
},
{
key: 'json',
label: 'JSON 源码',
children: (
<JsonEditor
value={value}
originalValue={originalValue}
onChange={onChange}
minRows={18}
/>
),
},
]}
/>
)
}
@@ -0,0 +1,359 @@
import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Input,
InputNumber,
Select,
Space,
Switch,
Tabs,
Tag,
Typography,
} from 'antd'
import { JsonEditor, describeJsonValue, parseJsonValue } from './JsonEditor'
interface StructuredJsonEditorProps {
value: string
originalValue?: string
onChange: (value: string) => void
}
const KEY_LABELS: Record<string, string> = {
schema_version: '结构版本',
enabled: '启用',
title: '标题',
subtitle: '副标题',
description: '说明',
content: '内容',
eyebrow: '眉标',
badge: '角标',
pill: '胶囊文案',
tone: '色调',
image_url: '图片地址',
alt: '图片替代文本',
action: '跳转动作',
type: '类型',
target: '跳转目标',
open_new_tab: '新窗口打开',
pc: 'PC 端',
mobile: '移动端',
virtual_asset_sale: '虚拟资产出售协议',
seller_agreement: '卖家协议',
virtual_asset_purchase: '虚拟资产购买协议',
fixed_markup_rules: '固定加价规则',
ratio_adjustment_rules: '比例修正规则',
min_m: '最低哈夫币(M)',
max_m: '最高哈夫币(M)',
markup_amount: '加价金额',
min_ratio: '最低比例',
max_ratio: '最高比例',
ratio_delta: '比例调整值',
base_amount: '基础押金',
skin_group_rules: '皮肤押金规则',
amount: '金额',
label: '名称',
key: '标识',
options: '选项',
price: '价格',
price_cent: '价格(分)',
placeholder: '占位提示',
hint: '提示文案',
points_per_yuan: '每元积分',
levels: '等级规则',
code: '等级编码',
name: '等级名称',
min_points: '最低积分',
discount_bps: '折扣(bps)',
nodes: '通知节点',
template_code: '短信模板编码',
stale_days: '未成交提醒天数',
deposit_placeholder: '押金占位提示',
deposit_hint: '押金说明',
price_placeholder: '价格占位提示',
ratio_description: '比例说明',
}
/** 复杂列表:即使为空也按“对象列表”渲染,避免被误当成字符串标签。 */
const COMPLEX_ARRAY_KEYS = new Set([
'banners',
'levels',
'fixed_markup_rules',
'ratio_adjustment_rules',
'skin_groups',
'quantity_items',
'screenshot_slots',
'rules',
'items',
'templates',
])
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function isLongText(value: unknown) {
return typeof value === 'string' && (value.length > 80 || value.includes('\n'))
}
function primitiveArray(value: unknown[]) {
return value.every(item => item === null || typeof item !== 'object')
}
function defaultFor(value: unknown): unknown {
if (isPlainObject(value)) {
const next: Record<string, unknown> = {}
for (const [key, child] of Object.entries(value)) {
next[key] = defaultFor(child)
}
return next
}
if (Array.isArray(value)) return []
if (typeof value === 'number') return 0
if (typeof value === 'boolean') return false
if (value === null) return ''
return ''
}
function itemTitle(item: unknown, index: number) {
if (isPlainObject(item)) {
for (const key of ['title', 'name', 'label', 'key', 'code'] as const) {
const candidate = item[key]
if (typeof candidate === 'string' && candidate.trim()) return candidate
}
}
return `第 ${index + 1} 项`
}
function fieldLabel(key: string) {
const label = KEY_LABELS[key]
return label ? (
<>
{label} <code className="admin-json-form__key">{key}</code>
</>
) : (
<code className="admin-json-form__key">{key}</code>
)
}
function JsonNodeForm({
value,
onChange,
fieldKey,
depth,
}: {
value: unknown
onChange: (next: unknown) => void
fieldKey?: string
depth: number
}) {
if (typeof value === 'boolean') {
return <Switch checked={value} onChange={checked => onChange(checked)} />
}
if (typeof value === 'number') {
return (
<InputNumber
value={value}
onChange={next => onChange(next ?? 0)}
style={{ width: '100%' }}
/>
)
}
if (typeof value === 'string') {
if (isLongText(value)) {
return (
<Input.TextArea
value={value}
autoSize={{ minRows: 3, maxRows: 10 }}
onChange={event => onChange(event.target.value)}
/>
)
}
return <Input value={value} onChange={event => onChange(event.target.value)} />
}
if (value === null || value === undefined) {
return (
<Input
placeholder="null,输入内容后转为字符串"
onChange={event => onChange(event.target.value)}
/>
)
}
if (Array.isArray(value)) {
const primitive = primitiveArray(value) && !COMPLEX_ARRAY_KEYS.has(fieldKey ?? '')
if (primitive) {
const numeric = value.length > 0 && value.every(item => typeof item === 'number')
return (
<Select
mode="tags"
value={value.map(item => (item === null ? '' : String(item)))}
tokenSeparators={[',', ',', '\n']}
placeholder="输入后回车新增"
options={value.map(item => ({ value: String(item ?? ''), label: String(item ?? '') }))}
onChange={next =>
onChange(numeric ? next.map(item => Number(item) || 0) : next)
}
/>
)
}
const replaceAt = (index: number, next: unknown) => {
onChange(value.map((item, current) => (current === index ? next : item)))
}
const removeAt = (index: number) => {
onChange(value.filter((_, current) => current !== index))
}
const move = (index: number, offset: number) => {
const target = index + offset
if (target < 0 || target >= value.length) return
const next = [...value]
;[next[index], next[target]] = [next[target], next[index]]
onChange(next)
}
return (
<div className="admin-json-form__array">
{value.map((item, index) => (
<div className="admin-json-form__array-item" key={index}>
<div className="admin-json-form__array-head">
<strong>{itemTitle(item, index)}</strong>
<Space size={4} wrap>
<Button size="small" disabled={index === 0} onClick={() => move(index, -1)}>
上移
</Button>
<Button
size="small"
disabled={index === value.length - 1}
onClick={() => move(index, 1)}
>
下移
</Button>
<Button size="small" danger onClick={() => removeAt(index)}>
删除
</Button>
</Space>
</div>
<JsonNodeForm
value={item}
fieldKey={fieldKey}
depth={depth + 1}
onChange={next => replaceAt(index, next)}
/>
</div>
))}
<Button
block
type="dashed"
onClick={() => onChange([...value, defaultFor(value[0])])}
>
新增一项
</Button>
</div>
)
}
if (isPlainObject(value)) {
const entries = Object.entries(value)
return (
<div className="admin-json-form__object">
{entries.map(([key, child]) => {
const wide =
isPlainObject(child) ||
(Array.isArray(child) &&
(!primitiveArray(child) || COMPLEX_ARRAY_KEYS.has(key))) ||
isLongText(child)
return (
<div
key={key}
className={`admin-json-form__field${wide ? ' admin-json-form__field--wide' : ''}`}
>
<span className="admin-json-form__label">{fieldLabel(key)}</span>
<JsonNodeForm
value={child}
fieldKey={key}
depth={depth + 1}
onChange={next => onChange({ ...value, [key]: next })}
/>
</div>
)
})}
{entries.length === 0 ? (
<div className="admin-json-form__empty">空对象,请切到「JSON 源码」添加字段。</div>
) : null}
</div>
)
}
return <Input value={String(value)} onChange={event => onChange(event.target.value)} />
}
/**
* 通用 JSON 配置表单:对象逐字段渲染、数组可增删排序,
* 覆盖系统配置里的所有 JSON 配置,并提供 JSON 源码页签兜底。
*/
export function StructuredJsonEditor({
value,
originalValue,
onChange,
}: StructuredJsonEditorProps) {
const parsed = useMemo(() => parseJsonValue(value), [value])
const editable =
parsed.valid && parsed.parsed !== null && typeof parsed.parsed === 'object'
const [tab, setTab] = useState<'form' | 'json'>('form')
useEffect(() => {
if (!editable) setTab('json')
}, [editable])
return (
<Tabs
activeKey={tab}
onChange={key => setTab(key as 'form' | 'json')}
items={[
{
key: 'form',
label: '表单配置',
children: editable ? (
<div className="admin-json-form">
<Space size={8} wrap>
<Tag color="blue">{describeJsonValue(parsed.parsed)}</Tag>
<Typography.Text type="secondary">
按字段编辑,保存时会输出格式化后的 JSON。
</Typography.Text>
</Space>
<JsonNodeForm
value={parsed.parsed}
depth={0}
onChange={next => onChange(JSON.stringify(next, null, 2))}
/>
</div>
) : (
<Alert
type="warning"
showIcon
message="当前 JSON 不是对象或数组,暂时不能使用表单配置"
description="请切换到「JSON 源码」检查结构。"
/>
),
},
{
key: 'json',
label: 'JSON 源码',
children: (
<JsonEditor
value={value}
originalValue={originalValue}
onChange={onChange}
minRows={18}
/>
),
},
]}
/>
)
}
+340
View File
@@ -544,6 +544,346 @@
line-height: 1.6;
}
/* 系统配置结构化表单 */
.admin-config-gui {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-config-gui__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.admin-config-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 7px;
}
.admin-config-field--wide {
grid-column: 1 / -1;
}
.admin-config-field__label {
color: var(--ink-secondary);
font-size: 13px;
font-weight: 500;
}
.admin-config-field small {
color: var(--ink-muted);
font-size: 12px;
line-height: 1.6;
}
.admin-config-field .ant-select {
width: 100%;
}
.admin-config-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 34px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper-warm);
}
/* 通用 JSON 结构化表单 */
.admin-json-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-json-form__object {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
}
.admin-json-form__field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.admin-json-form__field--wide {
grid-column: 1 / -1;
}
.admin-json-form__label {
color: var(--ink-secondary);
font-size: 13px;
font-weight: 500;
}
.admin-json-form__key {
color: var(--ink-muted);
font-family: 'Inter', monospace;
font-size: 11px;
font-weight: 400;
}
.admin-json-form__empty {
grid-column: 1 / -1;
padding: 18px;
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--ink-muted);
font-size: 12px;
text-align: center;
}
.admin-json-form__array {
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-json-form__array-item {
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.admin-json-form__array-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.admin-json-form__array-head strong {
color: var(--brand-ink);
font-size: 13px;
}
.admin-json-form .ant-input-number,
.admin-json-form .ant-select {
width: 100%;
}
.admin-json-form__object .admin-json-form__object {
padding: 12px;
border: 1px dashed var(--line);
border-radius: 8px;
background: var(--paper-warm);
}
@media (max-width: 900px) {
.admin-json-form__object {
grid-template-columns: 1fr;
}
}
/* 系统配置 JSON 编辑器 */
.admin-json-editor {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.admin-json-editor__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 16px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: var(--paper-warm);
}
.admin-json-editor__stats {
font-size: 12px;
}
.admin-json-editor__body {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr);
min-height: 360px;
}
.admin-json-editor__pane {
display: flex;
min-width: 0;
min-height: 0;
border-right: 1px solid var(--line);
}
.admin-json-editor__gutter {
flex: 0 0 44px;
overflow: hidden;
padding: 12px 8px 12px 0;
border-right: 1px solid var(--line);
background: #faf8f2;
color: var(--ink-muted);
font-family: 'SFMono-Regular', Menlo, Consolas, monospace;
font-size: 12px;
line-height: 21px;
text-align: right;
user-select: none;
}
.admin-json-editor__gutter span {
display: block;
height: 21px;
}
.admin-json-editor__input {
flex: 1;
min-width: 0;
padding: 12px;
border: 0;
outline: none;
background: transparent;
color: var(--ink);
font-family: 'SFMono-Regular', Menlo, Consolas, monospace;
font-size: 12px;
line-height: 21px;
white-space: pre;
resize: none;
}
.admin-json-editor__preview {
display: flex;
min-width: 0;
flex-direction: column;
background: #fcfbf7;
}
.admin-json-editor__preview-head {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: var(--paper-warm);
color: var(--ink-secondary);
font-size: 12px;
font-weight: 600;
}
.admin-json-editor__tree {
max-height: 360px;
padding: 8px 10px;
overflow: auto;
font-family: 'Inter', monospace;
font-size: 12px;
}
.admin-json-node {
line-height: 1.8;
}
.admin-json-node summary {
display: flex;
gap: 8px;
align-items: baseline;
cursor: pointer;
list-style: none;
}
.admin-json-node summary::-webkit-details-marker {
display: none;
}
.admin-json-node summary::before {
content: '▸';
color: var(--ink-muted);
font-size: 10px;
}
.admin-json-node[open] > summary::before {
content: '▾';
}
.admin-json-node__children {
margin-left: 2px;
padding-left: 14px;
border-left: 1px dashed var(--line);
}
.admin-json-node--leaf {
display: flex;
gap: 8px;
align-items: baseline;
padding-left: 12px;
}
.admin-json-node__key {
color: var(--brand-ink);
}
.admin-json-node__meta {
color: var(--ink-muted);
font-size: 11px;
}
.admin-json-node__value {
color: var(--ink-secondary);
word-break: break-all;
}
.admin-json-node__value.is-string {
color: #0f7b6c;
}
.admin-json-node__value.is-number {
color: #b26a00;
}
.admin-json-node__value.is-boolean {
color: #8a4fbf;
}
.admin-json-node__value.is-null {
color: var(--ink-muted);
}
.admin-json-node__more {
color: var(--ink-muted);
font-size: 11px;
}
.admin-json-editor__error {
padding: 8px 12px;
border-top: 1px solid #ffccc7;
background: #fff2f0;
color: #cf1322;
font-size: 12px;
line-height: 1.6;
word-break: break-word;
}
@media (max-width: 900px) {
.admin-json-editor__body {
grid-template-columns: 1fr;
}
.admin-json-editor__pane {
border-right: 0;
}
.admin-json-editor__preview {
border-top: 1px solid var(--line);
}
.admin-json-editor__tree {
max-height: 240px;
}
}
.admin-detail-drawer .ant-drawer-header {
align-items: flex-start;
}