Files
shaguabijia-admin-web/src/app/(main)/config/page.tsx
T
ouzhou a393b5c0e3 feat(config): 配置页 bool 开关控件 + 全站 antd App.useApp 迁移 (#12)
- 系统配置页支持 bool 类型(Switch 控件),供「提现自动对账」「比价期广告」等运营开关使用
- message / Modal.confirm 静态调用迁移到 App.useApp() 的 message / modal,消除 React 19
  "Static function can not consume context" 告警;providers 用 <App> 容器包裹全站

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #12
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-17 10:38:40 +08:00

154 lines
5.0 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd';
import { api, errMsg } from '@/lib/api';
interface ConfigItem {
key: string;
label: string;
group: string;
type: string; // int / int_list / dict_str_int / bool
help: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
overridden: boolean;
updated_at: string | null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function toEdit(item: ConfigItem): any {
if (item.type === 'int_list') return (item.value as number[]).join(', ');
if (item.type === 'dict_str_int') return JSON.stringify(item.value);
return item.value;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function fromEdit(type: string, raw: any): any {
if (type === 'int') return Number(raw);
if (type === 'bool') return Boolean(raw);
if (type === 'int_list') {
return String(raw)
.split(',')
.map((s) => parseInt(s.trim(), 10));
}
if (type === 'dict_str_int') return JSON.parse(raw);
return raw;
}
export default function ConfigPage() {
const { message } = App.useApp();
const [items, setItems] = useState<ConfigItem[]>([]);
const [loading, setLoading] = useState(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [edits, setEdits] = useState<Record<string, any>>({});
const [saving, setSaving] = useState<string | null>(null);
const load = async () => {
setLoading(true);
try {
const { data } = await api.get<ConfigItem[]>('/admin/api/config');
setItems(data);
setEdits(Object.fromEntries(data.map((i) => [i.key, toEdit(i)])));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const save = async (item: ConfigItem) => {
let value;
try {
value = fromEdit(item.type, edits[item.key]);
} catch {
message.error('格式不对(列表用逗号分隔、映射用 JSON)');
return;
}
setSaving(item.key);
try {
await api.patch(`/admin/api/config/${item.key}`, { value });
message.success('已保存,即时生效');
load();
} catch (e) {
message.error(errMsg(e));
} finally {
setSaving(null);
}
};
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
const groups = [...new Set(items.map((i) => i.group))];
return (
<div>
<h2></h2>
<p style={{ color: '#999' }}>
() / ,
</p>
{groups.map((g) => (
<Card key={g} title={g} size="small" style={{ marginBottom: 16 }}>
{items
.filter((i) => i.group === g)
.map((item) => (
<div
key={item.key}
style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f0f0f0' }}
>
<div style={{ marginBottom: 4 }}>
<b>{item.label}</b>{' '}
{item.overridden ? <Tag color="blue"></Tag> : <Tag></Tag>}
{item.help && (
<Tooltip title={item.help}>
<span style={{ color: '#999', marginLeft: 6, fontSize: 12, cursor: 'help' }}>
</span>
</Tooltip>
)}
</div>
<Space wrap>
{item.type === 'bool' ? (
<Switch
checked={!!edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
/>
) : item.type === 'int' ? (
<InputNumber
value={edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
style={{ width: 180 }}
/>
) : (
<Input
value={edits[item.key]}
onChange={(e) => setEdits({ ...edits, [item.key]: e.target.value })}
style={{ width: 380 }}
placeholder={
item.type === 'int_list' ? '逗号分隔,如 10, 20, 30' : 'JSON,如 {"key": 100}'
}
/>
)}
<Button
type="primary"
size="small"
loading={saving === item.key}
onClick={() => save(item)}
>
</Button>
<span style={{ color: '#bbb', fontSize: 12 }}>
{JSON.stringify(item.default)}
</span>
</Space>
</div>
))}
</Card>
))}
</div>
);
}