From fc2e5b65de867a0432604ea425a3a43cc7e13f43 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Thu, 20 Aug 2026 18:54:15 +0800 Subject: [PATCH] =?UTF-8?q?v3.5.3=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B01?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StorePackageAuditPanel.tsx | 453 ++++++++++++++++ .../src/pages/StorePackageAuditsPage.tsx | 490 ++++-------------- apps/admin-web/src/pages/StoresPage.tsx | 144 ++++- .../src/pages/SystemSettingsPage.tsx | 87 +++- .../src/components/MultiOssUploadField.tsx | 195 ++++--- apps/h5-partner/src/pages/StoreCreatePage.tsx | 2 +- apps/h5-partner/src/pages/StoreDetailPage.tsx | 200 ++++--- apps/h5-partner/src/styles.css | 9 + .../src/components/WeakNetFallbackPanel.tsx | 19 +- .../h5-shop/src/contexts/ShopToastContext.tsx | 53 ++ apps/h5-shop/src/lib/money.ts | 27 + apps/h5-shop/src/lib/toast.ts | 23 + apps/h5-shop/src/main.tsx | 5 +- apps/h5-shop/src/pages/HomePage.tsx | 13 +- apps/h5-shop/src/pages/PhoneRedeemPage.tsx | 22 +- apps/h5-shop/src/pages/RecordsPage.tsx | 13 +- apps/h5-shop/src/pages/RedeemConfirmPage.tsx | 15 +- apps/h5-shop/src/pages/RedeemSuccessPage.tsx | 9 +- apps/h5-shop/src/pages/WithdrawPage.tsx | 11 +- apps/h5-shop/src/styles.css | 23 + apps/mini-user/src/app.css | 68 +++ .../src/assets/icons/store-benefit.png | Bin 0 -> 29106 bytes .../src/components/BenefitFigure.tsx | 27 + apps/mini-user/src/components/CouponBadge.tsx | 10 +- .../src/components/FloatingToastHost.tsx | 32 ++ apps/mini-user/src/components/PageShell.tsx | 2 + .../src/components/ProductCarousel.tsx | 11 +- apps/mini-user/src/lib/api.ts | 9 +- apps/mini-user/src/lib/floating-toast.ts | 18 + apps/mini-user/src/lib/money.ts | 23 +- apps/mini-user/src/lib/wechat-share.ts | 44 +- apps/mini-user/src/lib/wechat-share.weapp.ts | 38 +- apps/mini-user/src/pages/benefit/index.tsx | 28 +- apps/mini-user/src/pages/mine/index.tsx | 4 +- .../src/pages/order-confirm-pickup/index.tsx | 7 +- .../src/pages/order-confirm/index.tsx | 7 +- .../src/pages/product-detail/index.tsx | 9 +- .../mini-user/src/pages/redeem-code/index.tsx | 3 +- .../src/pages/redeem-success/index.tsx | 7 +- apps/mini-user/src/pages/redeem/index.tsx | 18 +- .../src/pages/store-detail/index.tsx | 17 +- apps/mini-user/src/styles/benefit.css | 12 +- apps/mini-user/src/styles/home.css | 1 + apps/mini-user/src/styles/mine.css | 4 +- apps/mini-user/src/styles/order.css | 2 + apps/mini-user/src/styles/product-detail.css | 5 + apps/mini-user/src/styles/redeem.css | 16 +- docs/杜康好客-v3-PRD.md | 3 +- docs/杜康好客-v3-现状对照.md | 10 +- ...康好客-v3.4.14-mini-user门店体验开发文档.md | 8 +- docs/杜康好客-v3.5.3-开发文档.md | 153 ++++++ packages/domain/src/index.test.ts | 11 + packages/domain/src/index.ts | 17 +- .../shared-types/src/store-info-change.ts | 6 +- packages/shared-types/src/system-config.ts | 2 + .../shared-types/src/wecom-message-push.ts | 23 +- .../decorators/current-user.decorator.ts | 30 +- .../system-config/system-config.registry.ts | 75 ++- .../wecom/wecom-message-push.service.ts | 135 +++-- .../src/modules/ops/admin-redeem.service.ts | 11 +- .../src/modules/redeem/redeem.service.ts | 78 ++- .../modules/settlement/settlement.service.ts | 84 ++- .../store/store-info-change.service.ts | 192 ++++++- .../modules/store/store-package.service.ts | 33 ++ .../src/modules/store/store.service.ts | 66 +++ 65 files changed, 2297 insertions(+), 875 deletions(-) create mode 100644 apps/admin-web/src/components/StorePackageAuditPanel.tsx create mode 100644 apps/h5-shop/src/contexts/ShopToastContext.tsx create mode 100644 apps/h5-shop/src/lib/money.ts create mode 100644 apps/h5-shop/src/lib/toast.ts create mode 100644 apps/mini-user/src/assets/icons/store-benefit.png create mode 100644 apps/mini-user/src/components/BenefitFigure.tsx create mode 100644 apps/mini-user/src/components/FloatingToastHost.tsx create mode 100644 apps/mini-user/src/lib/floating-toast.ts create mode 100644 docs/杜康好客-v3.5.3-开发文档.md diff --git a/apps/admin-web/src/components/StorePackageAuditPanel.tsx b/apps/admin-web/src/components/StorePackageAuditPanel.tsx new file mode 100644 index 0000000..adc0356 --- /dev/null +++ b/apps/admin-web/src/components/StorePackageAuditPanel.tsx @@ -0,0 +1,453 @@ +import { useEffect, useState } from 'react'; +import { Button, Image, Input, Modal, Space, Tag, Typography, message } from 'antd'; +import type { + StorePackageAuditDetailDto, + StorePackageItemDto, + StorePackageViewDto, +} from '@dukang/shared-types'; +import { normalizeStorePackageImageUrls } from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { notifyPackageAuditChanged } from '../lib/admin-events'; +import { fmtTime } from '../lib/constants'; + +const HQ_PACKAGE_STATUS_LABELS: Record = { + PENDING: '待审核', + APPROVED: '已通过', + REJECTED: '已驳回', +}; + +function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) { + const name = String(pkg.name ?? '').trim(); + return name ? `name:${name}` : `idx:${index}`; +} + +function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) { + return normalizeStorePackageImageUrls(pkg).join('|'); +} + +type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' }; + +function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = []; + let i = 0; + let j = 0; + while (i < m && j < n) { + if (a[i] === b[j]) { + raw.push({ type: 'equal', text: a[i] }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + raw.push({ type: 'delete', text: a[i] }); + i++; + } else { + raw.push({ type: 'insert', text: b[j] }); + j++; + } + } + while (i < m) raw.push({ type: 'delete', text: a[i++] }); + while (j < n) raw.push({ type: 'insert', text: b[j++] }); + const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = []; + for (const s of raw) { + const last = merged[merged.length - 1]; + if (last && last.type === s.type) last.text += s.text; + else merged.push({ ...s }); + } + return merged; +} + +function fieldChanges( + live: StorePackageItemDto | StorePackageViewDto, + proposed: StorePackageItemDto | StorePackageViewDto, +): FieldChange[] { + const changes: FieldChange[] = []; + const text = (v: string | number | null | undefined) => (v ?? '').toString().trim(); + const pushText = (label: string, oldV: string, newV: string) => { + if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' }); + }; + const pushValue = (label: string, oldV: string, newV: string) => { + if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' }); + }; + pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`); + pushText('套餐名称', text(live.name), text(proposed.name)); + pushText('菜品内容', text(live.dishes), text(proposed.dishes)); + pushText('可用时间', text(live.usableTime), text(proposed.usableTime)); + pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes)); + const liveImgs = normalizeStorePackageImageUrls(live); + const proposedImgs = normalizeStorePackageImageUrls(proposed); + if (imageSignature(live) !== imageSignature(proposed)) { + changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' }); + } + return changes; +} + +function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) { + const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p])); + const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p])); + const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]); + const rows: Array<{ + key: string; + change: 'added' | 'removed' | 'changed' | 'unchanged'; + live?: StorePackageViewDto; + proposed?: StorePackageItemDto; + changes?: FieldChange[]; + }> = []; + + for (const key of keys) { + const l = liveMap.get(key); + const p = proposedMap.get(key); + if (l && !p) { + rows.push({ key, change: 'removed', live: l }); + } else if (!l && p) { + rows.push({ key, change: 'added', proposed: p }); + } else if (l && p) { + const changed = + l.price !== p.price || + l.dishes !== p.dishes || + (l.usableTime ?? '') !== (p.usableTime ?? '') || + (l.otherNotes ?? '') !== (p.otherNotes ?? '') || + imageSignature(l) !== imageSignature(p); + rows.push({ + key, + change: changed ? 'changed' : 'unchanged', + live: l, + proposed: p, + changes: changed ? fieldChanges(l, p) : undefined, + }); + } + } + return rows; +} + +const CHANGE_LABELS = { + added: { text: '新增', color: 'green' }, + removed: { text: '删除', color: 'red' }, + changed: { text: '变更', color: 'orange' }, + unchanged: { text: '未变', color: 'default' }, +} as const; + +function TextDiff({ oldText, newText }: { oldText: string; newText: string }) { + const segs = diffText(oldText, newText); + return ( +
+
+ 原: + {segs + .filter((s) => s.type !== 'insert') + .map((s, idx) => + s.type === 'delete' ? ( + + {s.text || '(空)'} + + ) : ( + {s.text} + ), + )} +
+
+ 新: + {segs + .filter((s) => s.type !== 'delete') + .map((s, idx) => + s.type === 'insert' ? ( + + {s.text || '(空)'} + + ) : ( + {s.text} + ), + )} +
+
+ ); +} + +function PackageDetailCard({ + title, + pkg, + change, + changes, +}: { + title?: string; + pkg: StorePackageItemDto | StorePackageViewDto; + change?: keyof typeof CHANGE_LABELS; + changes?: FieldChange[]; +}) { + const images = normalizeStorePackageImageUrls(pkg); + const meta = change ? CHANGE_LABELS[change] : null; + return ( +
+ + {title ? {title} : null} + {meta ? {meta.text} : null} + +
+ {pkg.name} + ¥{pkg.price} +
+ + {pkg.dishes || '—'} + + {pkg.usableTime ? ( + + 可用时间:{pkg.usableTime} + + ) : null} + {pkg.otherNotes ? ( + + 其他说明:{pkg.otherNotes} + + ) : null} + {images.length ? ( + + + {images.map((url) => ( + + ))} + + + ) : ( + 无套餐图片 + )} + {changes && changes.length ? ( +
+ + 变更明细 + +
    + {changes.map((c) => ( +
  • + {c.label}: + {c.kind === 'text' ? ( + + ) : ( + <> + + {c.old} + + + {c.now} + + )} +
  • + ))} +
+
+ ) : null} +
+ ); +} + +export type StorePackageAuditPanelProps = { + requestId: string; + /** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */ + showActions?: boolean; + onAudited?: () => void; + onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void; +}; + +/** 套餐变更对比 + 通过/驳回(审核通知页与门店详情抽屉共用) */ +export default function StorePackageAuditPanel({ + requestId, + showActions = true, + onAudited, + onDetailLoaded, +}: StorePackageAuditPanelProps) { + const [loading, setLoading] = useState(false); + const [detail, setDetail] = useState(null); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectReason, setRejectReason] = useState(''); + const [auditing, setAuditing] = useState(false); + + async function load() { + setLoading(true); + try { + const data = await request(`/admin/store-package-audits/${requestId}`); + setDetail(data); + onDetailLoaded?.(data); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载套餐审核详情失败'); + setDetail(null); + onDetailLoaded?.(null); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requestId]); + + async function audit(action: 'APPROVE' | 'REJECT', reason?: string) { + if (!detail) return; + setAuditing(true); + try { + await request(`/admin/store-package-audits/${detail.id}/audit`, { + method: 'PUT', + body: JSON.stringify(action === 'REJECT' ? { action, rejectReason: reason } : { action }), + }); + message.success(action === 'APPROVE' ? '套餐已通过' : '套餐已驳回'); + notifyPackageAuditChanged(); + onAudited?.(); + await load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setAuditing(false); + } + } + + if (loading && !detail) { + return 加载套餐变更…; + } + if (!detail) { + return 暂无套餐审核详情; + } + + const diffRows = diffPackages(detail.livePackages ?? [], detail.packages ?? []); + const changeByKey = new Map(diffRows.map((row) => [row.key, row.change])); + const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes])); + const addedCount = diffRows.filter((r) => r.change === 'added').length; + const removedCount = diffRows.filter((r) => r.change === 'removed').length; + const changedCount = diffRows.filter((r) => r.change === 'changed').length; + const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length; + + return ( +
+ + + {HQ_PACKAGE_STATUS_LABELS[detail.status] ?? detail.status} + + 提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)} + + + {showActions && detail.status === 'PENDING' ? ( + + + + + ) : null} + + {detail.rejectReason ? ( + 驳回原因:{detail.rejectReason} + ) : null} + + + 线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条 + + + 新增 {addedCount} + 删除 {removedCount} + 变更 {changedCount} + {unchangedCount ? 未变 {unchangedCount} : null} + + +
+
+ + 线上已审核套餐 + + {(detail.livePackages ?? []).length ? ( + (detail.livePackages ?? []).map((pkg, index) => ( + + )) + ) : ( + 暂无线上套餐 + )} +
+
+ + 待审核套餐 + + {(detail.packages ?? []).length ? ( + (detail.packages ?? []).map((pkg, index) => ( + + )) + ) : ( + 暂无待审核套餐 + )} +
+
+ + setRejectOpen(false)} + onOk={() => { + if (!rejectReason.trim()) { + message.warning('请填写驳回原因'); + return; + } + void audit('REJECT', rejectReason.trim()).then(() => setRejectOpen(false)); + }} + > + setRejectReason(e.target.value)} + /> + +
+ ); +} + +/** 供门店详情抽屉 extra 调用 */ +export async function auditStorePackageRequest( + requestId: string, + action: 'APPROVE' | 'REJECT', + rejectReason?: string, +) { + await request(`/admin/store-package-audits/${requestId}/audit`, { + method: 'PUT', + body: JSON.stringify(action === 'REJECT' ? { action, rejectReason } : { action }), + }); + notifyPackageAuditChanged(); +} diff --git a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx index c13cc3a..3c16615 100644 --- a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx +++ b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx @@ -20,20 +20,15 @@ import type { StoreInfoChangeFieldDiff, StoreInfoChangeRequestDto, StoreInfoChangeStatus, - StorePackageAuditDetailDto, StorePackageAuditSummaryDto, StorePackageChangeRequestDto, StorePackageChangeStatus, - StorePackageItemDto, - StorePackageViewDto, -} from '@dukang/shared-types'; -import { - STORE_INFO_CHANGE_STATUS_LABELS, - normalizeStorePackageImageUrls, } from '@dukang/shared-types'; +import { STORE_INFO_CHANGE_STATUS_LABELS } from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; import { notifyPackageAuditChanged } from '../lib/admin-events'; import { fmtTime } from '../lib/constants'; +import StorePackageAuditPanel from '../components/StorePackageAuditPanel'; const HQ_PACKAGE_STATUS_LABELS: Record = { PENDING: '待审核', @@ -41,270 +36,6 @@ const HQ_PACKAGE_STATUS_LABELS: Record = { REJECTED: '已驳回', }; -function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) { - const name = String(pkg.name ?? '').trim(); - return name ? `name:${name}` : `idx:${index}`; -} - -function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) { - return normalizeStorePackageImageUrls(pkg).join('|'); -} - -type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' }; - -/** 文本逐字差异:LCS 比对,产出 equal / delete / insert 段落,用于高亮具体变了哪些字 */ -function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> { - const m = a.length; - const n = b.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); - for (let i = m - 1; i >= 0; i--) { - for (let j = n - 1; j >= 0; j--) { - dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); - } - } - const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = []; - let i = 0; - let j = 0; - while (i < m && j < n) { - if (a[i] === b[j]) { - raw.push({ type: 'equal', text: a[i] }); - i++; - j++; - } else if (dp[i + 1][j] >= dp[i][j + 1]) { - raw.push({ type: 'delete', text: a[i] }); - i++; - } else { - raw.push({ type: 'insert', text: b[j] }); - j++; - } - } - while (i < m) raw.push({ type: 'delete', text: a[i++] }); - while (j < n) raw.push({ type: 'insert', text: b[j++] }); - const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = []; - for (const s of raw) { - const last = merged[merged.length - 1]; - if (last && last.type === s.type) last.text += s.text; - else merged.push({ ...s }); - } - return merged; -} - -/** 逐字段比较套餐内容,返回发生变化的字段明细(用于“变动的地方详细列出”) */ -function fieldChanges( - live: StorePackageItemDto | StorePackageViewDto, - proposed: StorePackageItemDto | StorePackageViewDto, -): FieldChange[] { - const changes: FieldChange[] = []; - const text = (v: string | number | null | undefined) => (v ?? '').toString().trim(); - const pushText = (label: string, oldV: string, newV: string) => { - if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' }); - }; - const pushValue = (label: string, oldV: string, newV: string) => { - if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' }); - }; - pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`); - pushText('套餐名称', text(live.name), text(proposed.name)); - pushText('菜品内容', text(live.dishes), text(proposed.dishes)); - pushText('可用时间', text(live.usableTime), text(proposed.usableTime)); - pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes)); - const liveImgs = normalizeStorePackageImageUrls(live); - const proposedImgs = normalizeStorePackageImageUrls(proposed); - if (imageSignature(live) !== imageSignature(proposed)) { - changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' }); - } - return changes; -} - -function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) { - const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p])); - const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p])); - const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]); - const rows: Array<{ - key: string; - change: 'added' | 'removed' | 'changed' | 'unchanged'; - live?: StorePackageViewDto; - proposed?: StorePackageItemDto; - changes?: FieldChange[]; - }> = []; - - for (const key of keys) { - const l = liveMap.get(key); - const p = proposedMap.get(key); - if (l && !p) { - rows.push({ key, change: 'removed', live: l }); - } else if (!l && p) { - rows.push({ key, change: 'added', proposed: p }); - } else if (l && p) { - const changed = - l.price !== p.price || - l.dishes !== p.dishes || - (l.usableTime ?? '') !== (p.usableTime ?? '') || - (l.otherNotes ?? '') !== (p.otherNotes ?? '') || - imageSignature(l) !== imageSignature(p); - rows.push({ - key, - change: changed ? 'changed' : 'unchanged', - live: l, - proposed: p, - changes: changed ? fieldChanges(l, p) : undefined, - }); - } - } - return rows; -} - -const CHANGE_LABELS = { - added: { text: '新增', color: 'green' }, - removed: { text: '删除', color: 'red' }, - changed: { text: '变更', color: 'orange' }, - unchanged: { text: '未变', color: 'default' }, -} as const; - -/** 文本逐字差异渲染:原行红色删除线标出被删的字,新行绿色标出新增的字 */ -function TextDiff({ oldText, newText }: { oldText: string; newText: string }) { - const segs = diffText(oldText, newText); - return ( -
-
- 原: - {segs - .filter((s) => s.type !== 'insert') - .map((s, idx) => - s.type === 'delete' ? ( - - {s.text || '(空)'} - - ) : ( - {s.text} - ), - )} -
-
- 新: - {segs - .filter((s) => s.type !== 'delete') - .map((s, idx) => - s.type === 'insert' ? ( - - {s.text || '(空)'} - - ) : ( - {s.text} - ), - )} -
-
- ); -} - -function PackageDetailCard({ - title, - pkg, - change, - changes, -}: { - title?: string; - pkg: StorePackageItemDto | StorePackageViewDto; - change?: keyof typeof CHANGE_LABELS; - changes?: FieldChange[]; -}) { - const images = normalizeStorePackageImageUrls(pkg); - const meta = change ? CHANGE_LABELS[change] : null; - return ( -
- - {title ? ( - {title} - ) : null} - {meta ? {meta.text} : null} - -
- {pkg.name} - ¥{pkg.price} -
- - {pkg.dishes || '—'} - - {pkg.usableTime ? ( - - 可用时间:{pkg.usableTime} - - ) : null} - {pkg.otherNotes ? ( - - 其他说明:{pkg.otherNotes} - - ) : null} - {images.length ? ( - - - {images.map((url) => ( - - ))} - - - ) : ( - 无套餐图片 - )} - {changes && changes.length ? ( -
- - 变更明细 - -
    - {changes.map((c) => ( -
  • - {c.label}: - {c.kind === 'text' ? ( - - ) : ( - <> - - {c.old} - - - {c.now} - - )} -
  • - ))} -
-
- ) : null} -
- ); -} - const INFO_CHANGE_FIELD_LABELS: Record = { name: '门店名称', contactPhone: '联系电话', @@ -318,6 +49,8 @@ const INFO_CHANGE_FIELD_LABELS: Record = { openTime2: '第二段开始', closeTime2: '第二段结束', avgPrice: '人均费用', + coverUrl: '门头照', + envPhotoUrls: '环境照片', }; function fmtFieldValue(field: string, v: unknown): string { @@ -325,9 +58,73 @@ function fmtFieldValue(field: string, v: unknown): string { if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') { return String(v); } + if (field === 'envPhotoUrls' && Array.isArray(v)) { + return `${v.length} 张`; + } + if (field === 'coverUrl') return '见对比图'; return String(v); } +function InfoChangeImageDiff({ + field, + live, + proposed, +}: { + field: string; + live: unknown; + proposed: unknown; +}) { + const liveUrls = + field === 'coverUrl' + ? [String(live || '').trim()].filter(Boolean) + : Array.isArray(live) + ? live.map((u) => String(u || '').trim()).filter(Boolean) + : []; + const proposedUrls = + field === 'coverUrl' + ? [String(proposed || '').trim()].filter(Boolean) + : Array.isArray(proposed) + ? proposed.map((u) => String(u || '').trim()).filter(Boolean) + : []; + + return ( +
+
+ + 变更前 + + {liveUrls.length ? ( + + + {liveUrls.map((url) => ( + + ))} + + + ) : ( + (空) + )} +
+
+ + 变更后 + + {proposedUrls.length ? ( + + + {proposedUrls.map((url) => ( + + ))} + + + ) : ( + (空) + )} +
+
+ ); +} + function InfoChangeAuditPanel({ initialRequestId, }: { @@ -544,15 +341,19 @@ function InfoChangeAuditPanel({ key={d.field} label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field} > - - - {fmtFieldValue(d.field, d.live)} - - - - {fmtFieldValue(d.field, d.proposed)} - - + {d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? ( + + ) : ( + + + {fmtFieldValue(d.field, d.live)} + + + + {fmtFieldValue(d.field, d.proposed)} + + + )} ))} @@ -599,8 +400,8 @@ export default function StorePackageAuditsPage() { const [rejectReason, setRejectReason] = useState(''); const [activeId, setActiveId] = useState(null); const [detailOpen, setDetailOpen] = useState(false); - const [detailLoading, setDetailLoading] = useState(false); - const [detail, setDetail] = useState(null); + const [activeRequestId, setActiveRequestId] = useState(null); + const [drawerTitle, setDrawerTitle] = useState('套餐变更详情'); async function reload(nextPage = page, nextStatus = status) { setLoading(true); @@ -629,30 +430,23 @@ export default function StorePackageAuditsPage() { void reload(1, status); }, [status]); - // 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉 const [searchParams] = useSearchParams(); const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package'; const [activeTab, setActiveTab] = useState(initialTab); const infoRequestId = searchParams.get('infoRequestId'); useEffect(() => { const rid = searchParams.get('requestId'); - if (rid) void openDetail(rid); + if (rid) { + setActiveRequestId(rid); + setDetailOpen(true); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - async function openDetail(id: string) { + function openDetail(id: string) { + setActiveRequestId(id); + setDrawerTitle('套餐变更详情'); setDetailOpen(true); - setDetailLoading(true); - setDetail(null); - try { - const data = await request(`/admin/store-package-audits/${id}`); - setDetail(data); - } catch (e) { - message.error(e instanceof Error ? e.message : '加载详情失败'); - setDetailOpen(false); - } finally { - setDetailLoading(false); - } } async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) { @@ -672,14 +466,6 @@ export default function StorePackageAuditsPage() { } } - const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : []; - const changeByKey = new Map(diffRows.map((row) => [row.key, row.change])); - const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes])); - const addedCount = diffRows.filter((r) => r.change === 'added').length; - const removedCount = diffRows.filter((r) => r.change === 'removed').length; - const changedCount = diffRows.filter((r) => r.change === 'changed').length; - const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length; - const columns: ColumnsType = [ { title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId }, { @@ -702,7 +488,7 @@ export default function StorePackageAuditsPage() { title: '操作', render: (_, row) => ( - {row.status === 'PENDING' ? ( @@ -781,90 +567,23 @@ export default function StorePackageAuditsPage() { /> setDetailOpen(false)} - extra={ - detail?.status === 'PENDING' ? ( - - - - - ) : null - } + destroyOnClose > - {detailLoading ? ( - 加载中… - ) : detail ? ( - <> - - {HQ_PACKAGE_STATUS_LABELS[detail.status]} - - 提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)} - - - {detail.rejectReason ? ( - 驳回原因:{detail.rejectReason} - ) : null} - - - 线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条 - - - 新增 {addedCount} - 删除 {removedCount} - 变更 {changedCount} - {unchangedCount ? 未变 {unchangedCount} : null} - - -
-
- - 线上已审核套餐 - - {(detail.livePackages ?? []).length ? ( - (detail.livePackages ?? []).map((pkg, index) => ( - - )) - ) : ( - 暂无线上套餐 - )} -
-
- - 待审核套餐 - - {(detail.packages ?? []).length ? ( - (detail.packages ?? []).map((pkg, index) => ( - - )) - ) : ( - 暂无待审核套餐 - )} -
-
- + {activeRequestId ? ( + { + setDetailOpen(false); + void reload(page, status); + }} + onDetailLoaded={(d) => { + if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`); + }} + /> ) : null}
@@ -892,3 +611,4 @@ export default function StorePackageAuditsPage() { ); } + diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 79985a9..47a44ed 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Alert, + Badge, Button, Checkbox, Descriptions, @@ -46,6 +47,9 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal'; import AdminStorePackagesSection, { type AdminStorePackagesHandle, } from '../components/AdminStorePackagesSection'; +import StorePackageAuditPanel, { + auditStorePackageRequest, +} from '../components/StorePackageAuditPanel'; const CREATE_STEPS = [ { title: '基本信息' }, @@ -270,7 +274,11 @@ export default function StoresPage() { const [filterPartners, setFilterPartners] = useState([]); const [detail, setDetail] = useState | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [detailTab, setDetailTab] = useState('basic'); const packagesRef = useRef(null); + const [packageRejectOpen, setPackageRejectOpen] = useState(false); + const [packageRejectReason, setPackageRejectReason] = useState(''); + const [packageAuditing, setPackageAuditing] = useState(false); const [rejectOpen, setRejectOpen] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [auditing, setAuditing] = useState(false); @@ -348,13 +356,18 @@ export default function StoresPage() { .map((n) => ({ value: n.id, label: n.name })); }, [categoryTree, editCategoryParentId]); - async function openStoreDetail(row: StoreRow) { + async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) { const [d, cats] = await Promise.all([ request>(`/admin/stores/${row.id}`), request('/admin/store-categories').catch(() => [] as CategoryNode[]), ]); setCategoryTree(Array.isArray(cats) ? cats : []); - setDetail(d); + setDetail({ + ...d, + pendingPackageAuditId: row.pendingPackageAuditId ?? d.pendingPackageAuditId, + pendingInfoChangeId: row.pendingInfoChangeId ?? d.pendingInfoChangeId, + }); + setDetailTab(opts?.tab || 'basic'); const category = d.category && typeof d.category === 'object' ? (d.category as { id?: string; parentId?: string | null }) : null; @@ -776,22 +789,13 @@ export default function StoresPage() { {row.pendingPackageAuditId ? ( - <> - - - + ) : null} {row.pendingInfoChangeId ? ( <> @@ -903,7 +907,7 @@ export default function StoresPage() { }, }} /> - setDrawerOpen(false)} + setDrawerOpen(false)} extra={detail && ( {String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? ( @@ -940,6 +944,41 @@ export default function StoresPage() { ) : null} + {detail.pendingPackageAuditId ? ( + <> + + + + ) : null} - {urls.length > 0 ? ( -
- {urls.map((url, index) => ( -
- {isPdf(url) ? ( - - - description - - PDF - - ) : ( - - )} - {!disabled ? ( - - ) : null} -
- ))} + {isGrid ? ( +
+ {urls.map((url, index) => thumb(url, index))} + {remaining > 0 && !disabled ? ( + + ) : null}
- ) : null} - - + ) : ( + <> + {urls.length > 0 ? ( +
+ {urls.map((url, index) => thumb(url, index))} +
+ ) : null} + + + )} {error ? (

{error} diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index cb4f387..04c8879 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -1007,9 +1007,9 @@ export default function StoreCreatePage() { u.trim()).filter(Boolean)} onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))} - label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20)`} /> diff --git a/apps/h5-partner/src/pages/StoreDetailPage.tsx b/apps/h5-partner/src/pages/StoreDetailPage.tsx index d691521..975ff19 100644 --- a/apps/h5-partner/src/pages/StoreDetailPage.tsx +++ b/apps/h5-partner/src/pages/StoreDetailPage.tsx @@ -56,7 +56,6 @@ export default function StoreDetailPage() { const [status, setStatus] = useState('OPEN'); const [statusSaving, setStatusSaving] = useState(false); const [saving, setSaving] = useState(false); - const [mediaSaving, setMediaSaving] = useState(false); const [mapPickerOpen, setMapPickerOpen] = useState(false); const [actionError, setActionError] = useState(''); const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); @@ -159,32 +158,133 @@ export default function StoreDetailPage() { } } - async function saveBasic() { + function mediaChanged(): boolean { + if (!store) return false; + const nextCover = coverUrl.trim(); + const origCover = String(store.coverUrl || '').trim(); + if (nextCover !== origCover) return true; + const nextEnv = uniqueEnvUrls(envPhotoUrls); + const origEnv = uniqueEnvUrls( + Array.isArray(store.media) + ? (store.media as Array<{ url?: string; bizType?: string }>) + .filter((m) => m.bizType === 'ENV') + .map((m) => String(m.url || '')) + : [], + ); + if (nextEnv.length !== origEnv.length) return true; + return nextEnv.some((u, i) => u !== origEnv[i]); + } + + function basicChanged(): boolean { + if (!store) return false; + const liveContact = String(store.contactPhone || store.phone || '').trim(); + const liveIntro = String(store.intro || '').trim(); + const liveRuleRaw = String(store.benefitUsageRule || '').trim(); + const liveRule = liveRuleRaw && !/^null$/i.test(liveRuleRaw) ? liveRuleRaw : ''; + const liveLat = + store.latitude != null && store.latitude !== '' ? String(store.latitude) : ''; + const liveLng = + store.longitude != null && store.longitude !== '' ? String(store.longitude) : ''; + return ( + form.name.trim() !== String(store.name || '').trim() || + form.contactPhone.trim() !== liveContact || + form.address.trim() !== String(store.address || '').trim() || + form.intro.trim() !== liveIntro || + form.benefitUsageRule.trim() !== liveRule || + form.latitude.trim() !== liveLat || + form.longitude.trim() !== liveLng + ); + } + + async function saveChanges() { if (!id || saving || status === 'CLOSED') return; const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase(); if (auditStatus === 'PENDING') { setActionError('门店审核中,暂不可修改资料'); return; } + + const wantMedia = mediaChanged(); + const wantBasic = basicChanged(); + if (!wantMedia && !wantBasic) { + setActionError('没有检测到需要变更的字段'); + return; + } + + const nextCover = coverUrl.trim(); + const nextEnv = uniqueEnvUrls(envPhotoUrls); + if (wantMedia) { + if (!nextCover) { + setActionError('请上传门头照'); + return; + } + if (nextEnv.length < MIN_ENV_PHOTO_COUNT) { + setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`); + return; + } + } + setSaving(true); setActionError(''); try { - // v3.5.1 #5:基本信息变更走「提交变更」审核流,由总部审核通过后覆盖门店 - await submitStoreInfoChangeRequest(id, { - name: form.name.trim(), - contactPhone: form.contactPhone.trim(), - address: form.address.trim(), - intro: form.intro.trim(), - benefitUsageRule: form.benefitUsageRule.trim() || null, - ...(form.latitude.trim() && form.longitude.trim() - ? { - latitude: Number(form.latitude), - longitude: Number(form.longitude), - } - : {}), - }); + // 入驻被驳回:直写 media/basic 并重提门店审核;已通过门店:统一走信息变更审核 + if (auditStatus === 'REJECTED') { + if (wantMedia) { + const data = await request>('PARTNER_H5', `/partner/stores/${id}/media`, { + method: 'PUT', + body: JSON.stringify({ + coverUrl: nextCover, + envPhotoUrls: nextEnv, + }), + }); + applyStore(data); + } + if (wantBasic) { + const data = await request>('PARTNER_H5', `/partner/stores/${id}/basic`, { + method: 'PUT', + body: JSON.stringify({ + name: form.name.trim(), + contactPhone: form.contactPhone.trim(), + address: form.address.trim(), + intro: form.intro.trim(), + benefitUsageRule: form.benefitUsageRule.trim() || null, + ...(form.latitude.trim() && form.longitude.trim() + ? { + latitude: Number(form.latitude), + longitude: Number(form.longitude), + } + : {}), + }), + }); + applyStore(data); + } + toastSuccess('已重新提交审核'); + return; + } + + const fields: Record = {}; + if (wantBasic) { + fields.name = form.name.trim(); + fields.contactPhone = form.contactPhone.trim(); + fields.address = form.address.trim(); + fields.intro = form.intro.trim(); + fields.benefitUsageRule = form.benefitUsageRule.trim() || null; + if (form.latitude.trim() && form.longitude.trim()) { + fields.latitude = Number(form.latitude); + fields.longitude = Number(form.longitude); + } + } + if (wantMedia) { + fields.coverUrl = nextCover; + fields.envPhotoUrls = nextEnv; + } + await submitStoreInfoChangeRequest(id, fields); setPendingInfoChange(true); - toastSuccess('变更已提交,等待总部审核'); + toastSuccess( + wantMedia + ? '变更已提交(含门头照/环境图),总部审核通过后生效' + : '变更已提交,等待总部审核', + ); } catch (e) { setActionError(e instanceof Error ? e.message : '提交失败'); } finally { @@ -192,42 +292,6 @@ export default function StoreDetailPage() { } } - async function saveMedia() { - if (!id || mediaSaving || status === 'CLOSED') return; - const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase(); - if (auditStatus === 'PENDING') { - setActionError('门店审核中,暂不可修改资料'); - return; - } - const nextCover = coverUrl.trim(); - const nextEnv = uniqueEnvUrls(envPhotoUrls); - if (!nextCover) { - setActionError('请上传门头照'); - return; - } - if (nextEnv.length < MIN_ENV_PHOTO_COUNT) { - setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`); - return; - } - setMediaSaving(true); - setActionError(''); - try { - const data = await request>('PARTNER_H5', `/partner/stores/${id}/media`, { - method: 'PUT', - body: JSON.stringify({ - coverUrl: nextCover, - envPhotoUrls: nextEnv, - }), - }); - applyStore(data); - toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新'); - } catch (e) { - setActionError(e instanceof Error ? e.message : '照片更新失败'); - } finally { - setMediaSaving(false); - } - } - if (loadError) { return (

@@ -445,27 +509,13 @@ export default function StoreDetailPage() {
{canMutate && !readOnly ? ( - <> - setEnvPhotoUrls(urls.length ? urls : [''])} - label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20)`} - /> - - + setEnvPhotoUrls(urls.length ? urls : [''])} + /> ) : envPhotos.length > 0 ? (
{envPhotos.map((url, index) => ( @@ -495,7 +545,7 @@ export default function StoreDetailPage() {
{canMutate && ( - diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index 9dc077a..5346b42 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -1622,10 +1622,19 @@ nav.app-tabbar .app-tabbar-label { gap: 8px; } +.partner-upload-grid-thumb { + position: relative; + aspect-ratio: 1; + width: 100%; + border-radius: 12px; + overflow: hidden; +} + .partner-upload-grid .partner-upload-dashed { aspect-ratio: 1; padding: 12px; font-size: 10px; + width: 100%; } .partner-upload-dashed--compact { diff --git a/apps/h5-shop/src/components/WeakNetFallbackPanel.tsx b/apps/h5-shop/src/components/WeakNetFallbackPanel.tsx index 0a96280..f74b069 100644 --- a/apps/h5-shop/src/components/WeakNetFallbackPanel.tsx +++ b/apps/h5-shop/src/components/WeakNetFallbackPanel.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { request } from '../lib/api'; +import { toastError, toastSuccess } from '../lib/toast'; import { uploadRedeemPendingPhoto } from '../lib/upload'; import { isWechatEnv, weixinSdk } from '../lib/weixin'; import type { RedeemPendingSubmitResult } from '@dukang/shared-types'; @@ -18,25 +19,22 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) const [previewUrl, setPreviewUrl] = useState(''); const [photoResourceId, setPhotoResourceId] = useState(''); const [result, setResult] = useState(null); - const [msg, setMsg] = useState(''); const [showAlbumFallback, setShowAlbumFallback] = useState(false); async function handleFile(file: File) { setUploading(true); - setMsg(''); try { const registered = await uploadRedeemPendingPhoto(file); setPhotoResourceId(registered.id); setPreviewUrl(registered.url); } catch (e) { - setMsg(e instanceof Error ? e.message : '上传失败'); + toastError(e instanceof Error ? e.message : '上传失败'); } finally { setUploading(false); } } async function pickPhoto() { - setMsg(''); if (isWechatEnv()) { try { setUploading(true); @@ -51,7 +49,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) } catch (e) { const text = e instanceof Error ? e.message : '选图失败'; if (!/cancel/i.test(text)) { - setMsg(`${text},可改从系统相册选择`); + toastError(`${text},可改从系统相册选择`); setShowAlbumFallback(true); inputRef.current?.click(); } @@ -65,11 +63,10 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) async function submitPending() { if (!photoResourceId) { - setMsg('请先拍摄或上传核销码照片'); + toastError('请先拍摄或上传核销码照片'); return; } setSubmitting(true); - setMsg(''); try { const res = await request('SHOP_H5', '/shop/redeem/pending', { method: 'POST', @@ -81,7 +78,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) }); setResult(res); } catch (e) { - setMsg(e instanceof Error ? e.message : '提交失败'); + toastError(e instanceof Error ? e.message : '提交失败'); } finally { setSubmitting(false); } @@ -89,8 +86,8 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) function copyText(text: string) { void navigator.clipboard?.writeText(text).then( - () => setMsg('已复制'), - () => setMsg('复制失败,请手动长按复制'), + () => toastSuccess('已复制'), + () => toastError('复制失败,请手动长按复制'), ); } @@ -169,8 +166,6 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
)} - - {msg &&

{msg}

} ); } diff --git a/apps/h5-shop/src/contexts/ShopToastContext.tsx b/apps/h5-shop/src/contexts/ShopToastContext.tsx new file mode 100644 index 0000000..aad72d8 --- /dev/null +++ b/apps/h5-shop/src/contexts/ShopToastContext.tsx @@ -0,0 +1,53 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; +import { registerShopToastListener, type ShopToastVariant } from '../lib/toast'; + +type ShopToastContextValue = { + showToast: (message: string, variant?: ShopToastVariant) => void; +}; + +const ShopToastContext = createContext(null); + +export function ShopToastProvider({ children }: { children: ReactNode }) { + const [toast, setToast] = useState(''); + const [variant, setVariant] = useState('error'); + const timerRef = useRef(null); + + const showToast = useCallback((message: string, nextVariant: ShopToastVariant = 'error') => { + if (timerRef.current) window.clearTimeout(timerRef.current); + setVariant(nextVariant); + setToast(message); + timerRef.current = window.setTimeout(() => { + setToast(''); + timerRef.current = null; + }, nextVariant === 'error' ? 2800 : 2000); + }, []); + + useEffect(() => { + registerShopToastListener(showToast); + return () => { + registerShopToastListener(null); + if (timerRef.current) window.clearTimeout(timerRef.current); + }; + }, [showToast]); + + return ( + + {children} + {toast ? ( +
+ {toast} +
+ ) : null} +
+ ); +} + +export function useShopToast(): ShopToastContextValue { + const ctx = useContext(ShopToastContext); + if (!ctx) throw new Error('useShopToast 必须在 ShopToastProvider 内使用'); + return ctx; +} diff --git a/apps/h5-shop/src/lib/money.ts b/apps/h5-shop/src/lib/money.ts new file mode 100644 index 0000000..a231741 --- /dev/null +++ b/apps/h5-shop/src/lib/money.ts @@ -0,0 +1,27 @@ +/** 把接口里的金额(number / 数字字符串 / Prisma Decimal 残影)转成有限数字 */ +export function toMoneyNumber(value: unknown): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (typeof value === 'string' && value.trim()) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; + } + if (value && typeof value === 'object') { + const o = value as { toNumber?: () => number; toString?: () => string; d?: unknown }; + if (typeof o.toNumber === 'function') { + const n = Number(o.toNumber()); + if (Number.isFinite(n)) return n; + } + if (typeof o.toString === 'function' && o.toString !== Object.prototype.toString) { + const n = Number(o.toString()); + if (Number.isFinite(n)) return n; + } + } + return 0; +} + +export function formatMoney(n: number) { + return toMoneyNumber(n).toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} diff --git a/apps/h5-shop/src/lib/toast.ts b/apps/h5-shop/src/lib/toast.ts new file mode 100644 index 0000000..4d4a41a --- /dev/null +++ b/apps/h5-shop/src/lib/toast.ts @@ -0,0 +1,23 @@ +export type ShopToastVariant = 'success' | 'error'; + +type ShopToastListener = (message: string, variant: ShopToastVariant) => void; + +let listener: ShopToastListener | null = null; + +export function registerShopToastListener(fn: ShopToastListener | null) { + listener = fn; +} + +export function showShopToast(message: string, variant: ShopToastVariant = 'error') { + const text = message.trim(); + if (!text || !listener) return; + listener(text, variant); +} + +export function toastError(message: string) { + showShopToast(message, 'error'); +} + +export function toastSuccess(message: string) { + showShopToast(message, 'success'); +} diff --git a/apps/h5-shop/src/main.tsx b/apps/h5-shop/src/main.tsx index d0c0474..caa9ba1 100644 --- a/apps/h5-shop/src/main.tsx +++ b/apps/h5-shop/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { getRouterBasename } from '@dukang/weixin-sdk'; import { StoreSessionProvider } from './contexts/StoreSessionContext'; +import { ShopToastProvider } from './contexts/ShopToastContext'; import { installClientErrorReporting } from '@dukang/client-logging'; import App from './App'; import { apiBase } from './lib/api'; @@ -19,7 +20,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index d6cf65a..3a068c3 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -9,6 +9,7 @@ import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk'; import { useStoreSession } from '../contexts/StoreSessionContext'; import { request } from '../lib/api'; +import { formatMoney, toMoneyNumber } from '../lib/money'; import { parseRedeemTokenFromScan } from '../lib/redeem-scan'; @@ -46,14 +47,6 @@ import { trackStore } from '../lib/analytics'; -function formatMoney(n: number) { - - return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); - -} - - - function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string { const msg = e instanceof Error ? e.message : '扫码失败,请重试'; @@ -476,7 +469,7 @@ export default function HomePage() { ¥ - {formatMoney(Number(dash?.todayAmount || 0))} + {formatMoney(toMoneyNumber(dash?.todayAmount))}

@@ -596,7 +589,7 @@ export default function HomePage() {
-

¥{formatMoney(Number(r.amount))}

+

¥{formatMoney(toMoneyNumber(r.amount))}

diff --git a/apps/h5-shop/src/pages/PhoneRedeemPage.tsx b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx index a3e0b76..d5aa326 100644 --- a/apps/h5-shop/src/pages/PhoneRedeemPage.tsx +++ b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types'; import { request } from '../lib/api'; +import { toastError, toastSuccess } from '../lib/toast'; import { useStorePageView } from '../lib/usePageView'; function formatAmount(n: number) { @@ -17,7 +18,6 @@ export default function PhoneRedeemPage() { const [prepared, setPrepared] = useState(null); const [storeName, setStoreName] = useState(''); const [storeClosed, setStoreClosed] = useState(false); - const [msg, setMsg] = useState(''); const [loading, setLoading] = useState(false); const [confirmCooldown, setConfirmCooldown] = useState(0); const [showOpenModal, setShowOpenModal] = useState(false); @@ -41,11 +41,10 @@ export default function PhoneRedeemPage() { async function prepareDirectRedeem() { const value = Number(amount); if (!Number.isFinite(value) || value <= 0) { - setMsg('请输入有效核销金额'); + toastError('请输入有效核销金额'); return; } setLoading(true); - setMsg(''); try { const result = await request('SHOP_H5', '/shop/redeem/phone/prepare-direct', { method: 'POST', @@ -54,10 +53,10 @@ export default function PhoneRedeemPage() { setPrepared(result); setConfirmCode(''); setConfirmCooldown(60); - setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`); + toastSuccess(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`); } catch (e) { setPrepared(null); - setMsg(e instanceof Error ? e.message : '发送验证码失败'); + toastError(e instanceof Error ? e.message : '发送验证码失败'); } finally { setLoading(false); } @@ -65,7 +64,7 @@ export default function PhoneRedeemPage() { async function sendConfirmSms() { if (!/^1\d{10}$/.test(phone.trim())) { - setMsg('请输入正确的手机号'); + toastError('请输入正确的手机号'); return; } if (storeClosed) { @@ -88,7 +87,7 @@ export default function PhoneRedeemPage() { await prepareDirectRedeem(); } catch (e) { setShowOpenModal(false); - setMsg(e instanceof Error ? e.message : '开启营业失败'); + toastError(e instanceof Error ? e.message : '开启营业失败'); } finally { setOpening(false); } @@ -96,15 +95,14 @@ export default function PhoneRedeemPage() { async function confirmRedeem() { if (!prepared) { - setMsg('请先发送核销验证码'); + toastError('请先发送核销验证码'); return; } if (!confirmCode.trim()) { - setMsg('请输入确认验证码'); + toastError('请输入确认验证码'); return; } setLoading(true); - setMsg(''); try { const result = await request>('SHOP_H5', '/shop/redeem/phone/confirm', { method: 'POST', @@ -118,7 +116,7 @@ export default function PhoneRedeemPage() { state: { result, storeName, user: prepared.user }, }); } catch (e) { - setMsg(e instanceof Error ? e.message : '核销失败'); + toastError(e instanceof Error ? e.message : '核销失败'); } finally { setLoading(false); } @@ -225,8 +223,6 @@ export default function PhoneRedeemPage() { > {loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`} - - {msg &&

{msg}

} diff --git a/apps/h5-shop/src/pages/RecordsPage.tsx b/apps/h5-shop/src/pages/RecordsPage.tsx index 9fda27c..f041f32 100644 --- a/apps/h5-shop/src/pages/RecordsPage.tsx +++ b/apps/h5-shop/src/pages/RecordsPage.tsx @@ -2,15 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types'; import { request } from '../lib/api'; +import { formatMoney, toMoneyNumber } from '../lib/money'; import { useStorePageView } from '../lib/usePageView'; type RangeKey = 'today' | '7d' | '30d'; type StatusFilter = 'all' | 'pending' | 'paid'; -function formatMoney(n: number) { - return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -} - function inRange(dateStr: string, range: RangeKey) { const d = new Date(dateStr); const now = new Date(); @@ -85,8 +82,8 @@ export default function RecordsPage() { }, [records, range, statusFilter]); const summary = useMemo(() => { - const totalAmount = filtered.reduce((s, r) => s + Number(r.amount || 0), 0); - const totalSettle = filtered.reduce((s, r) => s + Number(r.settleAmount || 0), 0); + const totalAmount = filtered.reduce((s, r) => s + toMoneyNumber(r.amount), 0); + const totalSettle = filtered.reduce((s, r) => s + toMoneyNumber(r.settleAmount), 0); const rate = totalAmount > 0 ? Math.round((totalSettle / totalAmount) * 100) : 60; return { totalAmount, totalSettle, rate }; }, [filtered]); @@ -181,8 +178,8 @@ export default function RecordsPage() { ) : (
{filtered.map((r) => { - const amount = Number(r.amount || 0); - const settle = Number(r.settleAmount || 0); + const amount = toMoneyNumber(r.amount); + const settle = toMoneyNumber(r.settleAmount); const payout = r.payout as { paidAt?: string | null; status?: string } | undefined; const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt); const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null); diff --git a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx index f60cc47..cba4d80 100644 --- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx +++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel'; import { request } from '../lib/api'; import { reportRedeemFailure } from '../lib/redeem-failure'; +import { toastError } from '../lib/toast'; import { useStorePageView } from '../lib/usePageView'; function formatAmount(n: number) { @@ -22,7 +23,6 @@ export default function RedeemConfirmPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [token, setToken] = useState(''); - const [msg, setMsg] = useState(''); const [loading, setLoading] = useState(false); const [storeName, setStoreName] = useState(''); const [preview, setPreview] = useState(null); @@ -61,10 +61,9 @@ export default function RedeemConfirmPage() { body: JSON.stringify({ token }), }); setPreview(p); - setMsg(''); } catch (e) { setPreview(null); - setMsg(e instanceof Error ? e.message : '无法预览核销码'); + toastError(e instanceof Error ? e.message : '无法预览核销码'); const report = await reportRedeemFailure(token, 'preview', e); if (report?.thresholdReached) { setFailCount(report.failCount); @@ -82,11 +81,10 @@ export default function RedeemConfirmPage() { async function doConfirm() { if (!token.trim()) { - setMsg('请先扫码获取核销码'); + toastError('请先扫码获取核销码'); return; } setLoading(true); - setMsg(''); try { const result = await request>('SHOP_H5', '/shop/redeem/confirm', { method: 'POST', @@ -95,7 +93,7 @@ export default function RedeemConfirmPage() { sessionStorage.setItem('lastRedeemResult', JSON.stringify(result)); navigate('/redeem/success', { state: { result, storeName, user: preview?.user } }); } catch (e) { - setMsg(e instanceof Error ? e.message : '核销失败'); + toastError(e instanceof Error ? e.message : '核销失败'); const report = await reportRedeemFailure(token, 'confirm', e); if (report?.thresholdReached) { setFailCount(report.failCount); @@ -126,13 +124,12 @@ export default function RedeemConfirmPage() { }); setStoreClosed(false); setShowOpenModal(false); - setMsg(''); // 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销 await loadPreview(); await doConfirm(); } catch (e) { setShowOpenModal(false); - setMsg(e instanceof Error ? e.message : '开启营业失败'); + toastError(e instanceof Error ? e.message : '开启营业失败'); } finally { setOpening(false); } @@ -217,8 +214,6 @@ export default function RedeemConfirmPage() { )}
- {msg &&

{msg}

} - {!showWeakNet && ( <>