feat(admin): 新增后台系统配置管理
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import {
|
||||
CodeOutlined,
|
||||
EditOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import { fetchAdminSystemConfigs, updateAdminSystemConfig } from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import {
|
||||
systemConfigCategory,
|
||||
systemConfigCategoryLabel,
|
||||
systemConfigCategoryOptions,
|
||||
} from '@/features/admin/labels'
|
||||
import type { AdminSystemConfig } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatText } from '@/shared/utils/format'
|
||||
|
||||
const SENSITIVE_PATTERN = /(token|secret|password|api[_-]?key|private[_-]?key)/i
|
||||
const EMPTY_TOKEN_KEY = 'integration.paddle_ocr_token'
|
||||
|
||||
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) {
|
||||
return parseJson(item.value).valid
|
||||
}
|
||||
|
||||
function formatJson(value: string) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function previewValue(item: AdminSystemConfig) {
|
||||
if (isSensitive(item)) return item.value ? '已配置,编辑时可见' : '未配置'
|
||||
const json = parseJson(item.value)
|
||||
const text = json.valid ? JSON.stringify(json.parsed) : item.value
|
||||
return text.length > 160 ? `${text.slice(0, 160)}…` : text || '空字符串'
|
||||
}
|
||||
|
||||
function validateValue(item: AdminSystemConfig, value: string) {
|
||||
if (item.key !== EMPTY_TOKEN_KEY && !value.trim()) return '配置值不能为空'
|
||||
if (item.key === 'distribution.default_settlement_hold_days') {
|
||||
const days = Number(value)
|
||||
if (!Number.isInteger(days) || days < 0 || days > 365) {
|
||||
return '结算冷却天数必须是 0-365 的整数'
|
||||
}
|
||||
}
|
||||
if (item.key === 'listing.publish_cooldown_minutes') {
|
||||
const minutes = Number(value)
|
||||
if (!Number.isInteger(minutes) || minutes < 0) {
|
||||
return '发布冷却分钟数必须是不小于 0 的整数'
|
||||
}
|
||||
}
|
||||
if (isJsonConfig(item) || value.trim().startsWith('{') || value.trim().startsWith('[')) {
|
||||
try {
|
||||
JSON.parse(value)
|
||||
} catch {
|
||||
return 'JSON 格式不正确,请先修正或点击「格式化 JSON」'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildColumns(
|
||||
onEdit: (item: AdminSystemConfig) => void,
|
||||
canUpdate: boolean
|
||||
): TableColumnsType<AdminSystemConfig> {
|
||||
return [
|
||||
{
|
||||
title: '配置键',
|
||||
dataIndex: 'key',
|
||||
width: 330,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span className="admin-mono">{row.key}</span>
|
||||
<span>{formatText(row.description)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
key: 'category',
|
||||
width: 120,
|
||||
render: (_, row) => <Tag>{systemConfigCategoryLabel(row.key)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 110,
|
||||
render: (_, row) => (
|
||||
<Space size={4} wrap>
|
||||
{isJsonConfig(row) ? <Tag color="blue">JSON</Tag> : <Tag>文本</Tag>}
|
||||
{isSensitive(row) ? <Tag color="red">敏感</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '当前值',
|
||||
key: 'value',
|
||||
width: 380,
|
||||
render: (_, row) => (
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: previewValue(row) }}>
|
||||
{isSensitive(row) ? (
|
||||
<span className="admin-mono">{previewValue(row)}</span>
|
||||
) : (
|
||||
<span className="admin-audit-summary">{previewValue(row)}</span>
|
||||
)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatDateTime(row.updated_at)}</span>
|
||||
<span className="admin-mono">
|
||||
{row.updated_by != null ? `操作人 #${row.updated_by}` : '系统默认值'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 96,
|
||||
fixed: 'right',
|
||||
render: (_, row) =>
|
||||
canUpdate ? (
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : (
|
||||
<Typography.Text type="secondary">只读</Typography.Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function AdminSystemConfigsPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [editing, setEditing] = useState<AdminSystemConfig | null>(null)
|
||||
const [draftValue, setDraftValue] = useState('')
|
||||
const [draftDescription, setDraftDescription] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canView = Boolean(permissions?.includes('*') || permissions?.includes('system_config:view'))
|
||||
const canUpdate = Boolean(
|
||||
permissions?.includes('*') || permissions?.includes('system_config:update')
|
||||
)
|
||||
|
||||
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: ['admin-system-configs'],
|
||||
queryFn: fetchAdminSystemConfigs,
|
||||
enabled: canView,
|
||||
})
|
||||
|
||||
const items = data ?? []
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const text = keyword.trim().toLowerCase()
|
||||
return items.filter(item => {
|
||||
const matchesCategory = !category || systemConfigCategory(item.key) === category
|
||||
const matchesKeyword =
|
||||
!text ||
|
||||
item.key.toLowerCase().includes(text) ||
|
||||
(item.description ?? '').toLowerCase().includes(text)
|
||||
return matchesCategory && matchesKeyword
|
||||
})
|
||||
}, [items, keyword, category])
|
||||
|
||||
const jsonCount = items.filter(isJsonConfig).length
|
||||
const sensitiveCount = items.filter(isSensitive).length
|
||||
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
|
||||
const hasActiveFilters = Boolean(keyword || category)
|
||||
|
||||
const openEditor = (item: AdminSystemConfig) => {
|
||||
setEditing(item)
|
||||
setDraftValue(isJsonConfig(item) ? formatJson(item.value) : item.value)
|
||||
setDraftDescription(item.description)
|
||||
}
|
||||
|
||||
const closeEditor = () => {
|
||||
if (saving) return
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const doSave = async () => {
|
||||
if (!editing) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateAdminSystemConfig(editing.key, {
|
||||
value: draftValue,
|
||||
description: draftDescription.trim(),
|
||||
})
|
||||
message.success('系统配置已更新,并已写入审计日志')
|
||||
setEditing(null)
|
||||
await refetch()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '更新失败,请稍后重试')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!editing) return
|
||||
const validation = validateValue(editing, draftValue)
|
||||
if (validation) {
|
||||
message.warning(validation)
|
||||
return
|
||||
}
|
||||
modal.confirm({
|
||||
title: '确认更新系统配置?',
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
content: (
|
||||
<div className="admin-stack">
|
||||
<span>
|
||||
配置键:<span className="admin-mono">{editing.key}</span>
|
||||
</span>
|
||||
<span>保存后前台或后台的下一次读取会立即使用新值,并记录一条审计日志。</span>
|
||||
<span>请确认已经核对过 JSON 结构、金额单位与布尔值。</span>
|
||||
</div>
|
||||
),
|
||||
okText: '确认更新',
|
||||
cancelText: '再检查一下',
|
||||
onOk: doSave,
|
||||
})
|
||||
}
|
||||
|
||||
const columns = useMemo(() => buildColumns(openEditor, canUpdate), [canUpdate])
|
||||
const editingJson = editing ? parseJson(draftValue).valid : false
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 系统"
|
||||
title="系统配置"
|
||||
description="维护首页、发布、订单、交接、聊天与第三方集成的运行参数;所有写入都会记录审计日志。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {items.length} 项</Tag>
|
||||
{jsonCount > 0 ? <Tag color="blue">JSON {jsonCount} 项</Tag> : null}
|
||||
{sensitiveCount > 0 ? <Tag color="red">敏感 {sensitiveCount} 项</Tag> : null}
|
||||
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => void refetch()}
|
||||
loading={isFetching}
|
||||
disabled={!canView}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{!canView ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="当前账号没有系统配置查看权限"
|
||||
description="需要 system_config:view 权限才能读取配置,修改还需要 system_config:update。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FilterBar
|
||||
onReset={
|
||||
hasActiveFilters
|
||||
? () => {
|
||||
setKeyword('')
|
||||
setCategory('')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
loading={isFetching}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
{filtered.length === items.length
|
||||
? '显示全部配置'
|
||||
: `筛选出 ${filtered.length} / ${items.length} 项`}
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<FilterField label="关键字" width={280}>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
placeholder="搜索配置键或说明"
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="分类" width={200}>
|
||||
<Select
|
||||
allowClear
|
||||
value={category || undefined}
|
||||
placeholder="全部分类"
|
||||
options={systemConfigCategoryOptions}
|
||||
onChange={value => setCategory(value ?? '')}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="系统配置没有加载出来"
|
||||
description={errorText}
|
||||
action={
|
||||
<Button size="small" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminSystemConfig>
|
||||
title={<span>运行参数</span>}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
{isPending ? '加载中' : `当前 ${filtered.length} 项 · 点击「编辑」修改`}
|
||||
</Typography.Text>
|
||||
}
|
||||
rowKey="key"
|
||||
columns={columns}
|
||||
dataSource={filtered}
|
||||
loading={isFetching}
|
||||
onRowClick={canUpdate ? openEditor : undefined}
|
||||
emptyDescription={canView ? '没有匹配的系统配置' : '当前账号没有查看权限'}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={Boolean(editing)}
|
||||
title={
|
||||
<div className="admin-detail-drawer__head">
|
||||
<div className="admin-detail-drawer__title">编辑系统配置</div>
|
||||
{editing ? (
|
||||
<div className="admin-detail-drawer__subtitle">
|
||||
{systemConfigCategoryLabel(editing.key)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
width={780}
|
||||
onCancel={closeEditor}
|
||||
maskClosable={!saving}
|
||||
closable={!saving}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={closeEditor} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CodeOutlined />}
|
||||
disabled={!editingJson || saving}
|
||||
onClick={() => setDraftValue(formatJson(draftValue))}
|
||||
>
|
||||
格式化 JSON
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} onClick={submit} disabled={!canUpdate}>
|
||||
保存配置
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
<div className="admin-config-form">
|
||||
<Space size={8} wrap>
|
||||
<Tag className="admin-mono">{editing.key}</Tag>
|
||||
{isJsonConfig(editing) ? <Tag color="blue">JSON</Tag> : <Tag>文本</Tag>}
|
||||
{isSensitive(editing) ? <Tag color="red">敏感</Tag> : null}
|
||||
<Tag>{systemConfigCategoryLabel(editing.key)}</Tag>
|
||||
</Space>
|
||||
|
||||
<div className="admin-filter-field">
|
||||
<span className="admin-filter-field__label">配置说明</span>
|
||||
<Input
|
||||
value={draftDescription}
|
||||
maxLength={200}
|
||||
showCount
|
||||
placeholder="说明这项配置的用途与取值范围"
|
||||
onChange={event => setDraftDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSensitive(editing) ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="敏感配置"
|
||||
description="该值包含令牌或密钥,保存后会用于第三方调用。请确认没有把生产密钥写入开发库或粘贴到公开渠道。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Typography.Text type="secondary">
|
||||
配置键不可修改;需要新增配置时先在后端默认配置中登记,再回到本页调整取值。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
AdminOrderDetail,
|
||||
AdminOrderPage,
|
||||
AdminOrderQuery,
|
||||
AdminSystemConfig,
|
||||
AdminSystemConfigUpdatePayload,
|
||||
AdminUser,
|
||||
AdminUserItem,
|
||||
AdminUserPage,
|
||||
@@ -422,3 +424,21 @@ export async function fetchAdminAuditLogs(query: AdminAuditQuery) {
|
||||
}
|
||||
|
||||
export type { AdminAuditLog }
|
||||
|
||||
export async function fetchAdminSystemConfigs() {
|
||||
const { data } = await adminClient.get<ApiResponse<{ items?: AdminSystemConfig[] }>>(
|
||||
'/admin/system-configs'
|
||||
)
|
||||
return data.data.items ?? []
|
||||
}
|
||||
|
||||
export async function updateAdminSystemConfig(
|
||||
key: string,
|
||||
payload: AdminSystemConfigUpdatePayload
|
||||
) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminSystemConfig>>(
|
||||
`/admin/system-configs/${encodeURIComponent(key)}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -310,3 +310,29 @@ export const auditBizTypeOptions = Object.entries(auditBizTypeMap).map(([value,
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
export const systemConfigCategoryMap: Record<string, string> = {
|
||||
chat: '聊天与群组',
|
||||
distribution: '分销',
|
||||
handoff: '交接与超时',
|
||||
integration: '第三方集成',
|
||||
listing: '商品发布',
|
||||
mobile: '移动端首页',
|
||||
order: '订单',
|
||||
profile: '个人中心',
|
||||
renter: '租客成长',
|
||||
sms: '短信',
|
||||
}
|
||||
|
||||
export const systemConfigCategoryOptions = Object.entries(systemConfigCategoryMap).map(
|
||||
([value, label]) => ({ value, label })
|
||||
)
|
||||
|
||||
export function systemConfigCategory(key: string) {
|
||||
return key.split('.')[0] || 'other'
|
||||
}
|
||||
|
||||
export function systemConfigCategoryLabel(key: string) {
|
||||
const category = systemConfigCategory(key)
|
||||
return systemConfigCategoryMap[category] ?? category
|
||||
}
|
||||
|
||||
@@ -607,3 +607,18 @@ export interface AdminAuditPage {
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminSystemConfig {
|
||||
id: number
|
||||
key: string
|
||||
value: string
|
||||
description: string
|
||||
updated_by?: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminSystemConfigUpdatePayload {
|
||||
value: string
|
||||
description: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user