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() {
取消
}
- disabled={!editingJson || saving}
- onClick={() => setDraftValue(formatJson(draftValue))}
+ type="primary"
+ loading={saving}
+ onClick={submit}
+ disabled={!canUpdate || (editingIsJson && !editingJsonValid)}
>
- 格式化 JSON
-
-
@@ -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)}` : ''}
+
+
+
+ } disabled={!parsed.valid} onClick={format}>
+ 格式化
+
+ }
+ disabled={!parsed.valid}
+ onClick={compact}
+ >
+ 压缩
+
+ } onClick={() => void copy()}>
+ {copied ? '已复制' : '复制'}
+
+ {originalValue !== undefined && value !== originalValue ? (
+ } onClick={() => onChange(originalValue)}>
+ 恢复
+
+ ) : null}
+
+
+
+
+
+
+ {Array.from({ length: lineCount }, (_, index) => (
+ {index + 1}
+ ))}
+
+
+
+ {canPreview ? (
+
+ ) : null}
+
+
+ {!parsed.valid ? (
+
+ {parsed.line
+ ? `第 ${parsed.line} 行${parsed.column ? `,第 ${parsed.column} 列` : ''}:`
+ : ''}
+ {parsed.error}
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/features/admin/components/PublishOptionsEditor.tsx b/frontend/src/features/admin/components/PublishOptionsEditor.tsx
new file mode 100644
index 0000000..2c32832
--- /dev/null
+++ b/frontend/src/features/admin/components/PublishOptionsEditor.tsx
@@ -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
+
+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 (
+ setTab(key as 'form' | 'json')}
+ items={[
+ {
+ key: 'form',
+ label: '表单配置',
+ children: object ? (
+
+
+ {LIST_FIELDS.map(field => (
+
+ {field.label} {asStringList(object[field.key]).length}
+
+ ))}
+
+
+
+ {LIST_FIELDS.map(field => (
+
+ ))}
+
+
+
+
+
下赛季通行证
+
+
+ 开启后发布页会显示「下赛季是否购买通行证」选项。
+
+ update('next_season_pass_enabled', checked)}
+ />
+
+
+
+
+
+
+ ) : (
+
+ ),
+ },
+ {
+ key: 'json',
+ label: 'JSON 源码',
+ children: (
+
+ ),
+ },
+ ]}
+ />
+ )
+}
diff --git a/frontend/src/features/admin/components/StructuredJsonEditor.tsx b/frontend/src/features/admin/components/StructuredJsonEditor.tsx
new file mode 100644
index 0000000..eaf3d9d
--- /dev/null
+++ b/frontend/src/features/admin/components/StructuredJsonEditor.tsx
@@ -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 = {
+ 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 {
+ 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 = {}
+ 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} {key}
+ >
+ ) : (
+ {key}
+ )
+}
+
+function JsonNodeForm({
+ value,
+ onChange,
+ fieldKey,
+ depth,
+}: {
+ value: unknown
+ onChange: (next: unknown) => void
+ fieldKey?: string
+ depth: number
+}) {
+ if (typeof value === 'boolean') {
+ return onChange(checked)} />
+ }
+
+ if (typeof value === 'number') {
+ return (
+ onChange(next ?? 0)}
+ style={{ width: '100%' }}
+ />
+ )
+ }
+
+ if (typeof value === 'string') {
+ if (isLongText(value)) {
+ return (
+ onChange(event.target.value)}
+ />
+ )
+ }
+ return onChange(event.target.value)} />
+ }
+
+ if (value === null || value === undefined) {
+ return (
+ 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 (
+