feat(admin): 新增后台系统配置管理

This commit is contained in:
yml2213
2026-10-30 13:32:34 +08:00
parent 1acf433611
commit df251c2c3c
10 changed files with 650 additions and 6 deletions
+6 -1
View File
@@ -108,6 +108,11 @@ const AdminAuditLogsPage = lazy(() =>
default: module.AdminAuditLogsPage,
}))
)
const AdminSystemConfigsPage = lazy(() =>
import('@/features/admin/AdminSystemConfigsPage').then(module => ({
default: module.AdminSystemConfigsPage,
}))
)
const AdminLayout = lazy(() =>
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
)
@@ -117,7 +122,6 @@ const AdminSectionPlaceholder = lazy(() =>
const adminSections: Array<{ path: string; title: string; description: string }> = [
{ path: 'distribution/sites', title: '分销站点', description: '管理分站、规则、结算与站点钱包。' },
{ path: 'system-configs', title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
]
export function AppRouter() {
@@ -159,6 +163,7 @@ export function AppRouter() {
<Route path="finance" element={<AdminFinancePage />} />
<Route path="announcements" element={<AdminAnnouncementsPage />} />
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
<Route path="system-configs" element={<AdminSystemConfigsPage />} />
{adminSections.map(section => (
<Route
key={section.path}
@@ -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>
)
}
+20
View File
@@ -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
}
+26
View File
@@ -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
}
+15
View File
@@ -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
}
+1 -1
View File
@@ -106,7 +106,7 @@ const allNavGroups: NavGroup[] = [
label: '系统',
icon: <SettingOutlined />,
children: [
{ key: '/system-configs', label: '系统配置', permission: 'systemconfig:view' },
{ key: '/system-configs', label: '系统配置', permission: 'system_config:view' },
{ key: '/audit-logs', label: '审计日志', permission: 'audit_log:view' },
],
},
+12
View File
@@ -1489,6 +1489,18 @@ a {
word-break: break-all;
}
.admin-config-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.admin-config-editor {
font-family: 'Inter', monospace;
font-size: 12px;
line-height: 1.6;
}
.admin-detail-drawer .ant-drawer-header {
align-items: flex-start;
}