11 Commits

Author SHA1 Message Date
jacy b5d4f8b27c Merge pull request 'v3.5.3版本更新2' (#30) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/30
2026-08-20 20:23:03 +08:00
jacy 26334ed072 v3.5.3版本更新2
CI / verify (pull_request) Waiting to run
2026-08-20 20:09:05 +08:00
jacy 9a80f0f8d6 Merge pull request 'v3.5.3版本更新1' (#29) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/29
2026-08-20 18:55:43 +08:00
jacy fc2e5b65de v3.5.3版本更新1
CI / verify (pull_request) Waiting to run
2026-08-20 18:54:15 +08:00
jacy e8b585e623 Merge pull request '增加音效逻辑' (#26) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/26
2026-08-20 15:06:07 +08:00
jacy ee493823bf 增加音效逻辑
CI / verify (pull_request) Has been cancelled
2026-08-20 15:04:54 +08:00
jacy 5702266276 Merge pull request 'Dev jacy' (#25) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/25
2026-08-20 13:43:29 +08:00
jacy 8b5bb6a981 大屏显示优化
CI / verify (pull_request) Has been cancelled
2026-08-20 13:41:50 +08:00
jacy 0a36188b35 调整优化大屏 2026-08-20 12:55:11 +08:00
jacy a8a37fbc4a Merge pull request 'v3.5.2 大屏调整' (#22) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/22
2026-08-20 12:00:57 +08:00
jacy 6cf66c722c v3.5.2 大屏调整
CI / verify (pull_request) Has been cancelled
2026-08-20 11:58:15 +08:00
86 changed files with 3603 additions and 1057 deletions
+19 -15
View File
@@ -291,26 +291,21 @@ body:has(.big-screen-page),
z-index: 1;
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
padding: 0 8px;
padding: 0;
overflow: hidden;
}
.big-screen-list-head,
.big-screen-row {
display: grid;
grid-template-columns: 208px 1fr 160px 200px;
grid-template-columns: minmax(180px, 18%) minmax(0, 1fr) minmax(140px, 16%) minmax(160px, 18%);
align-items: center;
column-gap: 16px;
}
.big-screen-list-head {
flex: 0 0 auto;
padding: 0 28px 10px 40px;
font-size: 15px;
color: rgba(170, 200, 230, 0.55);
letter-spacing: 2px;
width: 100%;
box-sizing: border-box;
}
.big-screen-list-body {
@@ -457,10 +452,20 @@ body:has(.big-screen-page),
}
.big-screen-empty {
position: absolute;
left: 0;
right: 0;
/* 全屏高度黄金分割点(≈0.382),略偏上 */
top: 38.2%;
transform: translateY(-50%);
z-index: 2;
text-align: center;
color: rgba(255, 255, 255, 0.45);
padding: 48px 20px;
font-size: 18px;
color: rgba(145, 190, 230, 0.42);
padding: 0 40px;
font-size: 60px;
letter-spacing: 6px;
font-weight: 400;
pointer-events: none;
}
.big-screen-fx {
@@ -713,9 +718,8 @@ body:has(.big-screen-page),
.big-screen-clock {
font-size: 56px;
}
.big-screen-list-head,
.big-screen-row {
grid-template-columns: 172px 1fr 120px 160px;
grid-template-columns: minmax(140px, 20%) minmax(0, 1fr) minmax(110px, 16%) minmax(130px, 18%);
font-size: 16px;
}
.big-screen-amount--6d {
@@ -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<string, string> = {
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 (
<div style={{ marginTop: 2 }}>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'insert')
.map((s, idx) =>
s.type === 'delete' ? (
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'delete')
.map((s, idx) =>
s.type === 'insert' ? (
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
</div>
);
}
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 (
<div
style={{
marginBottom: 12,
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
<Space style={{ marginBottom: 8 }} wrap>
{title ? <Typography.Text type="secondary">{title}</Typography.Text> : null}
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
</Space>
<div style={{ marginBottom: 8 }}>
<strong>{pkg.name}</strong>
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
</div>
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
{pkg.dishes || '—'}
</Typography.Paragraph>
{pkg.usableTime ? (
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 4 }}>
{pkg.usableTime}
</Typography.Paragraph>
) : null}
{pkg.otherNotes ? (
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 8 }}>
{pkg.otherNotes}
</Typography.Paragraph>
) : null}
{images.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image key={url} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
{changes && changes.length ? (
<div
style={{
marginTop: 8,
padding: 8,
background: '#fff7e6',
border: '1px solid #ffe7ba',
borderRadius: 6,
}}
>
<Typography.Text strong style={{ fontSize: 12 }}>
</Typography.Text>
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{changes.map((c) => (
<li key={c.label} style={{ marginBottom: 6 }}>
<Typography.Text type="secondary">{c.label}</Typography.Text>
{c.kind === 'text' ? (
<TextDiff oldText={c.old} newText={c.now} />
) : (
<>
<Typography.Text delete type="secondary">
{c.old}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>{c.now}</Typography.Text>
</>
)}
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
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<StorePackageAuditDetailDto | null>(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<StorePackageAuditDetailDto>(`/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 <Typography.Text type="secondary"></Typography.Text>;
}
if (!detail) {
return <Typography.Text type="secondary"></Typography.Text>;
}
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 (
<div>
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }} wrap>
<Space wrap>
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status] ?? detail.status}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
</Typography.Text>
</Space>
{showActions && detail.status === 'PENDING' ? (
<Space>
<Button type="primary" loading={auditing} onClick={() => void audit('APPROVE')}>
</Button>
<Button
danger
loading={auditing}
onClick={() => {
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null}
</Space>
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
<Typography.Text type="secondary">
线 {detail.livePackages?.length ?? 0} · {detail.packages?.length ?? 0}
</Typography.Text>
<Space wrap>
<Tag color="green"> {addedCount}</Tag>
<Tag color="red"> {removedCount}</Tag>
<Tag color="orange"> {changedCount}</Tag>
{unchangedCount ? <Tag> {unchangedCount}</Tag> : null}
</Space>
</Space>
<div className="admin-package-audit-cols">
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
线
</Typography.Title>
{(detail.livePackages ?? []).length ? (
(detail.livePackages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`live-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary">线</Typography.Text>
)}
</div>
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{(detail.packages ?? []).length ? (
(detail.packages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`pending-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
changes={changesByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</div>
<Modal
title="驳回套餐变更"
open={rejectOpen}
confirmLoading={auditing}
onCancel={() => setRejectOpen(false)}
onOk={() => {
if (!rejectReason.trim()) {
message.warning('请填写驳回原因');
return;
}
void audit('REJECT', rejectReason.trim()).then(() => setRejectOpen(false));
}}
>
<Input.TextArea
rows={3}
value={rejectReason}
placeholder="驳回原因"
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
/** 供门店详情抽屉 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();
}
+109 -118
View File
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { request } from '../lib/api';
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
export type BigScreenOrder = {
id: string;
@@ -16,102 +19,60 @@ type AmountTier = 1 | 2 | 3;
const POLL_MS = 3000;
const ROW_MS = 2500;
/** 不足此条数时重复填充,保证滚动连贯 */
const MIN_SCROLL_ROWS = 10;
const FX_DURATION_MS = 10_000;
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const TIER_DURATION: Record<AmountTier, number> = { 1: 3000, 2: 4500, 3: 6000 };
const SEED_IDS = new Set(
['seed-1', 'seed-2', 'seed-3', 'seed-4', 'seed-5', 'seed-6', 'seed-7', 'seed-8'],
);
const CELEBRATE_SFX: Record<AmountTier, string> = {
1: sfxTier1,
2: sfxTier2,
3: sfxTier3,
};
function seedPaidAt(h: number, m: number, s: number): string {
const d = new Date();
d.setHours(h, m, s, 0);
return d.toISOString();
/** 浏览器自动播放策略:需用户手势后才能出声 */
let audioUnlocked = false;
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
function unlockCelebrateAudio() {
if (audioUnlocked || !sharedAudio) return;
sharedAudio.muted = true;
sharedAudio.src = CELEBRATE_SFX[1];
void sharedAudio
.play()
.then(() => {
sharedAudio.pause();
sharedAudio.currentTime = 0;
sharedAudio.muted = false;
audioUnlocked = true;
})
.catch(() => {
/* 等待下次手势 */
});
}
/** 发布会滚动列表垫底演示数据(不参与成交动效) */
const BIG_SCREEN_SEED_ORDERS: BigScreenOrder[] = [
{
id: 'seed-1',
orderNo: 'DK-DEMO-001',
payAmount: 1288,
items: '杜康君酿 · 浓香型 × 6瓶',
createdAt: seedPaidAt(14, 28, 36),
paidAt: seedPaidAt(14, 28, 36),
userPhoneMasked: '138****8888',
},
{
id: 'seed-2',
orderNo: 'DK-DEMO-002',
payAmount: 688,
items: '杜康君酿 · 浓香型 × 2瓶',
createdAt: seedPaidAt(14, 22, 18),
paidAt: seedPaidAt(14, 22, 18),
userPhoneMasked: '139****6666',
},
{
id: 'seed-3',
orderNo: 'DK-DEMO-003',
payAmount: 1588,
items: '杜康典藏 · 礼盒装 × 2套',
createdAt: seedPaidAt(14, 15, 42),
paidAt: seedPaidAt(14, 15, 42),
userPhoneMasked: '136****9527',
},
{
id: 'seed-4',
orderNo: 'DK-DEMO-004',
payAmount: 520,
items: '杜康特曲 · 500ml × 4瓶',
createdAt: seedPaidAt(14, 8, 55),
paidAt: seedPaidAt(14, 8, 55),
userPhoneMasked: '135****3210',
},
{
id: 'seed-5',
orderNo: 'DK-DEMO-005',
payAmount: 268,
items: '杜康君酿 · 浓香型 × 1瓶',
createdAt: seedPaidAt(13, 56, 7),
paidAt: seedPaidAt(13, 56, 7),
userPhoneMasked: '137****1888',
},
{
id: 'seed-6',
orderNo: 'DK-DEMO-006',
payAmount: 999,
items: '杜康窖藏 · 珍藏版 × 3瓶',
createdAt: seedPaidAt(13, 48, 33),
paidAt: seedPaidAt(13, 48, 33),
userPhoneMasked: '133****7788',
},
{
id: 'seed-7',
orderNo: 'DK-DEMO-007',
payAmount: 188,
items: '杜康小酒 · 品鉴装 × 2瓶',
createdAt: seedPaidAt(13, 35, 21),
paidAt: seedPaidAt(13, 35, 21),
userPhoneMasked: '158****0099',
},
{
id: 'seed-8',
orderNo: 'DK-DEMO-008',
payAmount: 1200,
items: '杜康君酿 · 浓香型 × 5瓶',
createdAt: seedPaidAt(13, 22, 49),
paidAt: seedPaidAt(13, 22, 49),
userPhoneMasked: '186****5566',
},
];
function mergeDisplayItems(live: BigScreenOrder[]): BigScreenOrder[] {
if (!live.length) return [...BIG_SCREEN_SEED_ORDERS];
return [...live, ...BIG_SCREEN_SEED_ORDERS];
}
function isSeedOrder(order: BigScreenOrder): boolean {
return SEED_IDS.has(order.id);
function playCelebrateSfx(tier: AmountTier): () => void {
if (!sharedAudio) return () => undefined;
try {
sharedAudio.pause();
sharedAudio.currentTime = 0;
sharedAudio.src = CELEBRATE_SFX[tier];
sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65;
sharedAudio.muted = false;
void sharedAudio.play().catch(() => {
/* 未解锁时静默失败 */
});
} catch {
/* ignore */
}
return () => {
try {
sharedAudio.pause();
sharedAudio.currentTime = 0;
} catch {
/* ignore */
}
};
}
function pad(n: number) {
@@ -134,11 +95,20 @@ function formatOrderTime(iso: string): string {
}
}
// function formatAmount(n: number): string {
// return Number(n || 0).toLocaleString('zh-CN', {
// minimumFractionDigits: 0,
// maximumFractionDigits: 0,
// });
// }
function formatAmount(n: number): string {
return Number(n || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
const num = Number(n || 0);
// 先保留两位小数,再分割处理
const fixed = num.toFixed(2);
const [intStr, decStr] = fixed.split('.');
const intFormatted = Number(intStr).toLocaleString('zh-CN');
return decStr === '00' ? intFormatted : `${intFormatted}.${decStr}`;
}
function amountCssClass(payAmount: number, base: string): string {
@@ -264,9 +234,13 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount);
useEffect(() => {
const duration = TIER_DURATION[tier];
const timer = window.setTimeout(() => onDoneRef.current(), duration);
const timer = window.setTimeout(() => onDoneRef.current(), FX_DURATION_MS);
return () => window.clearTimeout(timer);
}, [order.id]);
useEffect(() => {
const stop = playCelebrateSfx(tier);
return stop;
}, [order.id, tier]);
useEffect(() => {
@@ -299,7 +273,6 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
let raf = 0;
let last = performance.now();
const started = performance.now();
const duration = TIER_DURATION[tier];
const gravity = tier === 3 ? 0.18 : 0.14;
const loop = (now: number) => {
const dt = Math.min(32, now - last) / 16.6;
@@ -310,8 +283,12 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rot += p.vr * dt;
p.life -= (tier === 3 ? 0.0032 : 0.005) * dt;
if (p.rain && (p.y > canvas.clientHeight + 30 || p.life <= 0) && now - started < duration - 400) {
p.life -= (tier === 3 ? 0.002 : 0.003) * dt;
if (
p.rain &&
(p.y > canvas.clientHeight + 30 || p.life <= 0) &&
now - started < FX_DURATION_MS - 400
) {
p.x = Math.random() * canvas.clientWidth;
p.y = -16 - Math.random() * 60;
p.vx = (Math.random() - 0.5) * 1.4;
@@ -374,12 +351,9 @@ export default function BigScreenPage() {
const queueRef = useRef<BigScreenOrder[]>([]);
const celebratingRef = useRef(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const dayKeyRef = useRef('');
const [viewportH, setViewportH] = useState(0);
useEffect(() => {
for (const o of BIG_SCREEN_SEED_ORDERS) celebratedIdsRef.current.add(o.id);
}, []);
const playNext = useCallback(() => {
const next = queueRef.current.shift() ?? null;
celebratingRef.current = !!next;
@@ -404,10 +378,22 @@ export default function BigScreenPage() {
);
const fetchData = useCallback(() => {
const now = new Date();
const todayKey = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
if (dayKeyRef.current && dayKeyRef.current !== todayKey) {
seenIdsRef.current = null;
celebratedIdsRef.current = new Set();
queueRef.current = [];
celebratingRef.current = false;
setCelebrate(null);
setPaused(false);
}
dayKeyRef.current = todayKey;
return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
.then((d) => {
const list = d.items ?? [];
setItems(mergeDisplayItems(list));
setItems(list);
setLoadError('');
const seen = seenIdsRef.current;
if (!seen) {
@@ -421,7 +407,6 @@ export default function BigScreenPage() {
})
.catch((e) => {
setLoadError(e instanceof Error ? e.message : '加载失败');
setItems(mergeDisplayItems([]));
});
}, [enqueueNew]);
@@ -431,6 +416,17 @@ export default function BigScreenPage() {
return () => clearInterval(id);
}, [fetchData]);
useEffect(() => {
unlockCelebrateAudio();
const unlock = () => unlockCelebrateAudio();
window.addEventListener('pointerdown', unlock, { once: true });
window.addEventListener('keydown', unlock, { once: true });
return () => {
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', unlock);
};
}, []);
useEffect(() => {
const html = document.documentElement;
const prevHtml = html.style.overflow;
@@ -457,7 +453,7 @@ export default function BigScreenPage() {
if (!items.length) return [];
const rowH = 80;
const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH));
const minCount = visible + 1;
const minCount = Math.max(MIN_SCROLL_ROWS, visible + 1);
const unit: BigScreenOrder[] = [];
while (unit.length < minCount) unit.push(...items);
return unit;
@@ -466,8 +462,7 @@ export default function BigScreenPage() {
const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]);
const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS;
const rolling = items.length > 0 && unitItems.length > 0;
const latestLive = items.find((o) => !isSeedOrder(o));
const latestId = latestLive?.id;
const latestId = items[0]?.id;
return (
<div className="big-screen-page">
@@ -496,16 +491,8 @@ export default function BigScreenPage() {
<LiveClock />
<div className="big-screen-list">
<div className="big-screen-list-head">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div className="big-screen-list-body" ref={viewportRef}>
{items.length === 0 ? (
<div className="big-screen-empty">{loadError || '暂无订单'}</div>
) : (
{items.length === 0 ? null : (
<div
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
@@ -522,6 +509,10 @@ export default function BigScreenPage() {
</div>
</div>
{items.length === 0 ? (
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
) : null}
{celebrate ? (
<CelebrateFx
key={celebrate.id}
+34 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Descriptions,
@@ -63,12 +64,22 @@ type CreateFormValues = {
};
export default function InvoicesPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('status')?.trim() || '';
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
const [filterForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
if (initialStatus) init.status = initialStatus;
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/invoices',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
return qs;
},
[filters],
@@ -81,12 +92,29 @@ export default function InvoicesPage() {
const [createForm] = Form.useForm<CreateFormValues>();
const invoiceKind = Form.useWatch('invoiceKind', createForm);
const titleType = Form.useWatch('titleType', createForm);
const deepLinkOpenedRef = useRef(false);
useEffect(() => {
filterForm.setFieldsValue({
status: filters.status || undefined,
invoiceNo: filters.invoiceNo || undefined,
});
}, [filterForm, filters.invoiceNo, filters.status]);
async function openDetail(id: string) {
setDetail(await request(`/admin/invoices/${id}`));
setDrawerOpen(true);
}
useEffect(() => {
if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.invoiceNo) === initialInvoiceNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialInvoiceNo, loading]);
async function issueWithFile(file: File) {
if (!detail) return false;
setUploading(true);
@@ -213,6 +241,7 @@ export default function InvoicesPage() {
</Button>
</div>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
@@ -231,6 +260,9 @@ export default function InvoicesPage() {
]}
/>
</Form.Item>
<Form.Item name="invoiceNo" label="申请单号">
<Input allowClear placeholder="发票申请单号" />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
+20 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
@@ -162,6 +162,8 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
}
export default function OrdersPage() {
const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const [form] = Form.useForm();
const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm();
@@ -172,6 +174,7 @@ export default function OrdersPage() {
const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<OrderDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const deepLinkOpenedRef = useRef(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
@@ -201,6 +204,21 @@ export default function OrdersPage() {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
if (initialOrderNo) {
form.setFieldsValue({ orderNo: initialOrderNo });
}
}, [form, initialOrderNo]);
useEffect(() => {
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.orderNo) === initialOrderNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialOrderNo, loading]);
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
+23 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
@@ -26,8 +27,14 @@ function maskPhone(phone: string | null | undefined) {
}
export default function RedeemRecordsPage() {
const [searchParams] = useSearchParams();
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records',
() => {
@@ -43,6 +50,11 @@ export default function RedeemRecordsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const deepLinkOpenedRef = useRef(false);
useEffect(() => {
if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
}, [form, initialRedeemNo]);
async function openDetail(id: string) {
setDetailLoading(true);
@@ -54,6 +66,15 @@ export default function RedeemRecordsPage() {
}
}
useEffect(() => {
if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.redeemNo) === initialRedeemNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialRedeemNo, loading]);
const columns: ColumnsType<Row> = [
{
title: '核销号',
+4 -1
View File
@@ -64,10 +64,13 @@ export default function StoreBillsPage() {
const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const initialStoreId = searchParams.get('storeId') || '';
const initialStatus =
searchParams.get('status')?.trim() ||
(initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '');
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
status: initialStatus,
storeId: initialStoreId,
});
const [stores, setStores] = useState<StoreOption[]>([]);
@@ -20,20 +20,18 @@ import type {
StoreInfoChangeFieldDiff,
StoreInfoChangeRequestDto,
StoreInfoChangeStatus,
StorePackageAuditDetailDto,
StorePackageAuditSummaryDto,
StorePackageChangeRequestDto,
StorePackageChangeStatus,
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types';
import {
STORE_INFO_CHANGE_STATUS_LABELS,
normalizeStorePackageImageUrls,
STORE_INFO_CHANGEABLE_FIELD_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<StorePackageChangeStatus, string> = {
PENDING: '待审核',
@@ -41,293 +39,78 @@ const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
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 (
<div style={{ marginTop: 2 }}>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'insert')
.map((s, idx) =>
s.type === 'delete' ? (
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'delete')
.map((s, idx) =>
s.type === 'insert' ? (
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
</div>
);
}
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 (
<div
style={{
marginBottom: 12,
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
<Space style={{ marginBottom: 8 }} wrap>
{title ? (
<Typography.Text type="secondary">{title}</Typography.Text>
) : null}
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
</Space>
<div style={{ marginBottom: 8 }}>
<strong>{pkg.name}</strong>
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
</div>
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
{pkg.dishes || '—'}
</Typography.Paragraph>
{pkg.usableTime ? (
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 4 }}
>
{pkg.usableTime}
</Typography.Paragraph>
) : null}
{pkg.otherNotes ? (
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 8 }}
>
{pkg.otherNotes}
</Typography.Paragraph>
) : null}
{images.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image
key={url}
src={url}
width={72}
height={72}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
{changes && changes.length ? (
<div
style={{
marginTop: 8,
padding: 8,
background: '#fff7e6',
border: '1px solid #ffe7ba',
borderRadius: 6,
}}
>
<Typography.Text strong style={{ fontSize: 12 }}>
</Typography.Text>
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{changes.map((c) => (
<li key={c.label} style={{ marginBottom: 6 }}>
<Typography.Text type="secondary">{c.label}</Typography.Text>
{c.kind === 'text' ? (
<TextDiff oldText={c.old} newText={c.now} />
) : (
<>
<Typography.Text delete type="secondary">
{c.old}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>{c.now}</Typography.Text>
</>
)}
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
name: '门店名称',
contactPhone: '联系电话',
address: '详细地址',
intro: '门店简介',
benefitUsageRule: '权益券使用规则',
latitude: '纬度',
longitude: '经度',
openTime: '营业开始',
closeTime: '营业结束',
openTime2: '第二段开始',
closeTime2: '第二段结束',
avgPrice: '人均费用',
};
function fmtFieldValue(field: string, v: unknown): string {
if (v == null || String(v).trim() === '') return '(空)';
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 (
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
</Typography.Text>
{liveUrls.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{liveUrls.map((url) => (
<Image key={`live-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
</Typography.Text>
{proposedUrls.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{proposedUrls.map((url) => (
<Image key={`new-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</div>
);
}
function InfoChangeAuditPanel({
initialRequestId,
}: {
@@ -425,7 +208,7 @@ function InfoChangeAuditPanel({
render: (_, row) =>
row.changedFields?.length
? row.changedFields.map((f) => (
<Tag key={f}>{INFO_CHANGE_FIELD_LABELS[f] ?? f}</Tag>
<Tag key={f}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f}</Tag>
))
: '—',
},
@@ -542,17 +325,21 @@ function InfoChangeAuditPanel({
{detail.diffs.map((d) => (
<Descriptions.Item
key={d.field}
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
>
<span>
<Typography.Text delete type="secondary">
{fmtFieldValue(d.field, d.live)}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>
{fmtFieldValue(d.field, d.proposed)}
</Typography.Text>
</span>
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
) : (
<span>
<Typography.Text delete type="secondary">
{fmtFieldValue(d.field, d.live)}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>
{fmtFieldValue(d.field, d.proposed)}
</Typography.Text>
</span>
)}
</Descriptions.Item>
))}
</Descriptions>
@@ -599,8 +386,8 @@ export default function StorePackageAuditsPage() {
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -629,30 +416,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<string>(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<StorePackageAuditDetailDto>(`/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 +452,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<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
@@ -702,7 +474,7 @@ export default function StorePackageAuditsPage() {
title: '操作',
render: (_, row) => (
<Space>
<Button type="link" onClick={() => void openDetail(row.id)}>
<Button type="link" onClick={() => openDetail(row.id)}>
</Button>
{row.status === 'PENDING' ? (
@@ -781,90 +553,23 @@ export default function StorePackageAuditsPage() {
/>
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
title={drawerTitle}
width={880}
open={detailOpen}
onClose={() => setDetailOpen(false)}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null
}
destroyOnClose
>
{detailLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : detail ? (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
</Typography.Text>
</Space>
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
<Typography.Text type="secondary">
线 {detail.livePackages?.length ?? 0} · {detail.packages?.length ?? 0}
</Typography.Text>
<Space wrap>
<Tag color="green"> {addedCount}</Tag>
<Tag color="red"> {removedCount}</Tag>
<Tag color="orange"> {changedCount}</Tag>
{unchangedCount ? <Tag> {unchangedCount}</Tag> : null}
</Space>
</Space>
<div className="admin-package-audit-cols">
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
线
</Typography.Title>
{(detail.livePackages ?? []).length ? (
(detail.livePackages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`live-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary">线</Typography.Text>
)}
</div>
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{(detail.packages ?? []).length ? (
(detail.packages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`pending-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
changes={changesByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</div>
</>
{activeRequestId ? (
<StorePackageAuditPanel
requestId={activeRequestId}
onAudited={() => {
setDetailOpen(false);
void reload(page, status);
}}
onDetailLoaded={(d) => {
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
}}
/>
) : null}
</Drawer>
@@ -892,3 +597,4 @@ export default function StorePackageAuditsPage() {
</div>
);
}
+146 -21
View File
@@ -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: '基本信息' },
@@ -242,6 +246,8 @@ export default function StoresPage() {
const [searchParams] = useSearchParams();
const initialCityId = searchParams.get('cityId') ?? '';
const initialPartnerId = searchParams.get('partnerId') ?? '';
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
const initialStoreId = searchParams.get('storeId') ?? '';
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>();
@@ -249,6 +255,7 @@ export default function StoresPage() {
const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId;
if (initialAuditStatus) init.auditStatus = initialAuditStatus;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
@@ -270,7 +277,11 @@ export default function StoresPage() {
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detailTab, setDetailTab] = useState('basic');
const packagesRef = useRef<AdminStorePackagesHandle>(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);
@@ -286,6 +297,7 @@ export default function StoresPage() {
const [optionsLoading, setOptionsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
const deepLinkStoreOpenedRef = useRef(false);
useEffect(() => {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -296,6 +308,12 @@ export default function StoresPage() {
.catch(() => {});
}, []);
useEffect(() => {
if (initialAuditStatus) {
form.setFieldsValue({ auditStatus: initialAuditStatus });
}
}, [form, initialAuditStatus]);
useEffect(() => {
const cityId = searchParams.get('cityId') ?? '';
const partnerId = searchParams.get('partnerId') ?? '';
@@ -348,13 +366,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<Record<string, unknown>>(`/admin/stores/${row.id}`),
request<CategoryNode[]>('/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;
@@ -441,6 +464,19 @@ export default function StoresPage() {
setDrawerOpen(true);
}
useEffect(() => {
if (!initialStoreId || deepLinkStoreOpenedRef.current || loading) return;
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
if (row) {
deepLinkStoreOpenedRef.current = true;
void openStoreDetail(row);
} else if (data && (data.items?.length ?? 0) >= 0) {
// 列表无该店时仍尝试直拉详情
deepLinkStoreOpenedRef.current = true;
void openStoreDetail({ id: initialStoreId } as StoreRow);
}
}, [data, initialStoreId, loading]);
async function saveStoreDetail() {
if (!detail) return;
setSaving(true);
@@ -776,22 +812,13 @@ export default function StoresPage() {
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}></Button>
{row.pendingPackageAuditId ? (
<>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
>
</Button>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
>
</Button>
</>
<Button
type="link"
size="small"
onClick={() => void openStoreDetail(row, { tab: 'packages' })}
>
</Button>
) : null}
{row.pendingInfoChangeId ? (
<>
@@ -903,7 +930,7 @@ export default function StoresPage() {
},
}}
/>
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
<Drawer title="门店详情" width={880} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Space wrap>
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
@@ -940,6 +967,41 @@ export default function StoresPage() {
</Button>
</>
) : null}
{detail.pendingPackageAuditId ? (
<>
<Button
type="primary"
ghost
loading={packageAuditing}
onClick={async () => {
setPackageAuditing(true);
try {
await auditStorePackageRequest(String(detail.pendingPackageAuditId), 'APPROVE');
message.success('套餐已通过');
setDetail({ ...detail, pendingPackageAuditId: null });
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '套餐审核失败');
} finally {
setPackageAuditing(false);
}
}}
>
</Button>
<Button
danger
ghost
loading={packageAuditing}
onClick={() => {
setPackageRejectReason('');
setPackageRejectOpen(true);
}}
>
</Button>
</>
) : null}
<Select value={String(detail.status)} style={{ width: 120 }}
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
@@ -954,6 +1016,8 @@ export default function StoresPage() {
{detail && (
<Form form={editForm} layout="vertical">
<Tabs
activeKey={detailTab}
onChange={setDetailTab}
destroyInactiveTabPane={false}
items={[
{
@@ -1202,15 +1266,76 @@ export default function StoresPage() {
},
{
key: 'packages',
label: '套餐',
label: detail.pendingPackageAuditId ? (
<Badge dot offset={[4, 0]}>
</Badge>
) : (
'套餐'
),
forceRender: true,
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
children: (
<>
{detail.pendingPackageAuditId ? (
<div style={{ marginBottom: 24 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<StorePackageAuditPanel
requestId={String(detail.pendingPackageAuditId)}
showActions
onAudited={() => {
setDetail({ ...detail, pendingPackageAuditId: null });
void reload();
}}
/>
</div>
) : null}
<AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />
</>
),
},
]}
/>
</Form>
)}
</Drawer>
<Modal
title="驳回套餐变更"
open={packageRejectOpen}
confirmLoading={packageAuditing}
onCancel={() => setPackageRejectOpen(false)}
onOk={async () => {
if (!detail?.pendingPackageAuditId) return;
if (!packageRejectReason.trim()) {
message.warning('请填写驳回原因');
return;
}
setPackageAuditing(true);
try {
await auditStorePackageRequest(
String(detail.pendingPackageAuditId),
'REJECT',
packageRejectReason.trim(),
);
message.success('套餐已驳回');
setPackageRejectOpen(false);
setDetail({ ...detail, pendingPackageAuditId: null });
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '套餐驳回失败');
} finally {
setPackageAuditing(false);
}
}}
>
<Input.TextArea
rows={3}
value={packageRejectReason}
placeholder="驳回原因"
onChange={(e) => setPackageRejectReason(e.target.value)}
/>
</Modal>
<Modal
title="新建门店"
open={createOpen}
+75 -12
View File
@@ -249,15 +249,70 @@ export default function SystemSettingsPage() {
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) =>
const SHARE_SUBGROUP_LABELS: Record<string, string> = {
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<string, SystemConfigFieldMeta[]>();
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 = (
<Collapse
defaultActiveKey={[]}
items={orderedKeys.map((sk) => ({
key: sk,
label: SHARE_SUBGROUP_LABELS[sk] || sk,
forceRender: true,
children: (
<div style={{ maxWidth: 720 }}>
{(bySub.get(sk) ?? []).map((f) =>
renderField(
f,
meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
) : undefined,
),
)}
</div>
),
}))}
/>
);
} else {
children = (
<div style={{ maxWidth: 720 }}>
{fields.map((f) =>
renderField(
f,
meta.configuredSecrets,
@@ -266,9 +321,17 @@ export default function SystemSettingsPage() {
) : undefined,
),
)}
</div>
),
}));
</div>
);
}
return {
key: group.key,
label: group.label,
forceRender: true,
children,
};
});
}, [meta, mockSmsEnabled, loading]);
async function onSave() {
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
Avatar,
Button,
@@ -13,6 +13,7 @@ import {
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
message,
@@ -21,8 +22,13 @@ import type { ColumnsType } from 'antd/es/table';
import {
WECOM_PUSH_CONDITION_GROUPS,
WECOM_PUSH_CONDITION_LABELS,
WECOM_TEMPLATE_EVENT_KEYS,
WECOM_TEMPLATE_EVENT_LABELS,
WECOM_TEMPLATE_PLACEHOLDERS,
type WecomMessagePushDto,
type WecomPushCondition,
type WecomPushTemplateDto,
type WecomTemplateEventKey,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
@@ -78,7 +84,7 @@ function WecomPushConditionPicker({
);
}
export default function WecomMessagePushesPage() {
function PushRoutesTab() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
@@ -257,11 +263,9 @@ export default function WecomMessagePushesPage() {
];
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<>
<Typography.Paragraph type="secondary">
Webhook
.env Webhook URL
Webhook / .env Webhook URL
</Typography.Paragraph>
<Form
@@ -332,10 +336,10 @@ export default function WecomMessagePushesPage() {
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:运营告警" />
<Input placeholder="如:业务待办通知群" />
</Form.Item>
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
<OssUpload />
<OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item>
<Form.Item
name="webhookUrl"
@@ -400,6 +404,208 @@ export default function WecomMessagePushesPage() {
</Descriptions>
) : null}
</Modal>
</>
);
}
function TemplatesTab() {
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
const [loading, setLoading] = useState(false);
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [preview, setPreview] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
setTemplates(list);
setEventKey((prev) => {
const current = list.find((t) => t.eventKey === prev) ?? list[0];
if (current) {
form.setFieldsValue({
title: current.title,
body: current.body,
handleLabel: current.handleLabel,
});
return current.eventKey;
}
return prev;
});
} catch (e) {
message.error(e instanceof Error ? e.message : '加载模板失败');
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
void load();
}, [load]);
function selectEvent(key: WecomTemplateEventKey) {
setEventKey(key);
const row = templates.find((t) => t.eventKey === key);
if (row) {
form.setFieldsValue({
title: row.title,
body: row.body,
handleLabel: row.handleLabel,
});
setPreview('');
}
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}`,
{
method: 'PUT',
body: JSON.stringify(values),
},
);
message.success('模板已保存');
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function reset() {
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}/reset`,
{ method: 'POST', body: '{}' },
);
message.success('已恢复默认文案');
form.setFieldsValue({
title: updated.title,
body: updated.body,
handleLabel: updated.handleLabel,
});
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
setPreview('');
} catch (e) {
message.error(e instanceof Error ? e.message : '恢复失败');
} finally {
setSaving(false);
}
}
async function testSend() {
setTesting(true);
try {
const res = await request<{ ok: boolean; message: string; preview: string }>(
`/admin/wecom-push-templates/${eventKey}/test`,
{ method: 'POST', body: '{}' },
);
setPreview(res.preview || '');
message.success(res.message || '已发送');
} catch (e) {
message.error(e instanceof Error ? e.message : '测试失败');
} finally {
setTesting(false);
}
}
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
return (
<>
<Typography.Paragraph type="secondary">
使 {'{{orderNo}}'} {'{{handleUrl}}'}
Webhook
</Typography.Paragraph>
<Space align="start" style={{ width: '100%' }} size={24} wrap>
<div style={{ minWidth: 200 }}>
<Typography.Text strong></Typography.Text>
<div style={{ marginTop: 8 }}>
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
<div key={k} style={{ marginBottom: 4 }}>
<Button
type={k === eventKey ? 'primary' : 'text'}
size="small"
onClick={() => selectEvent(k)}
block
style={{ textAlign: 'left' }}
>
{WECOM_TEMPLATE_EVENT_LABELS[k]}
</Button>
</div>
))}
</div>
</div>
<div style={{ flex: 1, minWidth: 360 }}>
<Form form={form} layout="vertical" disabled={loading}>
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="body"
label="正文(企微 markdown"
rules={[{ required: true }]}
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
>
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
<Input placeholder="去处理" />
</Form.Item>
<Space wrap>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
<Button loading={saving}></Button>
</Popconfirm>
<Button loading={testing} onClick={() => void testSend()}>
</Button>
</Space>
</Form>
{preview ? (
<div style={{ marginTop: 16 }}>
<Typography.Text strong></Typography.Text>
<pre
style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
whiteSpace: 'pre-wrap',
borderRadius: 8,
}}
>
{preview}
</pre>
</div>
) : null}
</div>
</Space>
</>
);
}
export default function WecomMessagePushesPage() {
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<Tabs
items={[
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
]}
/>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.mp3' {
const src: string;
export default src;
}
@@ -17,6 +17,11 @@ type Props = {
accept?: string;
/** 计量单位文案,如「张」「个」 */
unit?: string;
/**
* stack:缩略图 + 下方独立上传按钮(合同等)
* grid:九宫格,末尾「+」格上传,无独立大按钮(环境图)
*/
variant?: 'stack' | 'grid';
};
function isCancelError(msg: string): boolean {
@@ -42,6 +47,7 @@ export default function MultiOssUploadField({
label,
accept = 'image/*',
unit = '张',
variant = 'stack',
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const pickingRef = useRef(false);
@@ -53,6 +59,7 @@ export default function MultiOssUploadField({
const onChangeRef = useRef(onChange);
const remaining = Math.max(0, maxCount - urls.length);
const inWechat = isWechatEnv();
const isGrid = variant === 'grid';
useEffect(() => {
urlsRef.current = urls;
@@ -126,11 +133,78 @@ export default function MultiOssUploadField({
}
}
function openPicker() {
if (inWechat) void pickWechat();
else inputRef.current?.click();
}
function removeAt(index: number) {
if (disabled) return;
onChange?.(urls.filter((_, i) => i !== index));
}
const thumb = (url: string, index: number) => (
<div key={`${url}-${index}`} className={isGrid ? 'partner-upload-grid-thumb' : undefined} style={isGrid ? undefined : { position: 'relative', width: 88, height: 88 }}>
{isPdf(url) ? (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
width: isGrid ? '100%' : 88,
height: isGrid ? '100%' : 88,
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
borderRadius: 8,
border: '1px solid rgba(0,0,0,0.08)',
background: '#f7f7f7',
fontSize: 12,
}}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
description
</span>
<span className="text-muted">PDF</span>
</a>
) : (
<img
src={url}
alt=""
style={{
width: isGrid ? '100%' : 88,
height: isGrid ? '100%' : 88,
objectFit: 'cover',
borderRadius: isGrid ? 12 : 8,
display: 'block',
}}
/>
)}
{!disabled ? (
<button
type="button"
className="partner-packages-remove"
style={{
position: 'absolute',
top: 2,
right: 2,
margin: 0,
padding: '2px 6px',
fontSize: 12,
background: 'rgba(0,0,0,0.55)',
color: '#fff',
borderRadius: 4,
}}
onClick={() => removeAt(index)}
>
</button>
) : null}
</div>
);
return (
<div className="partner-oss-upload">
<input
@@ -146,86 +220,49 @@ export default function MultiOssUploadField({
}}
/>
{urls.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{urls.map((url, index) => (
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
{isPdf(url) ? (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
width: 88,
height: 88,
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
borderRadius: 8,
border: '1px solid rgba(0,0,0,0.08)',
background: '#f7f7f7',
fontSize: 12,
}}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
description
</span>
<span className="text-muted">PDF</span>
</a>
) : (
<img
src={url}
alt=""
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
/>
)}
{!disabled ? (
<button
type="button"
className="partner-packages-remove"
style={{
position: 'absolute',
top: 2,
right: 2,
margin: 0,
padding: '2px 6px',
fontSize: 12,
background: 'rgba(0,0,0,0.55)',
color: '#fff',
borderRadius: 4,
}}
onClick={() => removeAt(index)}
>
</button>
) : null}
</div>
))}
{isGrid ? (
<div className="partner-upload-grid">
{urls.map((url, index) => thumb(url, index))}
{remaining > 0 && !disabled ? (
<button
type="button"
className="partner-upload-dashed partner-upload-dashed--compact"
disabled={uploading}
onClick={openPicker}
aria-label={uploading ? '上传中' : `添加${unit}${urls.length}/${maxCount}`}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
{uploading ? 'hourglass_top' : 'add'}
</span>
</button>
) : null}
</div>
) : null}
<button
type="button"
className="partner-upload-dashed partner-upload-dashed--compact"
disabled={disabled || uploading || remaining <= 0}
onClick={() => {
if (inWechat) void pickWechat();
else inputRef.current?.click();
}}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
{uploading ? 'hourglass_top' : 'add_a_photo'}
</span>
<span className="text-primary" style={{ fontWeight: 500 }}>
{uploading
? '上传中…'
: remaining <= 0
? `已达上限 ${maxCount}${unit}`
: label ?? `批量上传(${urls.length}/${maxCount}`}
</span>
</button>
) : (
<>
{urls.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{urls.map((url, index) => thumb(url, index))}
</div>
) : null}
<button
type="button"
className="partner-upload-dashed partner-upload-dashed--compact"
disabled={disabled || uploading || remaining <= 0}
onClick={openPicker}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
{uploading ? 'hourglass_top' : 'add_a_photo'}
</span>
<span className="text-primary" style={{ fontWeight: 500 }}>
{uploading
? '上传中…'
: remaining <= 0
? `已达上限 ${maxCount}${unit}`
: label ?? `批量上传(${urls.length}/${maxCount}`}
</span>
</button>
</>
)}
{error ? (
<p className="partner-form-error" role="alert">
{error}
@@ -1007,9 +1007,9 @@ export default function StoreCreatePage() {
<MultiOssUploadField
bizType="STORE_ENV"
maxCount={20}
variant="grid"
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20`}
/>
</section>
+125 -75
View File
@@ -56,7 +56,6 @@ export default function StoreDetailPage() {
const [status, setStatus] = useState<StoreStatusValue>('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<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
method: 'PUT',
body: JSON.stringify({
coverUrl: nextCover,
envPhotoUrls: nextEnv,
}),
});
applyStore(data);
}
if (wantBasic) {
const data = await request<Record<string, unknown>>('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<string, unknown> = {};
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<Record<string, unknown>>('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 (
<div className="partner-detail-page partner-home--flush-top">
@@ -445,27 +509,13 @@ export default function StoreDetailPage() {
</span>
</div>
{canMutate && !readOnly ? (
<>
<MultiOssUploadField
bizType="STORE_ENV"
maxCount={20}
value={uniqueEnvUrls(envPhotoUrls)}
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20`}
/>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 16 }}
disabled={mediaSaving}
onClick={() => void saveMedia()}
>
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
upload
</span>
{mediaSaving ? '上传中…' : '重新上传照片'}
</button>
</>
<MultiOssUploadField
bizType="STORE_ENV"
maxCount={20}
variant="grid"
value={uniqueEnvUrls(envPhotoUrls)}
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
/>
) : envPhotos.length > 0 ? (
<div className="partner-photo-grid">
{envPhotos.map((url, index) => (
@@ -495,7 +545,7 @@ export default function StoreDetailPage() {
<footer className="partner-save-footer">
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<button type="button" className="partner-save-submit" onClick={() => void saveChanges()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">send</span>
{saving ? '提交中…' : '提交变更'}
</button>
+9
View File
@@ -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 {
@@ -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<RedeemPendingSubmitResult | null>(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<RedeemPendingSubmitResult>('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)
</div>
</>
)}
{msg && <p className="shop-redeem-error" style={{ marginTop: 12 }}>{msg}</p>}
</section>
);
}
@@ -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<ShopToastContextValue | null>(null);
export function ShopToastProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState('');
const [variant, setVariant] = useState<ShopToastVariant>('error');
const timerRef = useRef<number | null>(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 (
<ShopToastContext.Provider value={{ showToast }}>
{children}
{toast ? (
<div
className={`shop-float-toast${variant === 'error' ? ' shop-float-toast--error' : ''}`}
role="alert"
aria-live="assertive"
>
{toast}
</div>
) : null}
</ShopToastContext.Provider>
);
}
export function useShopToast(): ShopToastContextValue {
const ctx = useContext(ShopToastContext);
if (!ctx) throw new Error('useShopToast 必须在 ShopToastProvider 内使用');
return ctx;
}
+27
View File
@@ -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,
});
}
+23
View File
@@ -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');
}
+4 -1
View File
@@ -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(
<React.StrictMode>
<BrowserRouter basename={getRouterBasename()}>
<StoreSessionProvider>
<App />
<ShopToastProvider>
<App />
</ShopToastProvider>
</StoreSessionProvider>
</BrowserRouter>
</React.StrictMode>,
+3 -10
View File
@@ -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() {
<span style={{ fontSize: 18 }}>¥</span>
{formatMoney(Number(dash?.todayAmount || 0))}
{formatMoney(toMoneyNumber(dash?.todayAmount))}
</p>
@@ -596,7 +589,7 @@ export default function HomePage() {
</div>
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
<p className="shop-home-record-amount">¥{formatMoney(toMoneyNumber(r.amount))}</p>
</div>
+9 -13
View File
@@ -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<RedeemPhoneDirectPrepareResult | null>(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<RedeemPhoneDirectPrepareResult>('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<Record<string, unknown>>('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)}`}
</button>
{msg && <p className="shop-redeem-error">{msg}</p>}
</div>
</section>
</main>
+5 -8
View File
@@ -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() {
) : (
<div className="shop-records-list">
{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);
+5 -10
View File
@@ -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<Preview | null>(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<Record<string, unknown>>('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() {
)}
</div>
{msg && <p className="shop-redeem-error">{msg}</p>}
{!showWeakNet && (
<>
<button
+3 -6
View File
@@ -1,10 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
import { formatMoney, toMoneyNumber } from '../lib/money';
export default function RedeemSuccessPage() {
const navigate = useNavigate();
@@ -24,7 +21,7 @@ export default function RedeemSuccessPage() {
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
const user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user;
const userLabel = user?.nickname || user?.phone || '—';
const amount = Number(result?.amount ?? 0);
const amount = toMoneyNumber(result?.amount);
const redeemNo = String(result?.redeemNo || '—');
const createdAt = result?.createdAt
? new Date(String(result.createdAt)).toLocaleString('zh-CN')
@@ -52,7 +49,7 @@ export default function RedeemSuccessPage() {
<span className="material-symbols-outlined">check_circle</span>
</div>
<h2 className="shop-success-title"></h2>
<p className="shop-success-amount">¥ {formatAmount(amount)}</p>
<p className="shop-success-amount">¥ {formatMoney(amount)}</p>
<p className="shop-success-sub"></p>
<p className="shop-success-note"></p>
</section>
+4 -7
View File
@@ -8,15 +8,12 @@ import {
} from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api';
import { formatMoney, toMoneyNumber } from '../lib/money';
import { useStorePageView } from '../lib/usePageView';
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
type StatusFilter = 'all' | StoreWithdrawStatus;
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function WithdrawPage() {
useStorePageView('store_withdraw_view');
const navigate = useNavigate();
@@ -113,13 +110,13 @@ export default function WithdrawPage() {
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-records-summary-value">
¥ {formatMoney(summary?.availableAmount ?? 0)}
¥ {formatMoney(toMoneyNumber(summary?.availableAmount))}
</p>
</div>
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-records-summary-value">
¥ {formatMoney(summary?.remainingDailyLimit ?? 0)}
¥ {formatMoney(toMoneyNumber(summary?.remainingDailyLimit))}
</p>
</div>
</div>
@@ -209,7 +206,7 @@ export default function WithdrawPage() {
<div className="shop-record-amounts">
<div>
<p className="shop-record-amount-label"></p>
<p className="shop-record-amount-value red">¥{formatMoney(Number(r.amount))}</p>
<p className="shop-record-amount-value red">¥{formatMoney(toMoneyNumber(r.amount))}</p>
</div>
<div>
<p className="shop-record-amount-label"></p>
+23
View File
@@ -3190,3 +3190,26 @@ header:has(> .app-page-title:only-child),
box-shadow: none !important;
}
.shop-float-toast {
position: fixed;
top: 28%;
left: 50%;
transform: translateX(-50%);
z-index: 10020;
max-width: min(320px, calc(100vw - 40px));
padding: 12px 20px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.78);
color: #fff;
font-size: 15px;
line-height: 1.5;
text-align: center;
pointer-events: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.shop-float-toast--error {
background: rgba(166, 29, 36, 0.92);
}
+68
View File
@@ -54,3 +54,71 @@ body::-webkit-scrollbar,
padding-bottom: 0 !important;
bottom: 0 !important;
}
.benefit-figure {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 4px;
line-height: 1;
vertical-align: middle;
}
.benefit-figure-prefix,
.benefit-figure-value {
line-height: 1;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
.benefit-figure-icon {
display: block;
flex-shrink: 0;
}
.benefit-figure--sm {
gap: 2px;
}
.benefit-figure--sm .benefit-figure-icon {
width: 12px;
height: 12px;
}
.benefit-figure--md .benefit-figure-icon {
width: 16px;
height: 16px;
}
.benefit-figure--lg .benefit-figure-icon {
width: 20px;
height: 20px;
}
.benefit-figure--xl {
gap: 6px;
}
.benefit-figure--xl .benefit-figure-icon {
width: 28px;
height: 28px;
}
.mu-float-toast {
position: fixed;
top: 28%;
left: 10%;
right: 10%;
z-index: 10020;
padding: 12px 20px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.78);
color: #fff;
font-size: 15px;
line-height: 1.5;
text-align: center;
pointer-events: none;
box-sizing: border-box;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

@@ -0,0 +1,27 @@
import { Image, Text, View } from '@tarojs/components';
import iconStoreBenefit from '../assets/icons/store-benefit.png';
type BenefitFigureSize = 'sm' | 'md' | 'lg' | 'xl';
type BenefitFigureProps = {
value: string;
size?: BenefitFigureSize;
prefix?: string;
className?: string;
};
/** 好客权益金额:门店核销图标 + 数字,替代人民币符号 */
export default function BenefitFigure({
value,
size = 'md',
prefix = '',
className = '',
}: BenefitFigureProps) {
return (
<View className={`benefit-figure benefit-figure--${size} ${className}`.trim()}>
{prefix ? <Text className="benefit-figure-prefix">{prefix}</Text> : null}
<Image className="benefit-figure-icon" src={iconStoreBenefit} mode="aspectFit" />
<Text className="benefit-figure-value">{value}</Text>
</View>
);
}
@@ -1,4 +1,5 @@
import { Text } from '@tarojs/components';
import { Text, View } from '@tarojs/components';
import BenefitFigure from './BenefitFigure';
type CouponBadgeProps = {
amount: number | string;
@@ -10,8 +11,9 @@ export default function CouponBadge({ amount, label = '好客权益' }: CouponBa
const n = Number(amount);
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
return (
<Text className="coupon-badge">
¥{display} {label}
</Text>
<View className="coupon-badge">
<Text></Text>
<BenefitFigure value={`${display} ${label}`} size="sm" />
</View>
);
}
@@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from 'react';
import { CoverView, View } from '@tarojs/components';
import { registerFloatingToastListener } from '../lib/floating-toast';
const TOAST_MS = 2600;
export default function FloatingToastHost() {
const [text, setText] = useState('');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return registerFloatingToastListener((message) => {
if (timerRef.current) clearTimeout(timerRef.current);
setText(message);
timerRef.current = setTimeout(() => {
setText('');
timerRef.current = null;
}, TOAST_MS);
});
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
if (!text) return null;
const Box = process.env.TARO_ENV === 'weapp' ? CoverView : View;
return <Box className="mu-float-toast">{text}</Box>;
}
@@ -1,5 +1,6 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { View } from '@tarojs/components';
import FloatingToastHost from './FloatingToastHost';
import { pageShellCssVars, useNavBarMetrics } from '../lib/nav-bar';
type PageShellVariant = 'tab' | 'scroll' | 'sub' | 'plain';
@@ -35,6 +36,7 @@ export default function PageShell({
return (
<View className={classes} style={pageShellCssVars(metrics)}>
{children as ReactNode}
<FloatingToastHost />
</View>
);
}
@@ -14,6 +14,8 @@ type ProductCarouselProps = {
* 依赖 swiper 原生 auto-height:海报有多高,轮播就有多高,无裁切。
*/
imageFit?: 'cover' | 'contain' | 'adaptive';
/** 预览相册(默认等于 images);门店详情可传入封面+环境图合并列表 */
previewUrls?: string[];
};
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
@@ -23,6 +25,7 @@ export default function ProductCarousel({
variant = 'detail',
previewable = false,
imageFit = 'cover',
previewUrls,
}: ProductCarouselProps) {
const slides = images.length > 0 ? images : [''];
const [activeIndex, setActiveIndex] = useState(0);
@@ -33,10 +36,10 @@ export default function ProductCarousel({
const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}${isAdaptive ? ` ${prefix}-wrap--adaptive` : ''}`;
function previewAt(index: number) {
const urls = slides.filter(Boolean);
if (!urls.length) return;
const current = slides[index] || urls[0];
Taro.previewImage({ current, urls }).catch(() => undefined);
const album = (previewUrls?.length ? previewUrls : slides).filter(Boolean);
if (!album.length) return;
const current = slides[index] || album[0];
Taro.previewImage({ current, urls: album }).catch(() => undefined);
}
return (
+8 -1
View File
@@ -4,6 +4,7 @@ import { forceReloadAfterAccountMerge } from './auth-nav';
import { resetStoresSessionBootstrap } from './stores-session';
import { resetHomeCatalogBootstrap } from './home-catalog-session';
import { reportClientValidationError } from './client-error';
import { showFloatingToast } from './floating-toast';
function resolveApiBase(): string {
const origin =
@@ -126,7 +127,13 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
}
export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none') {
Taro.showToast({ title, icon, duration: 1800 });
const text = title.trim();
if (!text) return;
if (icon !== 'success' && showFloatingToast(text)) {
void Taro.hideToast();
return;
}
Taro.showToast({ title: text, icon, duration: 1800 });
}
export type SessionPayload = {
+18
View File
@@ -0,0 +1,18 @@
type FloatingToastListener = (message: string) => void;
const listeners = new Set<FloatingToastListener>();
export function registerFloatingToastListener(fn: FloatingToastListener) {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
/** 已有页面宿主时返回 true,否则调用方应回退到原生 toast */
export function showFloatingToast(message: string): boolean {
const text = message.trim();
if (!text || listeners.size === 0) return false;
listeners.forEach((fn) => fn(text));
return true;
}
+21 -2
View File
@@ -1,7 +1,26 @@
/** 金额展示(不依赖 Intl / toLocaleString,兼容微信小程序) */
export function toMoneyNumber(amount: unknown): number {
if (typeof amount === 'number') return Number.isFinite(amount) ? amount : 0;
if (typeof amount === 'string' && amount.trim()) {
const n = Number(amount);
return Number.isFinite(n) ? n : 0;
}
if (amount && typeof amount === 'object') {
const o = amount as { toNumber?: () => number; toString?: () => string };
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(amount: number | string): string {
const n = typeof amount === 'number' ? amount : Number(amount);
if (!Number.isFinite(n)) return '0.00';
const n = toMoneyNumber(amount);
const fixed = n.toFixed(2);
const [intPart, dec] = fixed.split('.');
const withComma = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+28 -16
View File
@@ -82,8 +82,10 @@ function sceneConfig(scene?: ShareScene): MiniShareSceneConfig {
}
/**
* 组装页面分享:场景配置优先;空字段用 dynamic → 默认分享
* orderDetail 标题支持 {productName}。
* 组装页面分享。
* - productDetail / storeDetailtitle 与 imageUrl 固定用业务字段(不被 HQ 场景配置覆盖)
* - 其它场景:场景配置优先;空字段用 dynamic → 默认分享
* - orderDetail 标题支持 {productName}
*/
export function buildSceneSharePayload(
scene: ShareScene,
@@ -98,23 +100,33 @@ export function buildSceneSharePayload(
): PageSharePayload {
const def = getShareRuntimeSync().default;
const sc = sceneConfig(scene);
let title = (sc.title || '').trim();
if (title && options?.titleVars) {
const vars = options.titleVars;
const missingRequired = Object.entries(vars).some(
([key, value]) => title.includes(`{${key}}`) && !(value != null && String(value).trim()),
);
title = missingRequired ? '' : applyShareTitleTemplate(title, vars);
}
if (!title) {
const preferEntity = scene === 'productDetail' || scene === 'storeDetail';
let title: string;
if (preferEntity) {
title = (options?.dynamicTitle || '').trim() || def.title;
} else {
title = (sc.title || '').trim();
if (title && options?.titleVars) {
const vars = options.titleVars;
const missingRequired = Object.entries(vars).some(
([key, value]) => title.includes(`{${key}}`) && !(value != null && String(value).trim()),
);
title = missingRequired ? '' : applyShareTitleTemplate(title, vars);
}
if (!title) {
title = (options?.dynamicTitle || '').trim() || def.title;
}
}
const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc;
const imgUrl =
(sc.imageUrl || '').trim() ||
(options?.dynamicImageUrl || '').trim() ||
def.imageUrl ||
getDefaultShareImageUrl();
const imgUrl = preferEntity
? (options?.dynamicImageUrl || '').trim() || def.imageUrl || getDefaultShareImageUrl()
: (sc.imageUrl || '').trim() ||
(options?.dynamicImageUrl || '').trim() ||
def.imageUrl ||
getDefaultShareImageUrl();
return {
title,
desc,
+24 -14
View File
@@ -97,23 +97,33 @@ export function buildSceneSharePayload(
): PageSharePayload {
const def = getShareRuntimeSync().default;
const sc = sceneConfig(scene);
let title = (sc.title || '').trim();
if (title && options?.titleVars) {
const vars = options.titleVars;
const missingRequired = Object.entries(vars).some(
([key, value]) => title.includes(`{${key}}`) && !(value != null && String(value).trim()),
);
title = missingRequired ? '' : applyShareTitleTemplate(title, vars);
}
if (!title) {
const preferEntity = scene === 'productDetail' || scene === 'storeDetail';
let title: string;
if (preferEntity) {
title = (options?.dynamicTitle || '').trim() || def.title;
} else {
title = (sc.title || '').trim();
if (title && options?.titleVars) {
const vars = options.titleVars;
const missingRequired = Object.entries(vars).some(
([key, value]) => title.includes(`{${key}}`) && !(value != null && String(value).trim()),
);
title = missingRequired ? '' : applyShareTitleTemplate(title, vars);
}
if (!title) {
title = (options?.dynamicTitle || '').trim() || def.title;
}
}
const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc;
const imgUrl =
(sc.imageUrl || '').trim() ||
(options?.dynamicImageUrl || '').trim() ||
def.imageUrl ||
getDefaultShareImageUrl();
const imgUrl = preferEntity
? (options?.dynamicImageUrl || '').trim() || def.imageUrl || getDefaultShareImageUrl()
: (sc.imageUrl || '').trim() ||
(options?.dynamicImageUrl || '').trim() ||
def.imageUrl ||
getDefaultShareImageUrl();
return {
title,
desc,
+19 -9
View File
@@ -4,6 +4,7 @@ import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimel
import PageShell from '../../components/PageShell';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import BenefitFigure from '../../components/BenefitFigure';
import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api';
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
@@ -150,10 +151,11 @@ export default function BenefitPage() {
<View>
<Text className="benefit-hero-label"></Text>
<View className="benefit-hero-amount">
<Text className="benefit-hero-symbol">¥</Text>
<Text className="benefit-hero-value">
{summary ? formatMoney(summary.totalBalance) : '--'}
</Text>
<BenefitFigure
value={summary ? formatMoney(summary.totalBalance) : '--'}
size="xl"
className="benefit-hero-value"
/>
</View>
</View>
<View className="benefit-hero-logo">
@@ -193,16 +195,19 @@ export default function BenefitPage() {
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
<View className="benefit-coupon-head">
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
<Text className="benefit-coupon-balance">¥{formatMoney(c.balance)}</Text>
<BenefitFigure value={formatMoney(c.balance)} size="md" className="benefit-coupon-balance" />
</View>
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
<View className="benefit-progress">
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
</View>
<View className="benefit-coupon-footer">
<Text className="benefit-coupon-meta">
¥{formatMoney(c.usedAmount)} / ¥{formatMoney(c.totalAmount)}
</Text>
<View className="benefit-coupon-meta">
<Text></Text>
<BenefitFigure value={formatMoney(c.usedAmount)} size="sm" />
<Text>/ </Text>
<BenefitFigure value={formatMoney(c.totalAmount)} size="sm" />
</View>
<Text
className="benefit-coupon-btn"
onClick={() =>
@@ -226,7 +231,12 @@ export default function BenefitPage() {
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
<View className="benefit-coupon-head">
<Text className="benefit-coupon-name">{r.storeName || '门店核销'}</Text>
<Text className="benefit-coupon-balance">-¥{formatMoney(Number(r.amount))}</Text>
<BenefitFigure
prefix="-"
value={formatMoney(Number(r.amount))}
size="md"
className="benefit-coupon-balance"
/>
</View>
<Text className="benefit-coupon-no">NO. {r.redeemNo}</Text>
<View className="benefit-coupon-footer">
+2 -2
View File
@@ -9,6 +9,7 @@ import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import BenefitFigure from '../../components/BenefitFigure';
import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth';
import {
@@ -447,8 +448,7 @@ export default function MinePage() {
<View>
<Text className="mine-asset-label"></Text>
<View className="mine-asset-amount">
<Text className="mine-asset-currency">¥</Text>
<Text className="mine-asset-value">{formatMoney(benefitBalance)}</Text>
<BenefitFigure value={formatMoney(benefitBalance)} size="lg" className="mine-asset-value" />
</View>
</View>
<Text
@@ -10,6 +10,7 @@ import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
import BenefitFigure from '../../components/BenefitFigure';
type PreviewProduct = {
id: string;
@@ -231,7 +232,11 @@ export default function OrderConfirmPickupPage() {
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value--price">¥{Number(preview.benefitAmount).toFixed(2)}</Text>
<BenefitFigure
value={Number(preview.benefitAmount).toFixed(2)}
size="sm"
className="order-row-value--price"
/>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
@@ -13,6 +13,7 @@ import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
import { getProductMainImage } from '../../lib/product-images';
import BenefitFigure from '../../components/BenefitFigure';
type Address = {
id: string;
@@ -384,7 +385,11 @@ export default function OrderConfirmPage() {
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value--price">¥{preview.benefitAmount.toFixed(2)}</Text>
<BenefitFigure
value={preview.benefitAmount.toFixed(2)}
size="sm"
className="order-row-value--price"
/>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
@@ -13,6 +13,7 @@ import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady';
import BenefitFigure from '../../components/BenefitFigure';
import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api';
@@ -189,10 +190,10 @@ export default function ProductDetailPage() {
<View className="product-detail-promo-icon">
<Text className="product-detail-promo-icon-text"></Text>
</View>
<Text className="product-detail-promo-title">
·
<Text className="product-detail-promo-amount"> ¥{benefit}</Text>
</Text>
<View className="product-detail-promo-title">
<Text> · </Text>
<BenefitFigure value={String(benefit)} size="sm" className="product-detail-promo-amount" />
</View>
</View>
<Text className="product-detail-promo-desc">
@@ -8,6 +8,7 @@ import RedeemQrCode from '../../components/RedeemQrCode';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { formatMoney } from '../../lib/money';
import BenefitFigure from '../../components/BenefitFigure';
const POLL_INTERVAL_MS = 2500;
const LAST_REDEEM_RECORD_KEY = 'lastRedeemRecordId';
@@ -154,7 +155,7 @@ export default function RedeemCodePage() {
<Text className="redeem-timer-label"></Text>
</View>
<Text className="u-muted"></Text>
<Text className="redeem-code-amount">¥ {formatMoney(amount)}</Text>
<BenefitFigure value={formatMoney(amount)} size="lg" className="redeem-code-amount" />
{token ? (
<View className="redeem-code-token-wrap" onClick={onTokenTap}>
<Text className="redeem-code-token-label"></Text>
@@ -6,7 +6,8 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { formatShanghaiDateTime } from '../../lib/datetime';
import { formatMoney } from '../../lib/money';
import { formatMoney, toMoneyNumber } from '../../lib/money';
import BenefitFigure from '../../components/BenefitFigure';
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
@@ -66,7 +67,7 @@ export default function RedeemSuccessPage() {
}
}, []);
const amount = Number(record?.amount ?? router.params.amount ?? 0);
const amount = toMoneyNumber(record?.amount ?? router.params.amount);
const storeName = record?.storeName || '门店';
const redeemNo = record?.redeemNo || '—';
const redeemedAt = formatChinaDateTime(record?.createdAt);
@@ -119,7 +120,7 @@ export default function RedeemSuccessPage() {
<Text></Text>
</View>
<Text className="redeem-success-title"></Text>
<Text className="redeem-success-amount">¥ {formatMoney(amount)}</Text>
<BenefitFigure value={formatMoney(amount)} size="lg" className="redeem-success-amount" />
<Text className="redeem-success-desc"> {storeName} </Text>
<View className="redeem-success-details">
+11 -7
View File
@@ -7,6 +7,7 @@ import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api';
import { formatMoney } from '../../lib/money';
import BenefitFigure from '../../components/BenefitFigure';
type BenefitSummary = {
totalBalance: number;
@@ -103,7 +104,7 @@ export default function RedeemPage() {
return;
}
if (value > redeemableMax) {
toast(couponId ? '核销金额不能超过该权益可用余额' : '超出可用余额');
toast(couponId ? '核销金额不能超过该权益可用余额' : '核销金额不能超过可用余额');
return;
}
@@ -134,9 +135,11 @@ export default function RedeemPage() {
<Text className="redeem-hero-label">
{couponId ? '当前权益可用余额' : '可用余额'}
</Text>
<Text className="redeem-hero-amount">
¥{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
</Text>
<BenefitFigure
value={redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
size="xl"
className="redeem-hero-amount"
/>
</View>
<View className="redeem-input-wrap">
<Input
@@ -153,9 +156,10 @@ export default function RedeemPage() {
/>
</View>
<View className="redeem-amount-foot">
<Text className="redeem-amount-hint">
¥{formatMoney(redeemableMax)}
</Text>
<View className="redeem-amount-hint">
<Text></Text>
<BenefitFigure value={formatMoney(redeemableMax)} size="sm" />
</View>
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
</Text>
@@ -16,6 +16,7 @@ import ShareNavButton from '../../components/ShareNavButton';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import { formatShanghaiDateTime } from '../../lib/datetime';
@@ -116,9 +117,8 @@ function formatPackagePriceYuan(price: string | number) {
return n.toFixed(2).replace(/\.?0+$/, '');
}
function formatRedeemAmountYuan(amount: number | string) {
const n = typeof amount === 'number' ? amount : Number(amount);
if (!Number.isFinite(n)) return '0';
function formatRedeemAmountYuan(amount: unknown) {
const n = toMoneyNumber(amount);
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return n.toFixed(2).replace(/\.?0+$/, '');
}
@@ -323,6 +323,8 @@ export default function StoreDetailPage() {
const envPhotos = envPhotoUrls(store);
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
/** 预览相册:封面 + 环境图(去重),页面展示仍分开 */
const previewAlbum = uniqueUrls([store.coverUrl, ...envPhotos]);
const packages = store.packages ?? [];
const intro = store.intro?.trim() || '';
@@ -337,10 +339,12 @@ export default function StoreDetailPage() {
}
function previewEnv(index: number) {
if (!envPhotos.length) return;
const current = envPhotos[index];
if (!current) return;
const urls = previewAlbum.length ? previewAlbum : envPhotos;
Taro.previewImage({
current: envPhotos[index],
urls: envPhotos,
current,
urls,
}).catch(() => toast('无法预览图片'));
}
@@ -362,6 +366,7 @@ export default function StoreDetailPage() {
variant="store"
previewable
imageFit="contain"
previewUrls={previewAlbum}
/>
</View>
+11 -1
View File
@@ -98,7 +98,7 @@
.benefit-hero-amount {
display: flex;
align-items: baseline;
align-items: center;
color: var(--color-heritage-red);
}
@@ -109,6 +109,8 @@
}
.benefit-hero-value {
display: inline-flex;
align-items: center;
font-family: var(--font-headline);
font-size: 40px;
font-weight: 700;
@@ -216,6 +218,10 @@
color: var(--color-heritage-red);
}
.benefit-coupon-meta .benefit-figure {
color: inherit;
}
.benefit-coupon-no {
display: block;
font-size: 11px;
@@ -244,6 +250,10 @@
}
.benefit-coupon-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
font-size: 12px;
color: var(--color-subtle-gray);
}
+1
View File
@@ -304,6 +304,7 @@
position: relative;
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--color-aged-amber);
color: var(--color-on-secondary-container);
padding: 2px 8px;
+3 -1
View File
@@ -215,7 +215,7 @@
.mine-asset-amount {
display: flex;
align-items: baseline;
align-items: center;
color: var(--color-heritage-red);
}
@@ -226,6 +226,8 @@
}
.mine-asset-value {
display: inline-flex;
align-items: center;
font-family: var(--font-headline);
font-size: 24px;
font-weight: 700;
+2
View File
@@ -215,6 +215,8 @@
}
.order-row-value--price {
display: inline-flex;
align-items: center;
color: var(--color-heritage-red);
font-weight: 700;
}
@@ -175,6 +175,10 @@
.product-detail-promo-title {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
font-family: var(--font-headline);
font-size: 16px;
font-weight: 700;
@@ -184,6 +188,7 @@
.product-detail-promo-amount {
color: var(--color-heritage-red);
font-weight: 700;
}
.product-detail-promo-desc {
+13 -3
View File
@@ -24,6 +24,9 @@
}
.redeem-hero-amount {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-headline);
font-size: 36px;
font-weight: 700;
@@ -92,6 +95,10 @@
}
.redeem-amount-hint {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
font-size: 12px;
color: var(--color-subtle-gray);
line-height: 1.4;
@@ -279,7 +286,9 @@
}
.redeem-code-amount {
display: block;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-headline);
font-size: 28px;
font-weight: 700;
@@ -350,8 +359,9 @@
}
.redeem-success-amount {
display: block;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-headline);
font-size: 28px;
font-weight: 700;
+2 -1
View File
@@ -19,9 +19,10 @@
| 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) |
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(限测可见+Mock旁路;测试流水计入结算** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) |
| 3.5.3 | 08-20 | 门店照片/企微审核/分享;核销金额序列化与提现补建;测试流水计结算;核销浮动提示;权益金额门店图标 | [`v3.5.3`](./杜康好客-v3.5.3-开发文档.md) |
---
+5 -5
View File
@@ -9,7 +9,7 @@
|------|------|
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
| C 端 | `mini-user` 小程序 + h5-userv3.4.13 版本门控/门店/物流已上 |
| 近期版本 | … · v3.4.15 · **v3.4.16 联系电话/套餐 UI/合伙人列表与入驻客服门槛/HQ 门店列表表格** |
| 近期版本 | … · v3.4.16 · **v3.5.3 门店照片/企微审核/分享 + 核销金额/提现补修 + 权益图标** |
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
@@ -57,14 +57,14 @@
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | ✅ 生产 |
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | 🔶 开发中 |
| 日期 | 说明 |
|------|------|
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
| 2026-08-20 | v3.5.3 增补:Decimal 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态 |
| 2026-08-07 | v3.4.14 增补统一测试白名单(限测可见 / Mock 旁路;测试流水计入结算) |
| 2026-08-07 | v3.4.15 增补:HQ 门店照片替换/删除、套餐多图上限 20 |
| 2026-08-05 | v3.4.13 |
| 2026-08-04 | v3.4.11 / v3.4.12 |
| 2026-07-11 | 首版对照表 |
@@ -10,7 +10,7 @@
| 门店详情·套餐 | 仅完整标题纵向列表;点击进入详情 |
| 套餐详情页 | 实底导航让出胶囊区;内容区顶/左右留白;有图在门店名下;无图直接菜品 |
| **系统设置·微信小程序** | Logo / 资质图 / 客服电话 / C 端 H5 / Mock 验证码 **可配置**,经 `client-config` 下发 |
| **测试白名单** | 全局手机号名单;账号/门店/订单/核销打标;不计四条结算;合并商品/门店可见性手机号;HQ 独立管理模块 |
| **测试白名单** | 全局手机号名单;账号/门店/订单/核销打标(计入结算;合并商品/门店可见性手机号;HQ 独立管理模块 |
## 页面路由
@@ -46,8 +46,8 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared
|------|------|
| 源真相 | HQ「白名单管理」维护 `common_test_whitelist_phone`;名单内手机号 = 测试账号 |
| 可见性 | 商品/门店 `visibilityWhitelistEnabled` 开启后,C 端仅当观众手机号 ∈ 全局名单可见/可购(不再用分实体 `*_visibility_phone` |
| 打标 | `User` / `StoreAccount` / `PartnerAccount` / `Store` / `Order` / `RedeemRecord``isTest` |
| 不计账 | 测试核销产生 `StorePayout`;酒厂/物流/合伙人账单排除 `isTest` 流水 |
| 打标 | `User` / `StoreAccount` / `PartnerAccount` / `Store` / `Order` / `RedeemRecord``isTest`HQ 列表可过滤;**计入结算** |
| 结算 | 测试核销同样产生 `StorePayout`;酒厂/物流/合伙人账单**包含** `isTest` 流水 |
| 验证旁路 | 页顶 Checkbox ↔ `MOCK_SMS` / `MOCK_WECHAT` / `MOCK_PAY`(勾选 = 不做真实验证) |
### API
@@ -80,7 +80,7 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared
- [ ] 页顶三 Checkbox 控制短信/微信/支付跳过真实验证,与 `MOCK_*` 同源立即生效
- [ ] 业务列表「过滤测试账号」勾选后不含测试数据
- [ ] 旧可见性手机号已导入;限测商品/门店仅全局名单可见
- [ ] 测试流水进四条账单与门店打款
- [ ] 测试流水进四条账单与门店打款(与正式流水相同)
## HQ 开发计划
+200
View File
@@ -0,0 +1,200 @@
# 杜康好客 · v3.5.3 版本更新
> **2026-08-20** · admin-web / mini-user / h5-partner / h5-shop / API
> 目标:门店封面/环境图体验、企微门店审核通知、总部抽屉内审套餐、小程序分享用业务标题主图、系统设置分享配置按场景折叠;**补修核销金额为 0 / 无法提现、测试流水计结算、核销浮动提示、好客权益金额图标**;**企微业务通知可编辑模板 + 处理快链**。
## 范围
| # | 项 | 交付 |
|---|----|------|
| 1 | 合伙人改封面/环境图 | **现状已具备**`PUT /partner/stores/:id/media`);本版不改接口 |
| 1a | 小程序店内环境预览 | 页面内竖排展示;点击调用 `previewImage`,相册内可左右滑 |
| 1b | 录入/编辑页环境图上传 UI | 去掉独立「批量上传 / 重新上传照片」大按钮;九宫格「+」选图;编辑页「提交变更」同时提交门头照/环境图 |
| 2 | 客服改企微 | **暂不处理** |
| 3 | 门店提交审核企微通知 | `wecom_message_push` 条件 `store.audit_pending`;群名「门店审核通知群」 |
| 4 | 套餐审核同步 + 通知 | 门店详情抽屉内对比并通过/驳回;`store.package_audit_pending` 同群通知 |
| 5 | 商品/门店微信分享 | title/主图固定用业务字段(不被 HQ 场景配置覆盖) |
| 6 | 系统设置分享分组 | 「小程序分享」内按场景子组 Collapse,可展开收起 |
| 7 | 核销金额序列化 / 门店提现 | Prisma Decimal → JSON 数字;结算比例兜底;提现摘要补建缺失 `store_payout` |
| 8 | 测试流水计结算 | **取消**「测试流水不计结算」;核销一律建 payout;账单/佣金不再因 `isTest` 跳过 |
| 9 | 核销校验浮动提示 | 门店 H5 / 小程序:超可用余额等提示改为页面中上部浮动气泡 |
| 10 | 好客权益金额图标 | 小程序权益金额前去掉 ¥,改为门店核销语义图标(商品售价/实付仍用 ¥) |
| 11 | 企微业务通知扩展 | 订单支付/核销成功/信息变更/提现/发票;`wecom_push_template` 可编辑;处理快链 |
**配置进库**`wecom_message_push` upsert「门店审核通知群 / 业务待办通知群 / 成交播报群」(无 webhook 时占位 URL + `enabled=false`)。**新表** `wecom_push_template`(发版不可 skip-db)。
---
## 1. 封面 / 环境图
### 合伙人端(已有)
- 创建:`StoreCreatePage` 门头照 + 环境照(≥3
- 修改:`StoreDetailPage``PUT /partner/stores/:id/media`
- 限制:`CLOSED` / `auditStatus=PENDING` 不可改;`REJECTED` 改照片会重提审核
### 小程序门店详情
- 顶部:仅 `coverUrl`(不混环境图)
- 「店内环境」:竖排长图列表;点击 `Taro.previewImage`,预览相册为**封面 + 环境图**(可左右滑);点封面预览同相册
### 录入/编辑页环境图 UI
- `MultiOssUploadField` 支持九宫格模式:缩略图 + 末尾「+」,去掉全宽虚线大按钮
- **已营业门店**:门头照/环境图变更并入「信息变更」审核(`coverUrl` / `envPhotoUrls`);总部「审核通知 → 信息变更」通过后才覆盖线上图;`PUT .../media` 对 APPROVED 门店已禁用
- **入驻驳回**:仍走 `PUT .../media` / `basic` 重提门店审核
- 签约合同本条不改
---
## 3 / 4. 企微「门店审核通知群」
| 条件 key | 触发 |
|----------|------|
| `store.audit_pending` | 新建门店进 PENDING;驳回后基本信息/照片重提 |
| `store.package_audit_pending` | 合伙人/门店提交套餐变更 PENDING |
- HQ `/wecom/pushes` 可改 webhook、启停、条件
- API 启动按名称 upsert 默认行;可选 env `WECOM_STORE_AUDIT_WEBHOOK_URL`
### 总部抽屉内审套餐
- 共享组件 `StorePackageAuditPanel`(对比 + 通过/驳回)
- `StoresPage` 详情抽屉「套餐」Tab 内嵌;列表「审核套餐」打开抽屉并切 Tab
- 原「审核通知」页保留
---
## 5. 小程序分享
| 场景 | title | imageUrl |
|------|-------|----------|
| 商品详情 | `product.name` | `mainImageUrl``carouselUrls[0]` |
| 门店详情 | `store.name` | `coverUrl` → 首张环境图 |
`buildSceneSharePayload`:上述两场景 title/image 优先业务字段;其它场景仍场景配置优先。
---
## 6. 系统设置分享子组
`SystemConfigFieldMeta.subgroup``wechat_mini_share` 内层 Collapse:全局 / 首页 / 门店列表 / 门店详情 / 权益 / 我的 / 商品详情 / 订单详情。
---
## 7. 核销金额序列化与门店提现补修
**现象**:线上偶发核销后金额显示为 `0.00`、门店「结算提现」可用余额为 0。
| 根因 | 处理 |
|------|------|
| Prisma `Decimal``serializeBigInt` 未转成数字,前端 `Number``NaN` → 显示 0 | `convertForJson``Decimal``number``bigint``string``Date` → ISO |
| `settlementRate` 非法/为空导致结算额为 0 | `packages/domain``DEFAULT_STORE_SETTLEMENT_RATE = 0.6``resolveSettlementRate()`;核销建 payout 使用兜底比例 |
| 历史核销无 `store_payout`(含曾跳过测试流水) | `settlement.service`:提现摘要 `backfillMissingStorePayouts(storeId)`(最多补 200 条孤儿核销) |
| 前端弱网/对象形态金额 | `h5-shop` / `mini-user` `formatMoney` / `toMoneyNumber` 统一解析 |
**发版后**:门店打开一次「结算提现」即可触发补建;无需手工 SQL。
| 位置 | 说明 |
|------|------|
| `common/decorators/current-user.decorator.ts` | 响应序列化 |
| `packages/domain` | `resolveSettlementRate` / `validateRedeemAmount``NaN` |
| `redeem.service` / `settlement.service` | 兜底比例 + 补建 payout |
| `apps/h5-shop/src/lib/money.ts``apps/mini-user/src/lib/money.ts` | 金额展示 |
---
## 8. 测试流水计入结算(规则变更)
相对 v3.4.14「测试打标」保留,**结算口径对齐正式流水**:
- 核销:一律 `createStorePayout`(记录仍可带 `isTest` 供 HQ `excludeTest` 列表过滤)
- 提现补建:不再跳过测试门店 / `isTest` 核销
- 合伙人账单、酒厂 T+3、物流账单:不再因 `isTest` 排除或零佣金
**仍保留**:HQ 列表「过滤测试账号」;测试账号 JWT 状态旁路;待支付 30 分钟取消仍只处理非测试单(非结算链路)。
---
## 9. 核销校验浮动提示
「核销金额不能超过可用余额」等校验/接口错误,改为页面**中上部**固定定位气泡(约 2.5~2.8s 消失),不再贴在表单底部(易滚出屏外)。
| 端 | 实现 |
|----|------|
| 门店 H5 | `ShopToastProvider` + `toastError` / `toastSuccess`;手机号核销、扫码确认、弱网兜底 |
| 小程序 | `FloatingToastHost`(挂 `PageShell`);`toast(icon≠success)` 走浮动层;成功仍用原生 `showToast` |
「门店休息中」等需持续可见的说明仍保留内联 banner。
---
## 10. 好客权益金额图标(mini-user)
权益额度前去掉人民币符号,改用红色圆角方块 + 白色店铺剪影图标,语义为「可到店核销」。
- 组件:`BenefitFigure``assets/icons/store-benefit.png`
- 覆盖:首页角标、商品详情礼遇额、下单「好客权益」行、我的资产、权益页余额/券面、核销页/码/成功页金额
- **不改**:商品售价、订单实付、套餐价、人均价等现金口径(仍用 ¥)
---
## 11. 企微业务通知 + 可编辑模板 + 处理快链
### 事件条件 key
| key | 触发 | 测试单 |
|-----|------|--------|
| `order.paid` | `afterOrderPaid` | 不推 |
| `redeem.success` | `executeRedeem` 成功后 | 不推 |
| `store.audit_pending` | 入驻/重提 PENDING | 推 |
| `store.package_audit_pending` | 套餐变更 PENDING | 推 |
| `store.info_change_pending` | 信息变更 PENDING | 推 |
| `store.withdraw_pending` | 手动提现 PENDING_REVIEW | 推 |
| `invoice.pending` | 发票申请 PENDING | 不推 |
默认推送行:`业务待办通知群``成交播报群`(占位 webhook + 禁用,直至 HQ 配置)。
### 模板表 `wecom_push_template`
- 按 `eventKey` 全站一份;启动 `ensureTemplates` **仅插入缺行**,不覆盖 HQ 已改
- HQ「企微机器人 → 消息推送 → 通知模板」:编辑 / 恢复默认 / 示例测试推送
- API`GET/PUT /admin/wecom-push-templates/:eventKey``POST .../reset``POST .../test`
- 渲染:`WecomMessagePushService.dispatchEvent``{{var}}` 插值 → 条件路由
### 处理快链
环境变量 `HQ_ADMIN_PUBLIC_URL`staging `https://admin-test.dukanghaoke.com`,生产 `https://admin.dukanghaoke.com`)。
未配置时按 `WECOM_ALERT_ENV_LABEL` 回退(prod→生产域名,staging→测试域名,local→`http://localhost:5175`),**禁止**只发相对路径(企微会解析成 `http://orders/...`)。
| 事件 | 路径 |
|------|------|
| 订单 | `/orders?orderNo=` |
| 核销 | `/redeem-records?redeemNo=` |
| 入驻 | `/stores?auditStatus=PENDING&storeId=` |
| 套餐 | `/store-package-audits?requestId=` |
| 信息变更 | `/store-package-audits?tab=info&infoRequestId=` |
| 提现 | `/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=` |
| 发票 | `/invoices?status=PENDING&invoiceNo=` |
提现待审改走 `store.withdraw_pending`,不再误用 `AlertService` `category: finance``alert.system`
---
## 验收
- [ ] 合伙人可改门头/环境图(PENDING 除外)
- [ ] 录入门店环境图无独立「批量上传」大按钮,九宫格「+」可多选
- [ ] 小程序门店详情「店内环境」竖排;点开预览后可左右滑看多图
- [ ] 关自动通过后,新建/重提门店推企微;提交套餐同样推
- [ ] 门店详情抽屉可审套餐,不必跳页
- [ ] 商品/门店分享卡片为该实体名称 + 主图
- [ ] 系统设置「小程序分享」按场景可展开/收起
- [ ] 核销接口返回的 `amount` / `settleAmount` 为数字;门店记录/成功页不再显示 `0.00`(真实有额时)
- [ ] 历史无 payout 的核销:打开「结算提现」后可用余额出现;测试核销同样计结算
- [ ] 门店 H5 / 小程序超额核销提示为中上部浮动气泡
- [ ] 小程序好客权益金额前为店铺图标,商品价仍为 ¥
- [ ] HQ 可编辑企微通知模板;勾选条件 + 配置 webhook 后订单/核销/提现/发票/审核有推送
- [ ] 企微消息「去处理」可打开 HQ 对应列表并尽量定位到该单(需登录)
- [ ] 测试订单/核销不推成交播报;发票申请测试单不推
- [ ] 发版含 `wecom_push_template` 表与 `HQ_ADMIN_PUBLIC_URL`
+11
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
calcBenefitAmount,
calcRedeemSettleAmount,
resolveSettlementRate,
calcLogisticsFeeByBottles,
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
@@ -92,6 +93,7 @@ describe('validateRedeemAmount', () => {
expect(validateRedeemAmount(100, 100).ok).toBe(true);
expect(validateRedeemAmount(50, 60).ok).toBe(false);
expect(validateRedeemAmount(100, 0).ok).toBe(false);
expect(validateRedeemAmount(100, Number.NaN).ok).toBe(false);
});
it('allows direct redeem up to total balance without a document cap', () => {
@@ -114,6 +116,15 @@ describe('calcRedeemSettleAmount', () => {
});
});
describe('resolveSettlementRate', () => {
it('falls back to 0.6 when missing or invalid', () => {
expect(resolveSettlementRate(0.6)).toBe(0.6);
expect(resolveSettlementRate(0)).toBe(0.6);
expect(resolveSettlementRate(Number.NaN)).toBe(0.6);
expect(resolveSettlementRate(undefined)).toBe(0.6);
});
});
describe('calcLogisticsFeeByBottles', () => {
const xfx = {
baseBottles: 2,
+14 -3
View File
@@ -33,14 +33,25 @@ export function validateMinPurchase(
return { ok: true };
}
export const DEFAULT_STORE_SETTLEMENT_RATE = 0.6;
export function resolveSettlementRate(raw: unknown): number {
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_STORE_SETTLEMENT_RATE;
}
export function validateRedeemAmount(
balance: number,
amount: number,
documentAmount?: number | null,
): { ok: boolean; message?: string } {
if (amount <= 0) return { ok: false, message: '核销金额必须大于 0' };
if (amount > balance) return { ok: false, message: '核销金额不能超过可用余额' };
if (documentAmount != null && amount > documentAmount) {
if (!Number.isFinite(amount) || amount <= 0) {
return { ok: false, message: '核销金额必须大于 0' };
}
if (!Number.isFinite(balance) || amount > balance) {
return { ok: false, message: '核销金额不能超过可用余额' };
}
if (documentAmount != null && Number.isFinite(documentAmount) && amount > documentAmount) {
return { ok: false, message: '核销金额不能超过该核销单可用金额' };
}
return { ok: true };
+33 -1
View File
@@ -10,7 +10,7 @@ export const STORE_INFO_CHANGE_STATUS_LABELS: Record<StoreInfoChangeStatus, stri
REJECTED: '已驳回',
};
/** 门店基础信息可变字段白名单(提交变更 / 总部审核覆盖时使用,均为 Store 标量列 */
/** 门店基础信息可变字段白名单(提交变更 / 总部审核覆盖时使用) */
export const STORE_INFO_CHANGEABLE_FIELDS = [
'name',
'contactPhone',
@@ -24,10 +24,42 @@ export const STORE_INFO_CHANGEABLE_FIELDS = [
'openTime2',
'closeTime2',
'avgPrice',
/** 门头照 URL(审核通过后写入 cover + common_resource */
'coverUrl',
/** 环境照 URL 数组(审核通过后替换 ENV common_resource */
'envPhotoUrls',
] as const;
export type StoreInfoChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
/** 可变字段中文名(企微推送 / HQ 审核列表共用) */
export const STORE_INFO_CHANGEABLE_FIELD_LABELS: Record<StoreInfoChangeableField, string> = {
name: '门店名称',
contactPhone: '联系电话',
address: '详细地址',
intro: '门店简介',
benefitUsageRule: '权益券使用规则',
latitude: '纬度',
longitude: '经度',
openTime: '营业开始',
closeTime: '营业结束',
openTime2: '第二段开始',
closeTime2: '第二段结束',
avgPrice: '人均费用',
coverUrl: '门头照',
envPhotoUrls: '环境照片',
};
/** 将字段 key 列表格式化为中文,如「门店名称、详细地址」 */
export function formatStoreInfoChangeFieldLabels(
fields: readonly string[],
separator = '、',
): string {
return fields
.map((f) => STORE_INFO_CHANGEABLE_FIELD_LABELS[f as StoreInfoChangeableField] ?? f)
.join(separator);
}
/** 单条记录的字段详情(用于审核页 diff 对比) */
export interface StoreInfoChangeFieldDiff {
field: StoreInfoChangeableField;
@@ -11,6 +11,8 @@ export interface SystemConfigFieldMeta {
key: string;
label: string;
group: string;
/** 组内子分组(如小程序分享按场景折叠) */
subgroup?: string;
type: SystemConfigFieldType;
secret?: boolean;
requiresRestart: boolean;
+146 -2
View File
@@ -1,4 +1,4 @@
/** 企微群机器人 Webhook · 推送条件(v3.4.11 */
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知 */
export const WECOM_PUSH_CONDITIONS = [
'alert.ops',
'support_ticket.created',
@@ -7,6 +7,13 @@ export const WECOM_PUSH_CONDITIONS = [
'alert.system',
'alert.settlement',
'dev_plan.task_dispatch',
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'order.paid',
'redeem.success',
'invoice.pending',
] as const;
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
@@ -19,6 +26,13 @@ export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
'alert.system': '系统监控(5xx、回调、定时任务)',
'alert.settlement': '结算任务告警',
'dev_plan.task_dispatch': '开发任务评审派发',
'store.audit_pending': '门店提交/重提待审',
'store.package_audit_pending': '套餐变更待审',
'store.info_change_pending': '门店信息变更待审',
'store.withdraw_pending': '门店手动提现待审',
'order.paid': '订单支付成功',
'redeem.success': '门店核销成功',
'invoice.pending': '发票申请待开票',
};
export const WECOM_PUSH_CONDITION_GROUPS: Array<{
@@ -33,9 +47,25 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
},
{
key: 'pay_redeem',
label: '支付与核销',
label: '支付与核销异常',
conditions: ['alert.pay', 'alert.redeem'],
},
{
key: 'deal_broadcast',
label: '成交播报',
conditions: ['order.paid', 'redeem.success'],
},
{
key: 'biz_todo',
label: '业务待办',
conditions: [
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'invoice.pending',
],
},
{
key: 'system',
label: '系统与结算',
@@ -64,6 +94,120 @@ export const WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS: WecomPushCondition[] =
'support_ticket.created',
];
/** 默认「门店审核通知群」推送条件 */
export const WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS: WecomPushCondition[] = [
'store.audit_pending',
'store.package_audit_pending',
];
/** 默认「业务待办通知群」 */
export const WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS: WecomPushCondition[] = [
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'invoice.pending',
];
/** 默认「成交播报群」 */
export const WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS: WecomPushCondition[] = [
'order.paid',
'redeem.success',
];
export const WECOM_STORE_AUDIT_PUSH_NAME = '门店审核通知群';
export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群';
export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群';
/** 占位 webhook(未配置真实 key 时禁用,避免误推) */
export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK =
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=PENDING';
/** 有 HQ 可编辑模板的业务事件(含入驻/套餐,不含 alert.*) */
export const WECOM_TEMPLATE_EVENT_KEYS = [
'order.paid',
'redeem.success',
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'invoice.pending',
] as const;
export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number];
export const WECOM_TEMPLATE_EVENT_LABELS: Record<WecomTemplateEventKey, string> = {
'order.paid': '订单支付成功',
'redeem.success': '门店核销成功',
'store.audit_pending': '门店提交/重提待审',
'store.package_audit_pending': '套餐变更待审',
'store.info_change_pending': '门店信息变更待审',
'store.withdraw_pending': '门店手动提现待审',
'invoice.pending': '发票申请待开票',
};
/** 各事件可用占位符说明(HQ 模板编辑) */
export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]> = {
'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'],
'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'],
'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'],
'store.package_audit_pending': [
'storeName',
'cityName',
'submitter',
'packageCount',
'time',
'handleUrl',
'handleLabel',
'requestId',
],
'store.info_change_pending': [
'storeName',
'cityName',
'submitter',
'changedFields',
'time',
'handleUrl',
'handleLabel',
'requestId',
],
'store.withdraw_pending': [
'storeName',
'withdrawNo',
'amount',
'payoutCount',
'time',
'handleUrl',
'handleLabel',
'storeId',
],
'invoice.pending': [
'invoiceNo',
'orderNo',
'payAmount',
'titleName',
'phoneMasked',
'time',
'handleUrl',
'handleLabel',
],
};
export type WecomPushTemplateDto = {
id: string;
eventKey: WecomTemplateEventKey;
title: string;
body: string;
handleLabel: string;
updatedAt: string;
};
export type UpdateWecomPushTemplateRequest = {
title?: string;
body?: string;
handleLabel?: string;
};
export function parseWecomPushConditions(
raw?: string | string[] | null,
): WecomPushCondition[] {
+6
View File
@@ -69,6 +69,12 @@ WECOM_AIBOT_ENABLED=false
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=local
# HQ 企微业务通知「去处理」快链根地址(无尾斜杠;未配时按 WECOM_ALERT_ENV_LABEL 回退生产/测试/本机)
# HQ_ADMIN_PUBLIC_URL=http://localhost:5175
# 生产请设:https://admin.dukanghaoke.com ;测试:https://admin-test.dukanghaoke.com
# WECOM_STORE_AUDIT_WEBHOOK_URL=
# WECOM_BIZ_TODO_WEBHOOK_URL=
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
# 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
@@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=production
# HQ 企微业务通知「去处理」快链根地址
HQ_ADMIN_PUBLIC_URL=https://admin.dukanghaoke.com
# WECOM_STORE_AUDIT_WEBHOOK_URL=
# WECOM_BIZ_TODO_WEBHOOK_URL=
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
+5
View File
@@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=staging
# HQ 企微业务通知「去处理」快链根地址
HQ_ADMIN_PUBLIC_URL=https://admin-test.dukanghaoke.com
# WECOM_STORE_AUDIT_WEBHOOK_URL=
# WECOM_BIZ_TODO_WEBHOOK_URL=
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
+13
View File
@@ -504,6 +504,19 @@ model WecomMessagePush {
@@map("wecom_message_push")
}
/// 企微业务通知文案模板(v3.5.3 · 按事件全站一份,HQ 可编辑)
model WecomPushTemplate {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
eventKey String @unique @map("event_key") @db.VarChar(64)
title String @db.VarChar(128)
body String @db.Text
handleLabel String @default("去处理") @map("handle_label") @db.VarChar(32)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@map("wecom_push_template")
}
/// HQ 语言模型 API 配置(非超管仅可见/可开关自己创建的)
model LlmApiConfig {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
@@ -1,4 +1,5 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../guards/jwt-auth.guard';
export const CurrentUser = createParamDecorator(
@@ -7,8 +8,29 @@ export const CurrentUser = createParamDecorator(
},
);
export function serializeBigInt<T>(value: T): T {
return JSON.parse(
JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
);
function isPrismaDecimal(value: unknown): value is Prisma.Decimal {
return Prisma.Decimal.isDecimal(value);
}
function convertForJson(value: unknown): unknown {
if (typeof value === 'bigint') return value.toString();
if (isPrismaDecimal(value)) {
const n = Number(value.toString());
return Number.isFinite(n) ? n : 0;
}
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map(convertForJson);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = convertForJson(v);
}
return out;
}
return value;
}
/** JSON 安全序列化:bigint → stringPrisma.Decimal → number(避免金额变成 {} 被前端显示成 0) */
export function serializeBigInt<T>(value: T): T {
return JSON.parse(JSON.stringify(convertForJson(value))) as T;
}
@@ -245,6 +245,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
key: 'SHARE_HINT',
label: '分享引导文案',
group: G.wechat_mini_share,
subgroup: 'global',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_HINT,
@@ -254,6 +255,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
key: 'SHARE_DEFAULT_TITLE',
label: '默认分享标题',
group: G.wechat_mini_share,
subgroup: 'global',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_TITLE,
@@ -263,6 +265,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
key: 'SHARE_DEFAULT_DESC',
label: '默认分享描述',
group: G.wechat_mini_share,
subgroup: 'global',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_DESC,
@@ -272,150 +275,170 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
key: 'SHARE_DEFAULT_IMAGE_URL',
label: '默认分享图',
group: G.wechat_mini_share,
subgroup: 'global',
type: 'image',
requiresRestart: false,
description: '各场景未配置图且无业务图时使用;建议接近 5:4',
},
{
key: 'SHARE_HOME_TITLE',
label: '首页 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'home',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_HOME_DESC',
label: '首页 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'home',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_HOME_IMAGE_URL',
label: '首页 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'home',
type: 'image',
requiresRestart: false,
description: '留空则优先用首页首张轮播图,再回退默认分享图',
},
{
key: 'SHARE_STORES_TITLE',
label: '门店列表 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'stores',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_STORES_TITLE,
},
{
key: 'SHARE_STORES_DESC',
label: '门店列表 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'stores',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_STORES_IMAGE_URL',
label: '门店列表 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'stores',
type: 'image',
requiresRestart: false,
},
{
key: 'SHARE_STORE_DETAIL_TITLE',
label: '门店详情 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'storeDetail',
type: 'string',
requiresRestart: false,
description: '留空则用门店名称',
description: '小程序好友/朋友圈分享固定用门店名称;本项仅作其它端回退参考,可留空',
},
{
key: 'SHARE_STORE_DETAIL_DESC',
label: '门店详情 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'storeDetail',
type: 'string',
requiresRestart: false,
description: '留空则用门店简介/地址',
},
{
key: 'SHARE_STORE_DETAIL_IMAGE_URL',
label: '门店详情 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'storeDetail',
type: 'image',
requiresRestart: false,
description: '留空则用门头图/环境图',
description: '小程序分享固定用门头图/环境图;本项可留空',
},
{
key: 'SHARE_BENEFIT_TITLE',
label: '权益页 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'benefit',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_BENEFIT_TITLE,
},
{
key: 'SHARE_BENEFIT_DESC',
label: '权益页 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'benefit',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_BENEFIT_IMAGE_URL',
label: '权益页 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'benefit',
type: 'image',
requiresRestart: false,
},
{
key: 'SHARE_MINE_TITLE',
label: '我的 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'mine',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_MINE_TITLE,
},
{
key: 'SHARE_MINE_DESC',
label: '我的 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'mine',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_MINE_IMAGE_URL',
label: '我的 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'mine',
type: 'image',
requiresRestart: false,
},
{
key: 'SHARE_PRODUCT_DETAIL_TITLE',
label: '商品详情 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'productDetail',
type: 'string',
requiresRestart: false,
description: '留空则用商品名称',
description: '小程序好友/朋友圈分享固定用商品名称;本项可留空',
},
{
key: 'SHARE_PRODUCT_DETAIL_DESC',
label: '商品详情 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'productDetail',
type: 'string',
requiresRestart: false,
description: '留空则用商品副标题',
},
{
key: 'SHARE_PRODUCT_DETAIL_IMAGE_URL',
label: '商品详情 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'productDetail',
type: 'image',
requiresRestart: false,
description: '留空则用商品主图',
description: '小程序分享固定用商品主图;本项可留空',
},
{
key: 'SHARE_ORDER_DETAIL_TITLE',
label: '订单详情 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'orderDetail',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_ORDER_TITLE,
@@ -423,15 +446,17 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
},
{
key: 'SHARE_ORDER_DETAIL_DESC',
label: '订单详情 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'orderDetail',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_ORDER_DETAIL_IMAGE_URL',
label: '订单详情 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'orderDetail',
type: 'image',
requiresRestart: false,
},
@@ -1,15 +1,31 @@
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
WECOM_BIZ_TODO_PUSH_NAME,
WECOM_DEAL_BROADCAST_PUSH_NAME,
WECOM_PUSH_CONDITIONS,
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS,
WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS,
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
WECOM_STORE_AUDIT_PUSH_NAME,
WECOM_TEMPLATE_EVENT_KEYS,
maskWecomWebhookUrl,
parseWecomPushConditions,
type WecomMessagePushDto,
type WecomPushCondition,
type WecomPushTemplateDto,
type WecomTemplateEventKey,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
import {
WECOM_PUSH_TEMPLATE_DEFAULTS,
buildHqHandleUrl,
getDefaultTemplate,
renderWecomTemplate,
} from './wecom-push-template.defaults';
type PushRow = {
id: bigint;
@@ -24,6 +40,16 @@ type PushRow = {
updatedAt: Date;
};
type TemplateRow = {
id: bigint;
eventKey: string;
title: string;
body: string;
handleLabel: string;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class WecomMessagePushService implements OnModuleInit {
private readonly logger = new Logger(WecomMessagePushService.name);
@@ -40,61 +66,137 @@ export class WecomMessagePushService implements OnModuleInit {
}
}
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送(v3.4.11 */
/** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 模板缺行 */
async ensureDefaults(): Promise<void> {
const count = await this.prisma.wecomMessagePush.count();
if (count > 0) return;
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
if (alertUrl) {
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
await this.prisma.wecomMessagePush.create({
data: {
name: '运营告警',
webhookUrl: alertUrl,
enabled: alertEnabled,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
sortOrder: 0,
},
});
this.logger.log('seeded wecom message push: 运营告警');
}
let devWebhook: string | null = null;
let devUserId: string | null = null;
let devEnabled = false;
try {
const rows = await this.prisma.$queryRawUnsafe<
Array<{
task_dispatch_webhook_url: string | null;
task_dispatch_wecom_user_id: string | null;
task_dispatch_enabled: number | boolean | null;
}>
>(
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
);
const row = rows[0];
if (row) {
devWebhook = row.task_dispatch_webhook_url;
devUserId = row.task_dispatch_wecom_user_id;
devEnabled = !!row.task_dispatch_enabled;
if (count === 0) {
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
if (alertUrl) {
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
await this.prisma.wecomMessagePush.create({
data: {
name: '运营告警',
webhookUrl: alertUrl,
enabled: alertEnabled,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
sortOrder: 0,
},
});
this.logger.log('seeded wecom message push: 运营告警');
}
let devWebhook: string | null = null;
let devUserId: string | null = null;
let devEnabled = false;
try {
const rows = await this.prisma.$queryRawUnsafe<
Array<{
task_dispatch_webhook_url: string | null;
task_dispatch_wecom_user_id: string | null;
task_dispatch_enabled: number | boolean | null;
}>
>(
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
);
const row = rows[0];
if (row) {
devWebhook = row.task_dispatch_webhook_url;
devUserId = row.task_dispatch_wecom_user_id;
devEnabled = !!row.task_dispatch_enabled;
}
} catch {
// 列已迁移删除,跳过
}
if (devWebhook?.trim()) {
await this.prisma.wecomMessagePush.create({
data: {
name: '开发任务派发',
webhookUrl: devWebhook.trim(),
enabled: devEnabled,
mentionWecomUserId: devUserId?.trim() || null,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
sortOrder: 10,
},
});
this.logger.log('seeded wecom message push: 开发任务派发');
}
} catch {
// 列已迁移删除,跳过
}
if (devWebhook?.trim()) {
await this.ensureNamedPush(
WECOM_STORE_AUDIT_PUSH_NAME,
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
20,
process.env.WECOM_STORE_AUDIT_WEBHOOK_URL,
);
await this.ensureNamedPush(
WECOM_BIZ_TODO_PUSH_NAME,
WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS,
25,
process.env.WECOM_BIZ_TODO_WEBHOOK_URL,
);
await this.ensureNamedPush(
WECOM_DEAL_BROADCAST_PUSH_NAME,
WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS,
30,
process.env.WECOM_DEAL_BROADCAST_WEBHOOK_URL,
);
await this.ensureTemplates();
}
private async ensureNamedPush(
name: string,
conditions: WecomPushCondition[],
sortOrder: number,
envUrl?: string,
): Promise<void> {
const existing = await this.prisma.wecomMessagePush.findFirst({ where: { name } });
if (existing) return;
const conditionsJson = JSON.stringify(conditions);
const url = (envUrl || '').trim();
if (url) {
await this.prisma.wecomMessagePush.create({
data: {
name: '开发任务派发',
webhookUrl: devWebhook.trim(),
enabled: devEnabled,
mentionWecomUserId: devUserId?.trim() || null,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
sortOrder: 10,
name,
webhookUrl: url,
enabled: true,
pushConditions: conditionsJson,
sortOrder,
},
});
this.logger.log('seeded wecom message push: 开发任务派发');
this.logger.log(`seeded wecom message push: ${name} (from env)`);
return;
}
await this.prisma.wecomMessagePush.create({
data: {
name,
webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
enabled: false,
pushConditions: conditionsJson,
sortOrder,
},
});
this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`);
}
/** 仅插入缺失 eventKey,不覆盖已有文案 */
async ensureTemplates(): Promise<void> {
for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) {
const existing = await this.prisma.wecomPushTemplate.findUnique({
where: { eventKey: def.eventKey },
});
if (existing) continue;
await this.prisma.wecomPushTemplate.create({
data: {
eventKey: def.eventKey,
title: def.title,
body: def.body,
handleLabel: def.handleLabel,
},
});
this.logger.log(`seeded wecom push template: ${def.eventKey}`);
}
}
@@ -111,6 +213,58 @@ export class WecomMessagePushService implements OnModuleInit {
return pushes.length > 0;
}
/**
* HQ
*
*/
async dispatchEvent(
eventKey: WecomTemplateEventKey,
vars: Record<string, string | number | null | undefined>,
options?: { applyMention?: boolean; handlePath?: string },
): Promise<number> {
try {
const content = await this.renderEventContent(eventKey, vars, options?.handlePath);
return await this.dispatchMarkdown(eventKey, content, {
applyMention: options?.applyMention ?? false,
});
} catch (e) {
this.logger.warn(
`dispatchEvent(${eventKey}) failed: ${e instanceof Error ? e.message : String(e)}`,
);
return 0;
}
}
async renderEventContent(
eventKey: WecomTemplateEventKey,
vars: Record<string, string | number | null | undefined>,
handlePath?: string,
): Promise<string> {
const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } });
const def = getDefaultTemplate(eventKey);
const body = row?.body || def?.body || `**${eventKey}**`;
const handleLabel = row?.handleLabel || def?.handleLabel || '去处理';
const merged: Record<string, string | number | null | undefined> = {
...vars,
handleLabel: vars.handleLabel ?? handleLabel,
time:
vars.time ??
new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
};
if (handlePath && !merged.handleUrl) {
merged.handleUrl = buildHqHandleUrl(handlePath);
}
let content = renderWecomTemplate(body, merged).trim();
const url = String(merged.handleUrl || '').trim();
if (url && !content.includes(url) && !/\{\{handleUrl\}\}/.test(body)) {
content = `${content}\n[${handleLabel}](${url})`;
}
return content;
}
/** 向所有匹配 eventKey 的启用推送发送 markdown;返回成功发送数 */
async dispatchMarkdown(
eventKey: WecomPushCondition,
@@ -196,6 +350,188 @@ export class WecomMessagePushService implements OnModuleInit {
: { ok: false, message: 'Webhook 调用失败,请检查 URL 或 API 日志' };
}
// ── templates CRUD ──
async listTemplates(): Promise<WecomPushTemplateDto[]> {
await this.ensureTemplates();
const rows = await this.prisma.wecomPushTemplate.findMany({
orderBy: { eventKey: 'asc' },
});
return rows.map((r) => this.templateToDto(r));
}
async getTemplate(eventKey: string): Promise<WecomPushTemplateDto> {
this.assertTemplateKey(eventKey);
await this.ensureTemplates();
const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } });
if (!row) throw new BadRequestException('模板不存在');
return this.templateToDto(row);
}
async updateTemplate(
eventKey: string,
data: { title?: string; body?: string; handleLabel?: string },
): Promise<WecomPushTemplateDto> {
this.assertTemplateKey(eventKey);
await this.ensureTemplates();
const existing = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } });
if (!existing) throw new BadRequestException('模板不存在');
const title = data.title != null ? String(data.title).trim() : undefined;
const body = data.body != null ? String(data.body).trim() : undefined;
const handleLabel =
data.handleLabel != null ? String(data.handleLabel).trim() || '去处理' : undefined;
if (title !== undefined && !title) throw new BadRequestException('标题不能为空');
if (body !== undefined && !body) throw new BadRequestException('正文不能为空');
const row = await this.prisma.wecomPushTemplate.update({
where: { eventKey },
data: {
...(title !== undefined ? { title } : {}),
...(body !== undefined ? { body } : {}),
...(handleLabel !== undefined ? { handleLabel } : {}),
},
});
return this.templateToDto(row);
}
async resetTemplate(eventKey: string): Promise<WecomPushTemplateDto> {
this.assertTemplateKey(eventKey);
const def = getDefaultTemplate(eventKey);
if (!def) throw new BadRequestException('无默认模板');
await this.ensureTemplates();
const row = await this.prisma.wecomPushTemplate.upsert({
where: { eventKey },
create: {
eventKey: def.eventKey,
title: def.title,
body: def.body,
handleLabel: def.handleLabel,
},
update: {
title: def.title,
body: def.body,
handleLabel: def.handleLabel,
},
});
return this.templateToDto(row);
}
/** 用示例变量渲染并推到勾选了该事件的启用群 */
async testTemplate(eventKey: string): Promise<{ ok: boolean; message: string; preview: string }> {
this.assertTemplateKey(eventKey);
const sample = this.sampleVars(eventKey);
const preview = await this.renderEventContent(
eventKey,
sample.vars,
sample.handlePath,
);
const sent = await this.dispatchMarkdown(eventKey, preview, { applyMention: false });
if (sent === 0) {
return {
ok: false,
message: '没有已启用且勾选该事件的消息推送,请先配置 webhook',
preview,
};
}
return { ok: true, message: `已发送至 ${sent} 个推送`, preview };
}
private sampleVars(eventKey: WecomTemplateEventKey): {
vars: Record<string, string>;
handlePath: string;
} {
const samples: Record<WecomTemplateEventKey, { vars: Record<string, string>; handlePath: string }> = {
'order.paid': {
vars: {
orderNo: 'DK202608200001',
payAmount: '199.00',
cityName: '郑州',
skuSummary: '杜康原浆 ×2',
phoneMasked: '138****8000',
},
handlePath: '/orders?orderNo=DK202608200001',
},
'redeem.success': {
vars: {
redeemNo: 'RD202608200001',
amount: '88.00',
storeName: '示例门店',
channel: '扫码',
},
handlePath: '/redeem-records?redeemNo=RD202608200001',
},
'store.audit_pending': {
vars: {
storeName: '示例门店',
cityName: '郑州',
partnerLabel: '示例合伙人',
action: '新建',
storeId: '1',
},
handlePath: '/stores?auditStatus=PENDING&storeId=1',
},
'store.package_audit_pending': {
vars: {
storeName: '示例门店',
cityName: '郑州',
submitter: '合伙人',
packageCount: '3',
requestId: '1',
},
handlePath: '/store-package-audits?requestId=1',
},
'store.info_change_pending': {
vars: {
storeName: '示例门店',
cityName: '郑州',
submitter: '门店',
changedFields: '门店名称、详细地址',
requestId: '1',
},
handlePath: '/store-package-audits?tab=info&infoRequestId=1',
},
'store.withdraw_pending': {
vars: {
storeName: '示例门店',
withdrawNo: 'SW202608200001',
amount: '500.00',
payoutCount: '5',
storeId: '1',
},
handlePath: '/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=1',
},
'invoice.pending': {
vars: {
invoiceNo: 'IV202608200001',
orderNo: 'DK202608200001',
payAmount: '199.00',
titleName: '示例公司',
phoneMasked: '138****8000',
},
handlePath: '/invoices?status=PENDING&invoiceNo=IV202608200001',
},
};
return samples[eventKey];
}
private assertTemplateKey(eventKey: string): asserts eventKey is WecomTemplateEventKey {
if (!(WECOM_TEMPLATE_EVENT_KEYS as readonly string[]).includes(eventKey)) {
throw new BadRequestException(`无效模板事件:${eventKey}`);
}
}
templateToDto(row: TemplateRow): WecomPushTemplateDto {
return {
id: row.id.toString(),
eventKey: row.eventKey as WecomTemplateEventKey,
title: row.title,
body: row.body,
handleLabel: row.handleLabel,
updatedAt: row.updatedAt.toISOString(),
};
}
toDto(row: PushRow): WecomMessagePushDto {
return {
id: row.id.toString(),
@@ -0,0 +1,157 @@
import type { WecomTemplateEventKey } from '@dukang/shared-types';
export type WecomTemplateDefault = {
eventKey: WecomTemplateEventKey;
title: string;
body: string;
handleLabel: string;
};
/** 代码内默认文案;ensureDefaults 仅在库中无行时写入,不覆盖 HQ 已改 */
export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
{
eventKey: 'order.paid',
title: '订单支付成功',
body: [
'**订单支付成功**',
'订单号:{{orderNo}}',
'实付:¥{{payAmount}}',
'城市:{{cityName}}',
'商品:{{skuSummary}}',
'用户:{{phoneMasked}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'redeem.success',
title: '门店核销成功',
body: [
'**门店核销成功**',
'核销单号:{{redeemNo}}',
'金额:¥{{amount}}',
'门店:{{storeName}}',
'渠道:{{channel}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'store.audit_pending',
title: '门店审核待处理',
body: [
'**门店审核待处理 · {{action}}**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'合伙人:{{partnerLabel}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'store.package_audit_pending',
title: '套餐变更待审核',
body: [
'**套餐变更待审核**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'提交端:{{submitter}}',
'套餐条数:{{packageCount}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'store.info_change_pending',
title: '门店信息变更待审',
body: [
'**门店信息变更待审**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'提交端:{{submitter}}',
'变更字段:{{changedFields}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'store.withdraw_pending',
title: '门店提现待审',
body: [
'**门店提现待审**',
'门店:{{storeName}}',
'提现单号:{{withdrawNo}}',
'金额:¥{{amount}}',
'明细笔数:{{payoutCount}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'invoice.pending',
title: '发票申请待开票',
body: [
'**发票申请待开票**',
'申请单号:{{invoiceNo}}',
'订单号:{{orderNo}}',
'金额:¥{{payAmount}}',
'抬头:{{titleName}}',
'用户:{{phoneMasked}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
];
export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined {
return WECOM_PUSH_TEMPLATE_DEFAULTS.find((t) => t.eventKey === eventKey);
}
/** 将 body 中的 {{key}} 替换为 vars;缺失置空 */
export function renderWecomTemplate(
body: string,
vars: Record<string, string | number | null | undefined>,
): string {
return body.replace(/\{\{(\w+)\}\}/g, (_m, key: string) => {
const v = vars[key];
if (v == null) return '';
return String(v);
});
}
/**
* HQ
* HQ_ADMIN_PUBLIC_URL WECOM_ALERT_ENV_LABEL / NODE_ENV 退
* http://orders/... 这类无效链接。
*/
export function resolveHqAdminPublicBase(): string {
const configured = (process.env.HQ_ADMIN_PUBLIC_URL || '').trim().replace(/\/$/, '');
if (configured) return configured;
const label = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || '')
.trim()
.toLowerCase();
if (label === 'production' || label === 'prod') {
return 'https://admin.dukanghaoke.com';
}
if (label === 'staging' || label === 'test' || label === 'testing') {
return 'https://admin-test.dukanghaoke.com';
}
// local / development / 未识别:本机 admin-web 默认端口
return 'http://localhost:5175';
}
export function buildHqHandleUrl(pathWithQuery: string): string {
const base = resolveHqAdminPublicBase();
let path = (pathWithQuery || '').trim();
if (!path) return base;
if (!path.startsWith('/')) path = `/${path}`;
return `${base}${path}`;
}
@@ -19,11 +19,13 @@ export class AdminInvoicesController {
@Get()
list(
@Query('status') status?: string,
@Query('invoiceNo') invoiceNo?: string,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.tradeService.adminListInvoices({
status,
invoiceNo,
page: Number(page),
pageSize: Number(pageSize),
});
@@ -25,13 +25,15 @@ export class AdminOrdersService {
private readonly adminRedeemService: AdminRedeemService,
) {}
/** v3.5.1 #1:发布会大屏,仅已付款订单;按付款时间倒序 */
/** v3.5.1 #1:发布会大屏,仅当日已付款订单;按付款时间倒序 */
async listBigScreen(limit?: string) {
const take = Math.min(Math.max(Number(limit) || 2000, 1), 2000);
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const orders = await this.prisma.order.findMany({
where: {
payAmount: { gte: 100 },
payStatus: 'PAID',
paidAt: { gte: startOfToday },
},
orderBy: [{ paidAt: 'desc' }, { createdAt: 'desc' }],
take,
@@ -58,7 +58,16 @@ export class AdminRedeemService {
}),
this.prisma.redeemRecord.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
return serializeBigInt({
items: items.map((row) => ({
...row,
amount: Number(row.amount),
settleAmount: Number(row.settleAmount),
})),
total,
page,
pageSize,
});
}
async detailRecord(id: bigint) {
@@ -0,0 +1,69 @@
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import type { UpdateWecomPushTemplateRequest } from '@dukang/shared-types';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
@Controller('admin/wecom-push-templates')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('wecom_bots')
export class AdminWecomPushTemplatesController {
constructor(private readonly wecomPush: WecomMessagePushService) {}
@Get()
list() {
return this.wecomPush.listTemplates();
}
@Get(':eventKey')
detail(@Param('eventKey') eventKey: string) {
return this.wecomPush.getTemplate(eventKey);
}
@Put(':eventKey')
@HqOperation({
action: HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE,
refType: 'WECOM_PUSH_TEMPLATE',
refIdField: 'eventKey',
includeBody: true,
})
update(@Param('eventKey') eventKey: string, @Body() body: UpdateWecomPushTemplateRequest) {
return this.wecomPush.updateTemplate(eventKey, body);
}
@Post(':eventKey/reset')
@HqOperation({
action: HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE,
refType: 'WECOM_PUSH_TEMPLATE',
refIdField: 'eventKey',
})
reset(@Param('eventKey') eventKey: string) {
return this.wecomPush.resetTemplate(eventKey);
}
@Post(':eventKey/test')
@HqOperation({
action: HqOperationAction.WECOM_MESSAGE_PUSH_TEST,
refType: 'WECOM_PUSH_TEMPLATE',
refIdField: 'eventKey',
})
async test(@Param('eventKey') eventKey: string) {
const result = await this.wecomPush.testTemplate(eventKey);
if (!result.ok) throw new BadRequestException(result.message);
return result;
}
}
@@ -69,6 +69,7 @@ import { AdminWecomBotsController } from './admin-wecom-bots.controller';
import { AdminWecomBotsService } from './admin-wecom-bots.service';
import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller';
import { AdminWecomMessagePushesService } from './admin-wecom-message-pushes.service';
import { AdminWecomPushTemplatesController } from './admin-wecom-push-templates.controller';
import { AdminWecomBotLogsController } from './admin-wecom-bot-logs.controller';
import { AdminWecomBotLogsService } from './admin-wecom-bot-logs.service';
import { AdminLlmConfigsController } from './admin-llm-configs.controller';
@@ -124,6 +125,7 @@ import { AdminTestWhitelistController } from './admin-test-whitelist.controller'
AdminSystemConfigController,
AdminWecomBotsController,
AdminWecomMessagePushesController,
AdminWecomPushTemplatesController,
AdminWecomBotLogsController,
AdminLlmConfigsController,
AdminKnowledgeBasesController,
@@ -9,6 +9,7 @@ import {
generateRedeemNo,
generateRedeemPendingNo,
REDEEM_WEAKNET_FAIL_THRESHOLD,
resolveSettlementRate,
validateRedeemAmount,
allocateBenefitCoupons,
} from '@dukang/domain';
@@ -29,6 +30,7 @@ import { SettlementService } from '../settlement/settlement.service';
import { BenefitService } from '../benefit/benefit.service';
import { AuthService } from '../iam/auth.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
type TokenPayload = {
userId: string;
@@ -81,6 +83,7 @@ export class RedeemService {
private readonly analyticsService: AnalyticsService,
private readonly authService: AuthService,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly wecomPush: WecomMessagePushService,
) {}
private maskPhoneForStore(phone: string) {
@@ -176,8 +179,15 @@ export class RedeemService {
normalizedAllocations: Array<{ couponId: string; amount: number }>,
analyticsExtra?: { channel: 'token' | 'phone'; sessionId?: string; tokenSuffix?: string },
) {
const settlementRate = Number(account.store.settlementRate);
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const amountNum = Number(amount);
if (!Number.isFinite(amountNum) || amountNum <= 0) {
throw new BadRequestException('核销金额必须大于 0');
}
const settlementRate = resolveSettlementRate(account.store.settlementRate);
const settleAmount = calcRedeemSettleAmount(amountNum, settlementRate);
if (!Number.isFinite(settleAmount) || settleAmount < 0) {
throw new BadRequestException('结算金额计算异常');
}
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
const [userRow, storeRow] = await Promise.all([
@@ -200,7 +210,7 @@ export class RedeemService {
userId,
couponId: BigInt(normalizedAllocations[0].couponId),
storeId: account.storeId,
amount,
amount: amountNum,
settleAmount,
channel: redeemChannel,
isTest,
@@ -214,16 +224,14 @@ export class RedeemService {
},
});
if (!isTest) {
await this.settlementService.createStorePayout(
redeemRecord.id,
account.storeId,
amount,
settleAmount,
settlementRate,
tx,
);
}
await this.settlementService.createStorePayout(
redeemRecord.id,
account.storeId,
amountNum,
settleAmount,
settlementRate,
tx,
);
return redeemRecord;
});
@@ -240,7 +248,7 @@ export class RedeemService {
const redeemExtra = {
redeemRecordId: record.id.toString(),
storeId: account.storeId.toString(),
amount,
amount: amountNum,
channel: analyticsExtra?.channel ?? 'token',
...(analyticsExtra?.sessionId ? { sessionId: analyticsExtra.sessionId } : {}),
...(analyticsExtra?.tokenSuffix ? { tokenSuffix: analyticsExtra.tokenSuffix } : {}),
@@ -252,7 +260,7 @@ export class RedeemService {
refId: record.id,
extraJson: {
redeemNo: record.redeemNo,
amount,
amount: amountNum,
userId: userId.toString(),
channel: analyticsExtra?.channel ?? 'token',
},
@@ -270,7 +278,27 @@ export class RedeemService {
extraJson: redeemExtra,
});
return record;
if (!isTest) {
const channelLabel = redeemChannel === 'PHONE' ? '手机号' : '扫码';
void this.wecomPush.dispatchEvent(
'redeem.success',
{
redeemNo: record.redeemNo,
amount: amountNum.toFixed(2),
storeName: account.store.name || String(account.storeId),
channel: channelLabel,
},
{
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
},
);
}
return {
...record,
amount: amountNum,
settleAmount,
};
}
async sendPhoneLookupSms(storeAccountId: bigint, storeId: bigint, phone: string) {
@@ -484,6 +512,12 @@ export class RedeemService {
}
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
const amount = Number(body.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('核销金额必须大于 0');
}
body = { ...body, amount };
let allocations: Array<{ couponId: string; amount: number }>;
if (body.couponId) {
@@ -993,6 +1027,9 @@ export class RedeemService {
await this.validateAllocations(normalizedAllocations);
const amount = Number(pending.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('待处理单核销金额无效');
}
const record = await this.executeRedeem(
account,
pending.userId,
@@ -1101,7 +1138,26 @@ export class RedeemService {
}),
this.prisma.redeemRecord.count({ where: { storeId } }),
]);
return { list: serializeBigInt(list), total, page, pageSize };
return {
list: serializeBigInt(
list.map((r) => ({
...r,
amount: Number(r.amount),
settleAmount: Number(r.settleAmount),
payout: r.payout
? {
...r.payout,
redeemAmount: Number(r.payout.redeemAmount),
payoutAmount: Number(r.payout.payoutAmount),
settlementRate: Number(r.payout.settlementRate),
}
: r.payout,
})),
),
total,
page,
pageSize,
};
}
async getShopRedeemStats(
@@ -1174,7 +1230,11 @@ export class RedeemService {
todayAmount,
todayScanCount,
todayPhoneCount,
recentRecords: recent,
recentRecords: recent.map((r) => ({
...r,
amount: Number(r.amount),
settleAmount: Number(r.settleAmount),
})),
});
}
@@ -8,7 +8,9 @@ import {
} from '@dukang/shared-types';
import {
calcLogisticsFeeByBottles,
calcRedeemSettleAmount,
pickPayoutsForWithdrawAmount,
resolveSettlementRate,
sumUnbilledPayoutAmount,
validateStoreWithdraw,
type LogisticsPricingRule,
@@ -16,6 +18,7 @@ import {
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
@@ -84,6 +87,7 @@ export class SettlementService {
private readonly partnerCityService: PartnerCityService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
private readonly alert: AlertService,
private readonly wecomPush: WecomMessagePushService,
) {}
// ─── Store payout (line) ─────────────────────────────
@@ -96,6 +100,12 @@ export class SettlementService {
settlementRate: number,
tx?: Prisma.TransactionClient,
) {
const redeemAmountNum = Number(redeemAmount);
const payoutAmountNum = Number(payoutAmount);
const settlementRateNum = resolveSettlementRate(settlementRate);
if (!(redeemAmountNum > 0) || !(payoutAmountNum > 0)) {
throw new BadRequestException('结算金额无效,无法入账');
}
const expectedPayAt = new Date();
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
const client = tx ?? this.prisma;
@@ -103,9 +113,9 @@ export class SettlementService {
data: {
redeemRecordId,
storeId,
redeemAmount,
payoutAmount,
settlementRate,
redeemAmount: redeemAmountNum,
payoutAmount: payoutAmountNum,
settlementRate: settlementRateNum,
status: 'PENDING',
expectedPayAt,
},
@@ -152,17 +162,65 @@ export class SettlementService {
}
private async listAvailableUnbilledPayouts(storeId: bigint) {
await this.backfillMissingStorePayouts(storeId);
return this.prisma.storePayout.findMany({
where: {
storeId,
status: 'PENDING',
storeBillId: null,
withdrawItem: null,
withdrawItem: { is: null },
},
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
}
/**
* store_payout
* 线 0
*/
async backfillMissingStorePayouts(storeId: bigint) {
const store = await this.prisma.store.findUnique({
where: { id: storeId },
select: { settlementRate: true },
});
if (!store) return 0;
const orphans = await this.prisma.redeemRecord.findMany({
where: {
storeId,
payout: { is: null },
},
select: { id: true, amount: true, settleAmount: true },
take: 200,
});
if (!orphans.length) return 0;
const settlementRate = resolveSettlementRate(store.settlementRate);
let created = 0;
for (const row of orphans) {
const redeemAmount = Number(row.amount);
let payoutAmount = Number(row.settleAmount);
if (!(redeemAmount > 0)) continue;
if (!(payoutAmount > 0)) {
payoutAmount = calcRedeemSettleAmount(redeemAmount, settlementRate);
}
if (!(payoutAmount > 0)) continue;
try {
await this.createStorePayout(
row.id,
storeId,
redeemAmount,
payoutAmount,
settlementRate,
);
created += 1;
} catch {
// 并发补写时可能已存在 payout
}
}
return created;
}
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
const start = startOfDay(now);
const end = new Date(start);
@@ -291,7 +349,7 @@ export class SettlementService {
storeId,
status: 'PENDING',
storeBillId: null,
withdrawItem: null,
withdrawItem: { is: null },
},
select: { id: true, payoutAmount: true },
});
@@ -330,20 +388,19 @@ export class SettlementService {
},
});
this.alert.notify({
level: 'P1',
category: 'finance',
title: '门店提现待审',
detail: [
`门店:${store.name}${store.cityName || '-'} / ${store.phone || '-'}`,
`单号:${created.withdrawNo}`,
`金额:¥${Number(created.amount).toFixed(2)}`,
`明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`,
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
].join('\n'),
dedupeKey: `store_withdraw_applied|${created.id.toString()}`,
dedupeTtlSec: 3600,
});
void this.wecomPush.dispatchEvent(
'store.withdraw_pending',
{
storeName: store.name,
withdrawNo: created.withdrawNo,
amount: Number(created.amount).toFixed(2),
payoutCount: String(created.payoutCount),
storeId: storeId.toString(),
},
{
handlePath: `/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=${storeId.toString()}`,
},
);
return serializeBigInt(created);
}
@@ -782,7 +839,7 @@ export class SettlementService {
storeBillId: null,
createdAt: { gte: start, lt: end },
// 排除已锁定在待审提现单中的明细,避免出账与提现双占
withdrawItem: null,
withdrawItem: { is: null },
status: 'PENDING',
},
include: { store: { select: { id: true, settlementRate: true } } },
@@ -1322,12 +1379,9 @@ export class SettlementService {
cityId: primary.cityId,
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
});
const orderCommission = primary.isTest
? 0
: orders.reduce((sum, o) => {
const orderCommission = orders.reduce((sum, o) => {
if (o.partnerAccountIdAtPay) {
if (o.partnerAccountIdAtPay !== primary.id) return sum;
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
@@ -1341,14 +1395,12 @@ export class SettlementService {
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
const redeems =
primary.isTest || !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
const redeems = !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
});
const redeemCommission = redeems.reduce(
@@ -1593,7 +1645,6 @@ export class SettlementService {
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end },
isTest: false,
},
orderBy: { completedAt: 'asc' },
});
@@ -1944,7 +1995,6 @@ export class SettlementService {
quantity: true,
deliveryType: true,
payStatus: true,
isTest: true,
},
},
},
@@ -1953,7 +2003,6 @@ export class SettlementService {
const eligible = deliveries.filter(
(d) =>
!d.order.isTest &&
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
@@ -10,6 +10,7 @@ import {
} from '@dukang/domain';
import {
STORE_INFO_CHANGEABLE_FIELDS,
formatStoreInfoChangeFieldLabels,
type StoreInfoChangeFieldDiff,
type StoreInfoChangeRequestDto,
type StoreInfoChangeStatus,
@@ -19,9 +20,18 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
const MIN_ENV_PHOTOS = 3;
const MAX_ENV_PHOTOS = 20;
/** Store 表标量字段(不含媒体) */
const STORE_SCALAR_CHANGE_FIELDS = STORE_INFO_CHANGEABLE_FIELDS.filter(
(f) => f !== 'coverUrl' && f !== 'envPhotoUrls',
) as ChangeableField[];
function normalizeOptionalTextField(value: unknown): string | null {
if (value == null) return null;
const s = String(value).trim();
@@ -45,6 +55,27 @@ function coerceNumberOrNull(value: unknown): number | null {
return n;
}
function normalizeEnvPhotoUrls(raw: unknown): string[] {
if (!Array.isArray(raw)) {
throw new BadRequestException('环境照片须为 URL 数组');
}
const seen = new Set<string>();
const urls: string[] = [];
for (const item of raw) {
const u = String(item ?? '').trim();
if (!u || seen.has(u)) continue;
seen.add(u);
urls.push(u);
}
if (urls.length < MIN_ENV_PHOTOS) {
throw new BadRequestException(`请上传至少 ${MIN_ENV_PHOTOS} 张环境照片`);
}
if (urls.length > MAX_ENV_PHOTOS) {
throw new BadRequestException(`环境照片最多 ${MAX_ENV_PHOTOS}`);
}
return urls;
}
/** 将白名单字段从提交 body 规整为可落库的 proposedSnapshot */
function buildProposedSnapshot(fields: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
@@ -67,6 +98,15 @@ function buildProposedSnapshot(fields: Record<string, unknown>): Record<string,
case 'benefitUsageRule':
out[field] = normalizeOptionalTextField(raw);
break;
case 'coverUrl': {
const url = String(raw ?? '').trim();
if (!url) throw new BadRequestException('请上传门头照');
out[field] = url;
break;
}
case 'envPhotoUrls':
out[field] = normalizeEnvPhotoUrls(raw);
break;
default:
out[field] = raw == null ? null : String(raw);
}
@@ -74,18 +114,13 @@ function buildProposedSnapshot(fields: Record<string, unknown>): Record<string,
return out;
}
/** 取 live store 上白名单字段的当前值(用于快照与 diff) */
function pickLiveFields(store: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
const v = store[field];
out[field] = v == null ? null : v;
}
return out;
}
function looseEqual(a: unknown, b: unknown): boolean {
if (a == null && b == null) return true;
if (Array.isArray(a) || Array.isArray(b)) {
const aa = Array.isArray(a) ? a.map((x) => String(x ?? '').trim()).filter(Boolean) : [];
const bb = Array.isArray(b) ? b.map((x) => String(x ?? '').trim()).filter(Boolean) : [];
return aa.length === bb.length && aa.every((x, i) => x === bb[i]);
}
return String(a) === String(b);
}
@@ -109,47 +144,153 @@ export class StoreInfoChangeService {
private readonly prisma: PrismaService,
private readonly storeService: StoreService,
private readonly partnerCityService: PartnerCityService,
private readonly wecomPush: WecomMessagePushService,
) {}
/** 合伙人 / 门店端 提交基础信息变更 */
private async loadLiveMediaFields(storeId: bigint, coverResourceId: bigint | null) {
let coverUrl: string | null = null;
if (coverResourceId) {
const cover = await this.prisma.commonResource.findUnique({
where: { id: coverResourceId },
select: { url: true, status: true },
});
if (cover?.status === 'ACTIVE' && cover.url) coverUrl = cover.url;
}
const envs = await this.prisma.commonResource.findMany({
where: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'ENV',
status: 'ACTIVE',
},
orderBy: { sortOrder: 'asc' },
select: { url: true },
});
return {
coverUrl,
envPhotoUrls: envs.map((e) => e.url).filter(Boolean),
};
}
private pickLiveScalarFields(store: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of STORE_SCALAR_CHANGE_FIELDS) {
const v = store[field];
out[field] = v == null ? null : v;
}
return out;
}
/** 审核通过后写入门头照 / 环境图 */
private async applyApprovedMedia(storeId: bigint, proposed: Record<string, unknown>) {
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
const store = await this.prisma.store.findUnique({
where: { id: storeId },
select: { coverResourceId: true },
});
if (!store) throw new NotFoundException('门店不存在');
if ('coverUrl' in proposed) {
const coverUrl = String(proposed.coverUrl ?? '').trim();
if (!coverUrl) throw new BadRequestException('门头照不能为空');
if (store.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: store.coverResourceId },
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket,
ossKey: coverUrl,
url: coverUrl,
},
});
await this.prisma.store.update({
where: { id: storeId },
data: { coverResourceId: cover.id },
});
}
}
if ('envPhotoUrls' in proposed) {
const urls = normalizeEnvPhotoUrls(proposed.envPhotoUrls);
await this.prisma.commonResource.updateMany({
where: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'ENV',
status: 'ACTIVE',
},
data: { status: 'DELETED' },
});
for (let i = 0; i < urls.length; i++) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket,
ossKey: urls[i],
url: urls[i],
sortOrder: i,
},
});
}
}
}
/** 合伙人 / 门店端 提交基础信息变更(含门头照/环境图) */
async submitChange(input: {
submitterType: StoreInfoChangeSubmitterType;
submitterId: bigint;
storeId: bigint;
fields: Record<string, unknown>;
}): Promise<StoreInfoChangeRequestDto> {
// 1) 校验归属
let store: Record<string, unknown>;
let store: Record<string, unknown> & { coverResourceId?: bigint | null };
if (input.submitterType === 'PARTNER') {
const primary = await this.partnerCityService.resolvePrimaryAccount(input.submitterId);
const found = await this.prisma.store.findFirst({
where: { id: input.storeId, partnerAccountId: primary.id },
});
if (!found) throw new NotFoundException('门店不存在或无权操作');
store = found as unknown as Record<string, unknown>;
store = found as unknown as Record<string, unknown> & { coverResourceId?: bigint | null };
} else {
// SHOP / HQ_DIRECT_ADMIN:先校验门店绑定/存在
if (input.submitterType === 'SHOP') {
await this.storeService.getShopStore(input.submitterId, input.storeId);
}
const found = await this.prisma.store.findUnique({ where: { id: input.storeId } });
if (!found) throw new NotFoundException('门店不存在');
store = found as unknown as Record<string, unknown>;
store = found as unknown as Record<string, unknown> & { coverResourceId?: bigint | null };
}
if (store.status === 'CLOSED') {
throw new BadRequestException('门店已关闭,不可提交变更');
}
if (String(store.auditStatus || '') === 'PENDING') {
throw new BadRequestException('门店审核中,暂不可提交变更');
}
// 2) 规整 proposed + diff
const proposed = buildProposedSnapshot(input.fields);
const live = pickLiveFields(store);
const media = await this.loadLiveMediaFields(
input.storeId,
store.coverResourceId != null ? BigInt(store.coverResourceId as never) : null,
);
const live: Record<string, unknown> = {
...this.pickLiveScalarFields(store),
coverUrl: media.coverUrl,
envPhotoUrls: media.envPhotoUrls,
};
const changedFields = computeChangedFields(live, proposed);
if (changedFields.length === 0) {
throw new BadRequestException('没有检测到需要变更的字段');
}
// 3) 基础校验
if (proposed.name != null && !String(proposed.name).trim()) {
throw new BadRequestException('请填写门店名称');
}
@@ -172,7 +313,6 @@ export class StoreInfoChangeService {
throw new BadRequestException('经纬度须同时提供');
}
// 4) 同门店已有 PENDING 则替换(最新优先)
await this.prisma.storeInfoChangeRequest.deleteMany({
where: { storeId: input.storeId, status: 'PENDING' },
});
@@ -192,10 +332,25 @@ export class StoreInfoChangeService {
this.logger.log(
`Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`,
);
const submitterLabel = input.submitterType === 'PARTNER' ? '合伙人' : '门店';
void this.wecomPush.dispatchEvent(
'store.info_change_pending',
{
storeName: String(store.name ?? input.storeId),
cityName: String(store.cityName ?? '—'),
submitter: submitterLabel,
changedFields: formatStoreInfoChangeFieldLabels(changedFields),
requestId: created.id.toString(),
},
{
handlePath: `/store-package-audits?tab=info&infoRequestId=${created.id.toString()}`,
},
);
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
}
/** 合伙人端查看本门店历史变更 */
async listPartnerRequests(
storeId: bigint,
partnerAccountId: bigint,
@@ -214,7 +369,6 @@ export class StoreInfoChangeService {
return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record<string, unknown>)));
}
/** 总部列表 */
async adminList(opts: {
status?: StoreInfoChangeStatus;
page?: number;
@@ -241,7 +395,6 @@ export class StoreInfoChangeService {
return { items, total, page, pageSize };
}
/** 总部待审总数(与套餐审核汇总,用于统一 badge) */
async adminSummary(): Promise<{ pendingCount: number; packagePendingCount: number }> {
const [infoPending, packagePending] = await Promise.all([
this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING' } }),
@@ -250,7 +403,6 @@ export class StoreInfoChangeService {
return { pendingCount: infoPending, packagePendingCount: packagePending };
}
/** 总部详情(含字段级 diff) */
async adminDetail(id: bigint): Promise<StoreInfoChangeRequestDto> {
const row = await this.prisma.storeInfoChangeRequest.findUnique({
where: { id },
@@ -272,7 +424,6 @@ export class StoreInfoChangeService {
return { ...dto, diffs };
}
/** 总部审核通过/驳回 */
async audit(input: {
id: bigint;
action: 'APPROVE' | 'REJECT';
@@ -299,15 +450,19 @@ export class StoreInfoChangeService {
return serializeBigInt(this.toDto(updated as unknown as Record<string, unknown>));
}
// APPROVE:将 proposedSnapshot 写入 Store(白名单内)
const proposed = (row as { proposedSnapshot?: Record<string, unknown> }).proposedSnapshot || {};
const data: Record<string, unknown> = {};
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
for (const field of STORE_SCALAR_CHANGE_FIELDS) {
if (!(field in proposed)) continue;
const v = proposed[field];
data[field] = v == null ? null : v;
}
await this.prisma.store.update({ where: { id: row.storeId }, data: data as never });
if (Object.keys(data).length) {
await this.prisma.store.update({ where: { id: row.storeId }, data: data as never });
}
if ('coverUrl' in proposed || 'envPhotoUrls' in proposed) {
await this.applyApprovedMedia(row.storeId, proposed);
}
const updated = await this.prisma.storeInfoChangeRequest.update({
where: { id: input.id },
@@ -1,6 +1,7 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -13,14 +14,18 @@ import {
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
type PackageInput = Record<string, unknown>;
@Injectable()
export class StorePackageService {
private readonly logger = new Logger(StorePackageService.name);
constructor(
private readonly prisma: PrismaService,
private readonly storeService: StoreService,
private readonly wecomPush: WecomMessagePushService,
) {}
normalizePackages(raw: unknown): StorePackageItemDto[] {
@@ -181,6 +186,24 @@ export class StorePackageService {
submitterId,
},
});
const store = await this.prisma.store.findUnique({
where: { id: storeId },
select: { name: true, cityName: true },
});
const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店';
void this.wecomPush.dispatchEvent(
'store.package_audit_pending',
{
storeName: store?.name ?? String(storeId),
cityName: store?.cityName || '—',
submitter: submitterLabel,
packageCount: String(packages.length),
requestId: req.id.toString(),
},
{ handlePath: `/store-package-audits?requestId=${req.id.toString()}` },
);
return serializeBigInt({
id: req.id.toString(),
status: req.status,
@@ -2,6 +2,7 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
@@ -22,6 +23,7 @@ import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
@@ -61,6 +63,7 @@ function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number
@Injectable()
export class StoreService {
private readonly config = loadAppConfig();
private readonly logger = new Logger(StoreService.name);
constructor(
private readonly prisma: PrismaService,
@@ -70,8 +73,32 @@ export class StoreService {
private readonly storeCategoryService: StoreCategoryService,
private readonly tencentLbs: TencentLbsProvider,
private readonly testWhitelist: TestWhitelistService,
private readonly wecomPush: WecomMessagePushService,
) {}
/** 门店进入 PENDING 时通知企微(失败不挡业务) */
private notifyStoreAuditPending(opts: {
storeId: bigint;
storeName: string;
cityName?: string | null;
partnerLabel?: string | null;
submitType: '新建' | '重提';
}) {
void this.wecomPush.dispatchEvent(
'store.audit_pending',
{
storeName: opts.storeName,
cityName: opts.cityName || '—',
partnerLabel: opts.partnerLabel || '—',
action: opts.submitType,
storeId: opts.storeId.toString(),
},
{
handlePath: `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`,
},
);
}
private async whitelistPhoneSet(): Promise<Set<string>> {
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
select: { phone: true },
@@ -559,6 +586,20 @@ export class StoreService {
extraJson: { storeName: store.name, phone: normalizedPhone },
});
if (!this.config.autoApproveStore) {
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: primaryId },
select: { name: true, phone: true },
});
this.notifyStoreAuditPending({
storeId: store.id,
storeName: store.name,
cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
submitType: '新建',
});
}
return serializeBigInt({
store: mapStoreCompat({
...store,
@@ -716,6 +757,17 @@ export class StoreService {
remark: '合伙人修改资料后重新提交审核',
},
});
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: primaryId },
select: { name: true, phone: true },
});
this.notifyStoreAuditPending({
storeId: updated.id,
storeName: updated.name,
cityName: updated.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
submitType: '重提',
});
}
return this.partnerGetStore(partnerAccountId, storeId);
@@ -739,6 +791,11 @@ export class StoreService {
if (store.auditStatus === 'PENDING') {
throw new BadRequestException('门店审核中,暂不可修改资料');
}
if (store.auditStatus === 'APPROVED') {
throw new BadRequestException(
'已营业门店修改门头照/环境图须通过「提交变更」由总部审核通过后生效',
);
}
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
const hasEnv = body.envPhotoUrls !== undefined;
@@ -825,6 +882,17 @@ export class StoreService {
remark: '合伙人重新上传资料后重新提交审核',
},
});
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: primaryId },
select: { name: true, phone: true },
});
this.notifyStoreAuditPending({
storeId: store.id,
storeName: store.name,
cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
submitType: '重提',
});
}
return this.partnerGetStore(partnerAccountId, storeId);
@@ -35,6 +35,7 @@ import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
import { AlertService } from '../../common/alert/alert.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import type { Request } from 'express';
@Injectable()
@@ -56,6 +57,7 @@ export class TradeService {
private readonly wechatOrderShipping: WechatOrderShippingService,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly alert: AlertService,
private readonly wecomPush: WecomMessagePushService,
) {}
private readonly logger = new Logger(TradeService.name);
@@ -429,9 +431,33 @@ export class TradeService {
private async afterOrderPaid(orderId: bigint) {
await this.benefitService.grantOnOrderPaid(orderId);
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: {
city: { select: { name: true } },
user: { select: { phone: true } },
},
});
if (!order) return;
if (!order.isTest) {
const skuSummary = `${order.productName}×${order.quantity}`;
const phone = order.user?.phone || '';
const phoneMasked =
phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—';
void this.wecomPush.dispatchEvent(
'order.paid',
{
orderNo: order.orderNo,
payAmount: Number(order.payAmount).toFixed(2),
cityName: order.city?.name || '—',
skuSummary,
phoneMasked,
},
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },
);
}
const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
if (!delivery) {
await this.prisma.orderDelivery.create({
@@ -1090,6 +1116,26 @@ export class TradeService {
remark: body.remark?.trim() || null,
},
});
if (!order.isTest) {
const phone = resolved.phone.trim();
const phoneMasked =
phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—';
void this.wecomPush.dispatchEvent(
'invoice.pending',
{
invoiceNo: invoice.invoiceNo,
orderNo: order.orderNo,
payAmount: Number(order.payAmount).toFixed(2),
titleName: invoice.titleName,
phoneMasked,
},
{
handlePath: `/invoices?status=PENDING&invoiceNo=${encodeURIComponent(invoice.invoiceNo)}`,
},
);
}
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
}
@@ -1177,11 +1223,17 @@ export class TradeService {
return this.createInvoice(order.userId, order.id, body);
}
async adminListInvoices(query: { status?: string; page?: number; pageSize?: number }) {
async adminListInvoices(query: {
status?: string;
invoiceNo?: string;
page?: number;
pageSize?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: { status?: never } = {};
const where: { status?: never; invoiceNo?: { contains: string } } = {};
if (query.status) where.status = query.status as never;
if (query.invoiceNo?.trim()) where.invoiceNo = { contains: query.invoiceNo.trim() };
const [items, total] = await Promise.all([
this.prisma.userInvoice.findMany({
where,