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
+3 -3
View File
@@ -194,12 +194,12 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
## 5. 已知限制
1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。
2. 后台已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、公告管理、审计日志,其余页面仍为统一占位。
2. 后台已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、公告管理、审计日志、系统配置,其余页面仍为统一占位。
3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`,公告 `announcement:view` / `announcement:manage`,审计日志 `audit_log:view`;系统配置与分销站点仍需核对。
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`,公告 `announcement:view` / `announcement:manage`,审计日志 `audit_log:view`,系统配置 `system_config:view` / `system_config:update`;分销站点仍需核对。
5. SVG 中文尚未转曲。
6. 入口包已降至 495 kB(gzip 161 kB);AntD 共享基础设施仍在入口,后续可拆独立入口。
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、公告与审计日志已按批次提交。
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、公告、审计日志与系统配置已按批次提交。
---
+110
View File
@@ -0,0 +1,110 @@
# Phase 2 验收报告(后台系统配置)
> 日期:2026-10-30
> 项目:解忧商行(hfb_jysh)
> 范围:`/console-jy8k3m/system-configs`
> 结论:**本切片通过,系统配置列表、筛选、JSON 格式化与更新已接真实接口**
---
## 1. 接口与权限
| 能力 | 接口 | 权限码 |
| --- | --- | --- |
| 配置列表 | `GET /api/admin/system-configs` | `system_config:view` |
| 更新配置 | `PUT /api/admin/system-configs/:key` | `system_config:update` |
更新请求体:
```json
{
"value": "配置值字符串",
"description": "配置说明"
}
```
后端行为:
- 配置键不可新增或删除,只能在默认配置白名单内更新。
- `backup.*` 只能走独立备份调度接口,不能从本页修改。
- `integration.paddle_ocr_token` 允许保存空值。
- `distribution.default_settlement_hold_days` 必须为 0–365 的整数。
- `listing.publish_cooldown_minutes` 必须为不小于 0 的整数。
- 发布选项、租客成长规则与短信模板由后端做结构校验和归一化;其余 JSON 配置主要由前端校验可解析,保存前需人工核对结构。
- 每次更新写入 `audit_logs`,动作为 `system_config.update`。
## 2. 页面
`frontend/src/features/admin/AdminSystemConfigsPage.tsx`
- 一次读取全部可见配置,前端按关键字与分类即时筛选
- 分类:聊天与群组、分销、交接与超时、第三方集成、商品发布、移动端首页、订单、个人中心、租客成长、短信
- 列:配置键/说明、分类、类型、当前值、更新时间、操作
- 类型标签:`JSON` / `文本`,敏感键额外显示 `敏感`
- 敏感键(token、secret、password、api_key、private_key)在列表中只显示「已配置 / 未配置」,进入编辑后可见原值
- 行点击或「编辑」打开配置弹窗
- 无 `system_config:view` 时停止请求并显示权限提示;无 `system_config:update` 时只读
## 3. 编辑弹窗
- 只读展示配置键、分类、类型与敏感标记
- 配置说明可编辑,最多 200 字
- 配置值使用等宽字体文本域,JSON 值默认两空格缩进
- 「格式化 JSON」仅在当前值可解析时可用
- 保存前做前端校验:
- 非空(OCR token 除外)
- 分销结算冷却天数 0–365
- 商品发布冷却分钟数不小于 0
- 原值为 JSON 或新值以 `{` / `[` 开头时必须能解析
- 保存前二次确认,提示配置会立即影响下一次读取并写入审计日志
- 敏感配置额外提示不要将生产密钥写入开发库
## 4. 菜单与路由
- 路由:`frontend/src/app/AppRouter.tsx` 新增 `system-configs`
- 菜单:`frontend/src/layouts/AdminLayout.tsx` 权限码由 `systemconfig:view` 修正为 `system_config:view`
- 占位列表同步移除 `system-configs`
## 5. 开发库配置概况
当前可见配置 27 项,其中 JSON 配置包含:
```text
listing.publish_agreements
listing.publish_options
listing.sale_price_config
mobile.aw_recycle
mobile.cooperation_feedback
mobile.home_announcements
mobile.home_banners
order.agreements
profile.post_rental_notice
renter.growth_level_rules
sms.business_templates
```
## 6. 验证结果
```text
npx tsc --noEmit 通过
npm run build 通过(入口 497.41 kB,gzip 161.38 kB)
GET /api/admin/system-configs ok,返回 27 项
GET /console-jy8k3m/system-configs 200
```
列表读取已实测;未执行 `PUT` 写操作,避免改动开发库运行参数。
## 7. 已知限制
1. 当前是原始键值编辑器,首页轮播、发布协议、短信模板等复杂 JSON 仍直接编辑原文,没有结构化表单。
2. 不支持新增或删除配置键,新增配置需先在后端默认配置中登记。
3. 备份调度不在本页,使用独立接口与后台入口。
4. 没有配置变更历史对比,需到审计日志查看 `system_config.update` 明细。
5. 敏感值仅在编辑弹窗展示,未接密钥轮换或加密存储改造。
## 8. 下一步建议
1. 对首页轮播、发布协议、短信模板做结构化编辑器。
2. 线下出款与运营支出登记。
3. 分销站点管理页。
4. 移动端下单、支付与交接/归还/结账。
+1 -1
View File
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理与审计日志;线下出款/运营支出与系统配置待迁移。
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理、审计日志与系统配置;线下出款/运营支出待迁移。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
+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;
}