fix(admin): improve promo layout and system settings save UX
CI / verify (pull_request) Has been cancelled

Fix mini home media form persistence; float save with unsaved leave prompt; promo detail QR on the right.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-23 13:54:10 +08:00
parent 479de04f7c
commit d11757c854
5 changed files with 206 additions and 288 deletions
+85 -230
View File
@@ -1,131 +1,71 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
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 } from '../lib/api';
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
const { TextArea } = Input;
function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
return (
<div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Mock 50
</Typography.Text>
<Table<MockSmsCodeItem>
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) => (
<Typography.Text copyable strong>
{code}
</Typography.Text>
),
},
]}
dataSource={codes}
/>
</div>
);
}
function renderField(
field: SystemConfigFieldMeta,
configuredSecrets: string[],
extra?: ReactNode,
) {
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
if (field.type === 'boolean') {
@@ -169,6 +109,9 @@ function renderField(
</Space>
}
tooltip={field.description}
trigger="onChange"
getValueFromEvent={(v: unknown) => (typeof v === 'string' ? v : '')}
normalize={(v) => (typeof v === 'string' ? v : '')}
>
{field.type === 'image' ? (
<ConfigImageField bizType="footer" />
@@ -214,34 +157,25 @@ function renderField(
);
}
export default function SystemSettingsPage() {
const navigate = useNavigate();
const [form] = Form.useForm<Record<string, string>>();
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(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<SystemConfigFormResponse>('/admin/system-config');
if (silent) {
// 仅刷新 Mock 验证码列表,避免轮询用服务端值覆盖未保存的表单(含 MOCK_SMS 开关)
setMeta((prev) =>
prev
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
@@ -249,300 +183,221 @@ export default function SystemSettingsPage() {
);
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();
}, []);
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 [];
return meta.groups.map((group) => ({
key: group.key,
label: group.label,
forceRender: true,
children: (
<div style={{ maxWidth: 720 }}>
{meta.fields
.filter((f) => f.group === group.key)
.map((f) =>
renderField(
f,
meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
) : undefined,
),
)}
</div>
),
}));
}, [meta, mockSmsEnabled, loading]);
async function onSave() {
const values = await form.validateFields();
await form.validateFields();
const values = form.getFieldsValue(true);
const payload: Record<string, string> = {};
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 (
<div>
<div style={{ paddingBottom: 88 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
<code>system_config</code> {' '}
<code>{meta?.envFilePath ?? '.env'}</code> 便
</Typography.Paragraph>
</div>
<Space>
<Button onClick={() => void onImportEnv()}></Button>
<Button loading={syncing} onClick={() => void onSyncEnv()}>
env
</Button>
<Button type="primary" loading={saving} onClick={() => void onSave()}>
</Button>
</Space>
</div>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="生效说明"
description={
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
<li>
<Tag color="green"></Tag> <code>process.env</code>Mock
</li>
<li>
<Tag color="orange"></Tag>/OSS <strong> API </strong>
</li>
<li>OSS </li>
<li></li>
</ul>
}
/>
<Card loading={loading}>
<Form form={form} layout="vertical">
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
<Collapse defaultActiveKey={meta?.groups.map((g) => g.key)} items={collapseItems} />
</Form>
{meta?.updatedAt ? (
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
{new Date(meta.updatedAt).toLocaleString()}
</Typography.Text>
) : null}
</Card>
<div
style={{
position: 'fixed',
right: 32,
bottom: 32,
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 8,
}}
>
{dirty ? <Tag color="orange"></Tag> : null}
<Button
type="primary"
size="large"
loading={saving}
onClick={() => void onSave()}
style={{
minWidth: 120,
boxShadow: '0 6px 16px rgba(0,0,0,0.18)',
}}
>
</Button>
</div>
</div>
);
}