feat(admin): 系统配置支持表单化编辑全部 JSON
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user