v3.5.3版本更新1
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-08-20 18:54:15 +08:00
parent ee493823bf
commit fc2e5b65de
65 changed files with 2297 additions and 875 deletions
@@ -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();
}
@@ -20,20 +20,15 @@ import type {
StoreInfoChangeFieldDiff,
StoreInfoChangeRequestDto,
StoreInfoChangeStatus,
StorePackageAuditDetailDto,
StorePackageAuditSummaryDto,
StorePackageChangeRequestDto,
StorePackageChangeStatus,
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types';
import {
STORE_INFO_CHANGE_STATUS_LABELS,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
import { STORE_INFO_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
PENDING: '待审核',
@@ -41,270 +36,6 @@ 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: '联系电话',
@@ -318,6 +49,8 @@ const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
openTime2: '第二段开始',
closeTime2: '第二段结束',
avgPrice: '人均费用',
coverUrl: '门头照',
envPhotoUrls: '环境照片',
};
function fmtFieldValue(field: string, v: unknown): string {
@@ -325,9 +58,73 @@ function fmtFieldValue(field: string, v: unknown): string {
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
return String(v);
}
if (field === 'envPhotoUrls' && Array.isArray(v)) {
return `${v.length}`;
}
if (field === 'coverUrl') return '见对比图';
return String(v);
}
function InfoChangeImageDiff({
field,
live,
proposed,
}: {
field: string;
live: unknown;
proposed: unknown;
}) {
const liveUrls =
field === 'coverUrl'
? [String(live || '').trim()].filter(Boolean)
: Array.isArray(live)
? live.map((u) => String(u || '').trim()).filter(Boolean)
: [];
const proposedUrls =
field === 'coverUrl'
? [String(proposed || '').trim()].filter(Boolean)
: Array.isArray(proposed)
? proposed.map((u) => String(u || '').trim()).filter(Boolean)
: [];
return (
<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,
}: {
@@ -544,15 +341,19 @@ function InfoChangeAuditPanel({
key={d.field}
label={INFO_CHANGE_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 +400,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 +430,23 @@ export default function StorePackageAuditsPage() {
void reload(1, status);
}, [status]);
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
const [searchParams] = useSearchParams();
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
const [activeTab, setActiveTab] = useState<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 +466,6 @@ export default function StorePackageAuditsPage() {
}
}
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
const addedCount = diffRows.filter((r) => r.change === 'added').length;
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
const columns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
@@ -702,7 +488,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 +567,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 +611,4 @@ export default function StorePackageAuditsPage() {
</div>
);
}
+123 -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: '基本信息' },
@@ -270,7 +274,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);
@@ -348,13 +356,18 @@ export default function StoresPage() {
.map((n) => ({ value: n.id, label: n.name }));
}, [categoryTree, editCategoryParentId]);
async function openStoreDetail(row: StoreRow) {
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
const [d, cats] = await Promise.all([
request<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;
@@ -776,22 +789,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 +907,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 +944,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 +993,8 @@ export default function StoresPage() {
{detail && (
<Form form={editForm} layout="vertical">
<Tabs
activeKey={detailTab}
onChange={setDetailTab}
destroyInactiveTabPane={false}
items={[
{
@@ -1202,15 +1243,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() {
@@ -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: 28 KiB

@@ -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;