import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Card,
Collapse,
Form,
Input,
InputNumber,
Modal,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api';
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
const { TextArea } = Input;
function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
return (
最近 Mock 验证码(写入数据库,最新 50 条)
size="small"
rowKey="id"
loading={loading}
pagination={false}
scroll={{ y: 240 }}
locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }}
columns={[
{
title: '时间',
dataIndex: 'createdAt',
width: 168,
render: (v: string) => new Date(v).toLocaleString(),
},
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '场景', dataIndex: 'scene', width: 160 },
{
title: '验证码',
dataIndex: 'code',
width: 88,
render: (code: string) => (
{code}
),
},
]}
dataSource={codes}
/>
);
}
function renderField(
field: SystemConfigFieldMeta,
configuredSecrets: string[],
extra?: ReactNode,
) {
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
if (field.type === 'boolean') {
return (
{field.label}
{field.key}
{field.requiresRestart ? 需重启 : 即时}
}
tooltip={field.description}
valuePropName="checked"
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
>
{extra}
);
}
if (field.type === 'image' || field.type === 'imageList') {
return (
{field.label}
{field.key}
{field.requiresRestart ? 需重启 : 即时}
}
tooltip={field.description}
trigger="onChange"
getValueFromEvent={(v: unknown) => (typeof v === 'string' ? v : '')}
normalize={(v) => (typeof v === 'string' ? v : '')}
>
{field.type === 'image' ? (
) : (
)}
);
}
const input =
field.type === 'textarea' ? (
) : field.type === 'number' ? (
) : field.secret ? (
) : (
);
return (
{field.label}
{field.key}
{field.requiresRestart ? 需重启 : 即时}
{isConfiguredSecret ? 已配置 : null}
}
tooltip={field.description}
>
{input}
);
}
export default function SystemSettingsPage() {
const navigate = useNavigate();
const [form] = Form.useForm>();
const [meta, setMeta] = useState(null);
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [dirty, setDirty] = useState(false);
const dirtyRef = useRef(false);
const bypassLeaveRef = useRef(false);
const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true';
dirtyRef.current = dirty;
async function load(silent = false) {
if (!silent) setLoading(true);
try {
const data = await request('/admin/system-config');
if (silent) {
setMeta((prev) =>
prev
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
: data,
);
return;
}
setMeta(data);
form.setFieldsValue(data.values);
setDirty(false);
} catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载失败');
} finally {
if (!silent) setLoading(false);
}
}
useEffect(() => {
void load();
request('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
if (!mockSmsEnabled) return;
const timer = window.setInterval(() => void load(true), 5000);
return () => window.clearInterval(timer);
}, [mockSmsEnabled]);
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (!dirtyRef.current) return;
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, []);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (!dirtyRef.current || bypassLeaveRef.current) return;
const target = e.target as HTMLElement | null;
const anchor = target?.closest?.('a');
if (!anchor || !(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target === '_blank' || anchor.hasAttribute('download')) return;
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin) return;
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
e.preventDefault();
e.stopPropagation();
Modal.confirm({
title: '有未保存的更改',
content: '离开前请先保存,否则更改将丢失。',
okText: '仍要离开',
cancelText: '留下',
onOk: () => {
bypassLeaveRef.current = true;
setDirty(false);
navigate(`${url.pathname}${url.search}${url.hash}`);
window.setTimeout(() => {
bypassLeaveRef.current = false;
}, 0);
},
});
};
document.addEventListener('click', onDocClick, true);
return () => document.removeEventListener('click', onDocClick, true);
}, [navigate]);
const collapseItems = useMemo(() => {
if (!meta) return [];
const SHARE_SUBGROUP_LABELS: Record = {
global: '全局',
home: '首页',
stores: '门店列表',
storeDetail: '门店详情',
benefit: '权益页',
mine: '我的',
productDetail: '商品详情',
orderDetail: '订单详情',
};
const SHARE_SUBGROUP_ORDER = [
'global',
'home',
'stores',
'storeDetail',
'benefit',
'mine',
'productDetail',
'orderDetail',
];
return meta.groups.map((group) => {
const fields = meta.fields.filter((f) => f.group === group.key);
const hasSubgroups = fields.some((f) => f.subgroup);
let children: ReactNode;
if (group.key === 'wechat_mini_share' && hasSubgroups) {
const bySub = new Map();
for (const f of fields) {
const sk = f.subgroup || 'global';
if (!bySub.has(sk)) bySub.set(sk, []);
bySub.get(sk)!.push(f);
}
const orderedKeys = [
...SHARE_SUBGROUP_ORDER.filter((k) => bySub.has(k)),
...[...bySub.keys()].filter((k) => !SHARE_SUBGROUP_ORDER.includes(k)),
];
children = (
({
key: sk,
label: SHARE_SUBGROUP_LABELS[sk] || sk,
forceRender: true,
children: (
{(bySub.get(sk) ?? []).map((f) =>
renderField(
f,
meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
) : undefined,
),
)}
),
}))}
/>
);
} else {
children = (
{fields.map((f) =>
renderField(
f,
meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
) : undefined,
),
)}
);
}
return {
key: group.key,
label: group.label,
forceRender: true,
children,
};
});
}, [meta, mockSmsEnabled, loading]);
async function onSave() {
await form.validateFields();
const values = form.getFieldsValue(true);
const payload: Record = {};
for (const [k, v] of Object.entries(values)) {
payload[k] = v === undefined || v === null ? '' : String(v);
}
setSaving(true);
try {
const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
'/admin/system-config',
{ method: 'PUT', body: JSON.stringify({ values: payload }) },
);
message.success(`已保存 ${res.updatedKeys.length} 项`);
if (res.requiresRestartKeys.length) {
message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
}
setDirty(false);
await load();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function onSyncEnv() {
setSyncing(true);
try {
const res = await request<{ message: string; envFilePath: string }>(
'/admin/system-config/sync-env',
{ method: 'POST' },
);
message.success(res.message || '已同步到 env 文件');
} catch (e) {
message.error(e instanceof Error ? e.message : '同步失败');
} finally {
setSyncing(false);
}
}
async function onImportEnv() {
try {
const res = await request<{ imported: number }>('/admin/system-config/import-env', {
method: 'POST',
});
message.success(`已从当前进程环境导入 ${res.imported} 项`);
await load();
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败');
}
}
return (
系统设置
配置存于 system_config 表;保存后写入进程环境。可同步到{' '}
{meta?.envFilePath ?? '.env'} 以便部署持久化。
{profile?.adminRole === 'SUPER_ADMIN' ? (
<>
>
) : null}
即时:保存后写入 process.env,Mock 开关、短信模板等可立即生效。
需重启:微信/OSS 密钥等集成凭证变更后,建议重启 API 进程。
OSS 始终走阿里云配置;凭证缺失时上传接口将直接报错。
修改后请点击右下角「保存」;未保存离开页面将提示确认。
}
/>
{meta?.updatedAt ? (
最近更新:{new Date(meta.updatedAt).toLocaleString()}
) : null}
{dirty ? 有未保存更改 : null}
);
}