From d11757c854d23af84533d322e8bdfbf7e177eb0e Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Thu, 23 Jul 2026 13:54:10 +0800 Subject: [PATCH] fix(admin): improve promo layout and system settings save UX Fix mini home media form persistence; float save with unsaved leave prompt; promo detail QR on the right. Co-authored-by: Cursor --- .../src/components/ConfigMediaFields.tsx | 36 +- .../src/pages/SystemSettingsPage.tsx | 315 +++++------------- .../src/pages/promo/PromoCodeDetailPage.tsx | 131 +++++--- .../system-config/system-config.registry.ts | 4 +- .../system-config/system-config.service.ts | 8 +- 5 files changed, 206 insertions(+), 288 deletions(-) diff --git a/apps/admin-web/src/components/ConfigMediaFields.tsx b/apps/admin-web/src/components/ConfigMediaFields.tsx index 33c67af..db66cd0 100644 --- a/apps/admin-web/src/components/ConfigMediaFields.tsx +++ b/apps/admin-web/src/components/ConfigMediaFields.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { Button, Space } from 'antd'; import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { @@ -8,6 +9,21 @@ import OssUpload from './OssUpload'; const MAX_BANNERS = 8; +/** 编辑态保留空位;下发/入库仍用 parseMiniHomeBanners 过滤空串 */ +function parseBannersForEdit(raw?: string | null): string[] { + if (!raw?.trim()) return []; + try { + const parsed = JSON.parse(raw.trim()) as unknown; + if (!Array.isArray(parsed)) return parseMiniHomeBanners(raw); + return parsed + .filter((u): u is string => typeof u === 'string') + .map((u) => u.trim()) + .slice(0, MAX_BANNERS); + } catch { + return parseMiniHomeBanners(raw); + } +} + /** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer) */ export function ConfigImageField({ value, @@ -22,8 +38,8 @@ export function ConfigImageField({ onChange?.(url ?? '')} placeholder="上传或粘贴图片 URL" /> ); @@ -39,10 +55,17 @@ export function ConfigImageListField({ onChange?: (json: string) => void; bizType?: string; }) { - const urls = parseMiniHomeBanners(value); + const [urls, setUrls] = useState(() => parseBannersForEdit(value)); + useEffect(() => { + setUrls(parseBannersForEdit(value)); + }, [value]); + + /** 本地可含空位;写入 Form 时去掉空串 */ function commit(next: string[]) { - onChange?.(serializeMiniHomeBanners(next)); + const clipped = next.slice(0, MAX_BANNERS); + setUrls(clipped); + onChange?.(serializeMiniHomeBanners(clipped)); } function updateAt(index: number, url: string) { @@ -67,13 +90,14 @@ export function ConfigImageListField({ function add() { if (urls.length >= MAX_BANNERS) return; - commit([...urls, '']); + // 只加本地空位,避免 serialize 过滤空串导致「点击无反应」 + setUrls((prev) => [...prev, '']); } return ( {urls.map((url, index) => ( - +
- - 最近 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') { @@ -169,6 +109,9 @@ function renderField(
} tooltip={field.description} + trigger="onChange" + getValueFromEvent={(v: unknown) => (typeof v === 'string' ? v : '')} + normalize={(v) => (typeof v === 'string' ? v : '')} > {field.type === 'image' ? ( @@ -214,34 +157,25 @@ function renderField( ); } - - export default function SystemSettingsPage() { - + const navigate = useNavigate(); const [form] = Form.useForm>(); - const [meta, setMeta] = 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) { - // 仅刷新 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: ( -
- {meta.fields - .filter((f) => f.group === group.key) - .map((f) => - renderField( - f, - meta.configuredSecrets, - f.key === 'MOCK_SMS' && mockSmsEnabled ? ( - - ) : undefined, - ), - )} -
- ), - })); - }, [meta, mockSmsEnabled, loading]); - - async function onSave() { - - const values = await form.validateFields(); - + 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'} 以便部署持久化。 - -
- - - - - - -
- - -
  • - 即时:保存后写入 process.env,Mock 开关、短信模板等可立即生效。 -
  • -
  • - 需重启:微信/OSS 密钥等集成凭证变更后,建议重启 API 进程。 -
  • -
  • OSS 始终走阿里云配置;凭证缺失时上传接口将直接报错。
  • - +
  • 修改后请点击右下角「保存」;未保存离开页面将提示确认。
  • - } - /> - - - -
    - + setDirty(true)}> g.key)} items={collapseItems} /> - - {meta?.updatedAt ? ( - - 最近更新:{new Date(meta.updatedAt).toLocaleString()} - - ) : null} -
    +
    + {dirty ? 有未保存更改 : null} + +
    - ); - } - - diff --git a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx index 6608beb..2dfaf55 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type CSSProperties } from 'react'; import { useOutletContext } from 'react-router-dom'; import { Button, @@ -25,6 +25,15 @@ import { request } from '../../lib/api'; import { fmtTime } from '../../lib/constants'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; +const descLabelStyle: CSSProperties = { + whiteSpace: 'nowrap', + width: 108, +}; + +const descContentStyle: CSSProperties = { + wordBreak: 'break-all', +}; + async function downloadQrcode(url: string, filename: string) { try { const res = await fetch(url); @@ -72,49 +81,25 @@ export default function PromoCodeDetailPage() { return ( <> - - - - {detail.qrcodeUrl ? ( -
    - 推广小程序码 - - 微信扫码进入小程序(scene=活动 ID {detail.id}) - - - - -
    - ) : ( - 暂无小程序码 - )} -
    - - - + + - @@ -138,7 +123,18 @@ export default function PromoCodeDetailPage() { )} > - + {detail.name} {detail.code} @@ -147,30 +143,67 @@ export default function PromoCodeDetailPage() { {PROMO_CODE_STATUS_LABELS[detail.status] || detail.status} - - - {detail.id}(小程序码 scene) + + + {detail.id} - - {detail.qrcodeId} + + + {detail.qrcodeId} + {detail.qrcodeUrl ? ( - + {detail.qrcodeUrl} ) : '—'} - {detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'} + + {detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'} + {detail.remark || '—'} - {fmtTime(detail.createdAt)} - {fmtTime(detail.updatedAt)} + + {fmtTime(detail.createdAt)} + + + {fmtTime(detail.updatedAt)} + + + + + {detail.qrcodeUrl ? ( + <> + 推广小程序码 + + scene={detail.id} + + + + ) : ( + 暂无小程序码 + )} + + diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index b69d17c..22c12b5 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -74,7 +74,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ group: G.wechat_mini, type: 'imageList', requiresRestart: false, - description: '小程序商品首页顶部轮播,建议比例 15:8,最多 8 张', + description: '小程序商品首页顶部轮播,建议比例 15:8,最多 8 张;上传后需点击右上角「保存」', }, { key: 'MINI_HOME_FOOTER_URL', @@ -82,7 +82,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ group: G.wechat_mini, type: 'image', requiresRestart: false, - description: '小程序商品首页底部 footer,建议比例 15:4', + description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」', }, { key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true }, diff --git a/server/dukang-api/src/common/system-config/system-config.service.ts b/server/dukang-api/src/common/system-config/system-config.service.ts index 7a4e883..f732a8a 100644 --- a/server/dukang-api/src/common/system-config/system-config.service.ts +++ b/server/dukang-api/src/common/system-config/system-config.service.ts @@ -5,7 +5,7 @@ import type { SystemConfigSyncResult, SystemConfigUpdateRequest, } from '@dukang/shared-types'; -import { loadAppConfig } from '@dukang/shared-types'; +import { loadAppConfig, parseMiniHomeBanners, serializeMiniHomeBanners } from '@dukang/shared-types'; import { PrismaService } from '../prisma/prisma.module'; import { SYSTEM_CONFIG_FIELDS, @@ -209,6 +209,12 @@ export class SystemConfigService implements OnModuleInit { if (meta.type === 'boolean') { return raw === 'true' || raw === '1' ? 'true' : 'false'; } + if (meta.type === 'imageList') { + return serializeMiniHomeBanners(parseMiniHomeBanners(raw)); + } + if (meta.type === 'image') { + return raw.trim(); + } if (meta.key === 'WX_MCH_PRIVATE_KEY' || meta.key === 'WX_PLATFORM_CERT') { let value = raw.trim(); if (