Compare commits
14 Commits
11659484c6
...
v3.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a061f085f | |||
| b20e566a65 | |||
| 4d434c9c67 | |||
| 62e61a9e63 | |||
| a01217539c | |||
| 26334ed072 | |||
| fc2e5b65de | |||
| ee493823bf | |||
| 8b5bb6a981 | |||
| 0a36188b35 | |||
| 6cf66c722c | |||
| 4db21cf1a1 | |||
| 2f633b307c | |||
| 62353a6800 |
@@ -291,26 +291,21 @@ body:has(.big-screen-page),
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.big-screen-list-head,
|
||||
.big-screen-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 160px 200px;
|
||||
grid-template-columns: minmax(180px, 18%) minmax(0, 1fr) minmax(140px, 16%) minmax(160px, 18%);
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.big-screen-list-head {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 28px 10px 40px;
|
||||
font-size: 15px;
|
||||
color: rgba(170, 200, 230, 0.55);
|
||||
letter-spacing: 2px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.big-screen-list-body {
|
||||
@@ -434,6 +429,13 @@ body:has(.big-screen-page),
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.big-screen-amount--6d {
|
||||
font-size: 0.86em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.big-screen-items {
|
||||
@@ -450,10 +452,20 @@ body:has(.big-screen-page),
|
||||
}
|
||||
|
||||
.big-screen-empty {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* 全屏高度黄金分割点(≈0.382),略偏上 */
|
||||
top: 38.2%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
padding: 48px 20px;
|
||||
font-size: 18px;
|
||||
color: rgba(145, 190, 230, 0.42);
|
||||
padding: 0 40px;
|
||||
font-size: 60px;
|
||||
letter-spacing: 6px;
|
||||
font-weight: 400;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.big-screen-fx {
|
||||
@@ -540,13 +552,14 @@ body:has(.big-screen-page),
|
||||
.big-screen-fx-card {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-width: 420px;
|
||||
max-width: 72vw;
|
||||
min-width: min(420px, 92vw);
|
||||
max-width: min(920px, 92vw);
|
||||
padding: 28px 40px 32px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
background: rgba(8, 22, 48, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t1 {
|
||||
@@ -562,7 +575,7 @@ body:has(.big-screen-page),
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t3 {
|
||||
min-width: 520px;
|
||||
min-width: min(560px, 94vw);
|
||||
padding: 36px 48px 40px;
|
||||
border: 3px solid #ffd666;
|
||||
box-shadow:
|
||||
@@ -623,12 +636,19 @@ body:has(.big-screen-page),
|
||||
}
|
||||
|
||||
.big-screen-fx-amount {
|
||||
font-size: 64px;
|
||||
font-size: clamp(44px, 8.5vw, 64px);
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 2px;
|
||||
line-height: 1.1;
|
||||
color: #e6f7ff;
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.big-screen-fx-amount--6d {
|
||||
font-size: clamp(36px, 7vw, 52px) !important;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t1 .big-screen-fx-amount {
|
||||
@@ -643,7 +663,7 @@ body:has(.big-screen-page),
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t3 .big-screen-fx-amount {
|
||||
font-size: 84px;
|
||||
font-size: clamp(52px, 10vw, 84px);
|
||||
color: #ffd666;
|
||||
text-shadow:
|
||||
0 0 12px #ffd666,
|
||||
@@ -651,6 +671,11 @@ body:has(.big-screen-page),
|
||||
animation: big-screen-amount-flash 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
|
||||
font-size: clamp(44px, 8.5vw, 68px) !important;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@keyframes big-screen-amount-pop {
|
||||
0% {
|
||||
transform: scale(0.7);
|
||||
@@ -693,16 +718,24 @@ body:has(.big-screen-page),
|
||||
.big-screen-clock {
|
||||
font-size: 56px;
|
||||
}
|
||||
.big-screen-list-head,
|
||||
.big-screen-row {
|
||||
grid-template-columns: 140px 1fr 120px 160px;
|
||||
grid-template-columns: minmax(140px, 20%) minmax(0, 1fr) minmax(110px, 16%) minmax(130px, 18%);
|
||||
font-size: 16px;
|
||||
}
|
||||
.big-screen-amount--6d {
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.big-screen-fx-amount {
|
||||
font-size: 44px;
|
||||
font-size: clamp(36px, 8vw, 44px);
|
||||
}
|
||||
.big-screen-fx-card--t3 .big-screen-fx-amount {
|
||||
font-size: 56px;
|
||||
font-size: clamp(40px, 9vw, 56px);
|
||||
}
|
||||
.big-screen-fx-amount--6d {
|
||||
font-size: clamp(32px, 6.5vw, 40px) !important;
|
||||
}
|
||||
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
|
||||
font-size: clamp(34px, 7vw, 48px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,616 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { request } from '../lib/api';
|
||||
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
|
||||
import OssUpload from './OssUpload';
|
||||
|
||||
type SpecValue = { id?: string; name: string; sortOrder?: number };
|
||||
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
|
||||
type SkuRow = {
|
||||
id?: string;
|
||||
specValueIds: string[];
|
||||
skuCode?: string;
|
||||
barcode69: string;
|
||||
price: number;
|
||||
benefitAmount?: number;
|
||||
status: string;
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
allowOnSitePickup: boolean;
|
||||
saleUnit: 'BOTTLE' | 'BOX';
|
||||
bottlesPerUnit: number;
|
||||
isDefault: boolean;
|
||||
sortOrder?: number;
|
||||
specText?: string;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
function cartesian(attrs: SpecAttr[]): string[][] {
|
||||
if (!attrs.length) return [[]];
|
||||
return attrs.reduce<string[][]>(
|
||||
(acc, attr) => {
|
||||
const next: string[][] = [];
|
||||
const values = attr.values.filter((v) => v.name?.trim());
|
||||
for (const prev of acc) {
|
||||
for (const v of values) {
|
||||
const vid = v.id || `__new__:${attr.name}:${v.name}`;
|
||||
next.push([...prev, vid]);
|
||||
}
|
||||
}
|
||||
return next.length ? next : acc;
|
||||
},
|
||||
[[]],
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(ids: string[]) {
|
||||
return [...ids].sort().join('_');
|
||||
}
|
||||
|
||||
type Props = {
|
||||
productId: string;
|
||||
initialAttrs: SpecAttr[];
|
||||
initialSkus: SkuRow[];
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
||||
initialAttrs.length
|
||||
? initialAttrs
|
||||
: [],
|
||||
);
|
||||
const [skus, setSkus] = useState<SkuRow[]>(
|
||||
initialSkus.length
|
||||
? initialSkus.map((s) => ({
|
||||
...s,
|
||||
specValueIds: s.specValueIds ?? [],
|
||||
status: s.status || 'ON_SALE',
|
||||
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
||||
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
||||
allowOnSitePickup: !!s.allowOnSitePickup,
|
||||
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||
isDefault: !!s.isDefault,
|
||||
imageUrl: s.imageUrl ?? '',
|
||||
}))
|
||||
: [
|
||||
{
|
||||
specValueIds: [],
|
||||
barcode69: '',
|
||||
price: 0,
|
||||
status: 'ON_SALE',
|
||||
allowOnlinePurchase: true,
|
||||
allowCrossCityDelivery: true,
|
||||
allowOnSitePickup: false,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: true,
|
||||
imageUrl: '',
|
||||
},
|
||||
],
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||
|
||||
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
||||
|
||||
function regenerateMatrix() {
|
||||
const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s]));
|
||||
const next: SkuRow[] = combos.map((ids, i) => {
|
||||
const existing = byKey.get(keyOf(ids));
|
||||
if (existing) return { ...existing, specValueIds: ids, sortOrder: i };
|
||||
return {
|
||||
specValueIds: ids,
|
||||
barcode69: '',
|
||||
price: skus[0]?.price ?? 0,
|
||||
benefitAmount: skus[0]?.benefitAmount,
|
||||
status: 'DRAFT',
|
||||
allowOnlinePurchase: true,
|
||||
allowCrossCityDelivery: true,
|
||||
allowOnSitePickup: false,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: i === 0,
|
||||
sortOrder: i,
|
||||
imageUrl: '',
|
||||
};
|
||||
});
|
||||
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
|
||||
setSkus(next.length ? next : skus);
|
||||
}
|
||||
|
||||
function patchSku(index: number, patch: Partial<SkuRow>) {
|
||||
setSkus((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], ...patch };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
// 1) 保存规格轴(新建值尚无 id,先提交 attrs)
|
||||
const specsRes = await request<{
|
||||
specAttrs: SpecAttr[];
|
||||
skus: SkuRow[];
|
||||
}>(`/admin/products/${productId}/specs`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
attrs: attrs.map((a, i) => ({
|
||||
id: a.id,
|
||||
name: a.name.trim(),
|
||||
sortOrder: i,
|
||||
values: a.values
|
||||
.filter((v) => v.name.trim())
|
||||
.map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
// 映射临时 id → 真实 id
|
||||
const nameToId = new Map<string, string>();
|
||||
for (const a of specsRes.specAttrs ?? []) {
|
||||
for (const v of a.values ?? []) {
|
||||
nameToId.set(`${a.name}:${v.name}`, v.id!);
|
||||
}
|
||||
}
|
||||
const resolveIds = (ids: string[]) =>
|
||||
ids.map((id) => {
|
||||
if (!id.startsWith('__new__:')) return id;
|
||||
const [, attrName, valueName] = id.split(':');
|
||||
const real = nameToId.get(`${attrName}:${valueName}`);
|
||||
if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`);
|
||||
return real;
|
||||
});
|
||||
|
||||
const payloadSkus = skus.map((s, i) => ({
|
||||
id: s.id,
|
||||
specValueIds: resolveIds(s.specValueIds ?? []),
|
||||
barcode69: s.barcode69.trim(),
|
||||
price: s.price,
|
||||
benefitAmount: s.benefitAmount,
|
||||
status: s.status,
|
||||
allowOnlinePurchase: s.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: s.allowCrossCityDelivery,
|
||||
allowOnSitePickup: s.allowOnSitePickup,
|
||||
saleUnit: s.saleUnit,
|
||||
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
|
||||
isDefault: !!s.isDefault,
|
||||
sortOrder: i,
|
||||
imageUrl: s.imageUrl?.trim() || null,
|
||||
}));
|
||||
|
||||
const barcodes = payloadSkus.map((row) => row.barcode69);
|
||||
if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码');
|
||||
if (new Set(barcodes).size !== barcodes.length) {
|
||||
throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码');
|
||||
}
|
||||
|
||||
await request(`/admin/products/${productId}/skus`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ skus: payloadSkus }),
|
||||
});
|
||||
message.success('规格与 SKU 已保存');
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const editing = editIndex != null ? skus[editIndex] : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary">
|
||||
SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写<strong>互不相同</strong>的 69 码,并可单独上传主图。
|
||||
点「填写」在弹窗中编辑。先配置销售规格轴(如「包装」),再生成 SKU 矩阵。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Typography.Title level={5}>规格轴</Typography.Title>
|
||||
{attrs.map((attr, ai) => (
|
||||
<div key={ai} style={{ marginBottom: 12, padding: 12, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||
<Space align="start" style={{ width: '100%' }} wrap>
|
||||
<Input
|
||||
style={{ width: 140 }}
|
||||
placeholder="规格名"
|
||||
value={attr.name}
|
||||
onChange={(e) => {
|
||||
const next = [...attrs];
|
||||
next[ai] = { ...attr, name: e.target.value };
|
||||
setAttrs(next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<MinusCircleOutlined />}
|
||||
onClick={() => setAttrs(attrs.filter((_, i) => i !== ai))}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{attr.values.map((val, vi) => (
|
||||
<Space key={vi} style={{ display: 'flex', marginBottom: 6 }}>
|
||||
<Input
|
||||
placeholder="规格值"
|
||||
value={val.name}
|
||||
onChange={(e) => {
|
||||
const next = [...attrs];
|
||||
const values = [...attr.values];
|
||||
values[vi] = { ...val, name: e.target.value };
|
||||
next[ai] = { ...attr, values };
|
||||
setAttrs(next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<MinusCircleOutlined />}
|
||||
onClick={() => {
|
||||
const next = [...attrs];
|
||||
next[ai] = { ...attr, values: attr.values.filter((_, j) => j !== vi) };
|
||||
setAttrs(next);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
const next = [...attrs];
|
||||
next[ai] = { ...attr, values: [...attr.values, { name: '' }] };
|
||||
setAttrs(next);
|
||||
}}
|
||||
>
|
||||
添加规格值
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
disabled={attrs.length >= 3}
|
||||
onClick={() => setAttrs([...attrs, { name: '', values: [{ name: '' }] }])}
|
||||
>
|
||||
添加规格轴
|
||||
</Button>
|
||||
<Button onClick={regenerateMatrix}>按规格生成 SKU 矩阵</Button>
|
||||
</Space>
|
||||
|
||||
<Typography.Title level={5}>SKU 列表</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, i) => String(i)}
|
||||
pagination={false}
|
||||
scroll={{ x: 1240 }}
|
||||
dataSource={skus}
|
||||
columns={[
|
||||
{
|
||||
title: '规格',
|
||||
width: 140,
|
||||
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
|
||||
},
|
||||
{
|
||||
title: 'SKU',
|
||||
width: 110,
|
||||
render: (_, row) => (
|
||||
<Typography.Text type="secondary">
|
||||
{row.skuCode || '保存后自动生成'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <span>69码 <Typography.Text type="danger">*</Typography.Text></span>,
|
||||
width: 180,
|
||||
render: (_, row, index) => (
|
||||
<Input
|
||||
placeholder="本规格独立 69 码"
|
||||
value={row.barcode69}
|
||||
onChange={(e) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, barcode69: e.target.value };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '售价',
|
||||
width: 100,
|
||||
render: (_, row, index) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={row.price}
|
||||
onChange={(v) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, price: Number(v) || 0 };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权益',
|
||||
width: 100,
|
||||
render: (_, row, index) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={row.benefitAmount}
|
||||
onChange={(v) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
render: (_, row, index) => (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={row.status}
|
||||
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={(status) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, status };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
width: 160,
|
||||
render: (_, row, index) => (
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 80 }}
|
||||
value={row.saleUnit}
|
||||
options={[
|
||||
{ value: 'BOTTLE', label: '瓶' },
|
||||
{ value: 'BOX', label: '箱' },
|
||||
]}
|
||||
onChange={(saleUnit) => {
|
||||
const next = [...skus];
|
||||
next[index] = {
|
||||
...row,
|
||||
saleUnit,
|
||||
bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1,
|
||||
};
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
{row.saleUnit === 'BOX' ? (
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: 70 }}
|
||||
value={row.bottlesPerUnit}
|
||||
onChange={(v) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, bottlesPerUnit: Number(v) || 6 };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
width: 200,
|
||||
render: (_, row, index) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Switch
|
||||
checkedChildren="线上"
|
||||
unCheckedChildren="线上"
|
||||
checked={row.allowOnlinePurchase}
|
||||
onChange={(checked) => {
|
||||
const next = [...skus];
|
||||
next[index] = {
|
||||
...row,
|
||||
allowOnlinePurchase: checked,
|
||||
allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false,
|
||||
};
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="跨城"
|
||||
unCheckedChildren="跨城"
|
||||
disabled={!row.allowOnlinePurchase}
|
||||
checked={row.allowCrossCityDelivery}
|
||||
onChange={(checked) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, allowCrossCityDelivery: checked };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="现场"
|
||||
unCheckedChildren="现场"
|
||||
checked={row.allowOnSitePickup}
|
||||
onChange={(checked) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, allowOnSitePickup: checked };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '默认',
|
||||
width: 70,
|
||||
render: (_, row, index) => (
|
||||
<Radio
|
||||
checked={row.isDefault}
|
||||
onChange={() => {
|
||||
setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index })));
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '主图',
|
||||
width: 72,
|
||||
render: (_, row) =>
|
||||
row.imageUrl ? (
|
||||
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
) : (
|
||||
<Typography.Text type="secondary">无</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '编辑',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row, index) => (
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
|
||||
填写
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
|
||||
保存规格与 SKU
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
|
||||
open={editIndex != null}
|
||||
onCancel={() => setEditIndex(null)}
|
||||
onOk={() => setEditIndex(null)}
|
||||
okText="完成"
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
{editing && editIndex != null ? (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">SKU</Typography.Text>
|
||||
<div>{editing.skuCode || '保存后自动生成'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>69 码 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
|
||||
<Input
|
||||
placeholder="本规格独立 69 码"
|
||||
value={editing.barcode69}
|
||||
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>主图</Typography.Text>
|
||||
<OssUpload
|
||||
bizType="COVER"
|
||||
value={editing.imageUrl || ''}
|
||||
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<div>
|
||||
<Typography.Text>售价</Typography.Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: 140 }}
|
||||
value={editing.price}
|
||||
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>权益</Typography.Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: 140 }}
|
||||
value={editing.benefitAmount}
|
||||
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
<div>
|
||||
<Typography.Text>状态</Typography.Text>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={editing.status}
|
||||
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={(status) => patchSku(editIndex, { status })}
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 100 }}
|
||||
value={editing.saleUnit}
|
||||
options={[
|
||||
{ value: 'BOTTLE', label: '瓶' },
|
||||
{ value: 'BOX', label: '箱' },
|
||||
]}
|
||||
onChange={(saleUnit) =>
|
||||
patchSku(editIndex, {
|
||||
saleUnit,
|
||||
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{editing.saleUnit === 'BOX' ? (
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={editing.bottlesPerUnit}
|
||||
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
<Space>
|
||||
<Switch
|
||||
checkedChildren="线上"
|
||||
unCheckedChildren="线上"
|
||||
checked={editing.allowOnlinePurchase}
|
||||
onChange={(checked) =>
|
||||
patchSku(editIndex, {
|
||||
allowOnlinePurchase: checked,
|
||||
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="跨城"
|
||||
unCheckedChildren="跨城"
|
||||
disabled={!editing.allowOnlinePurchase}
|
||||
checked={editing.allowCrossCityDelivery}
|
||||
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="现场"
|
||||
unCheckedChildren="现场"
|
||||
checked={editing.allowOnSitePickup}
|
||||
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="默认"
|
||||
unCheckedChildren="默认"
|
||||
checked={editing.isDefault}
|
||||
onChange={(checked) => {
|
||||
if (!checked) return;
|
||||
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||
const [addressDetail, setAddressDetail] = useState('');
|
||||
const [productId, setProductId] = useState<string>();
|
||||
const [skuId, setSkuId] = useState<string>();
|
||||
const [quantity, setQuantity] = useState(2);
|
||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||
@@ -87,9 +88,37 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
}, [open]);
|
||||
|
||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
||||
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||
const selectedSku =
|
||||
skuOptions.find((s) => s.id === skuId) ||
|
||||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
||||
skuOptions[0];
|
||||
const allowOnline = selectedSku
|
||||
? selectedSku.allowOnlinePurchase !== false
|
||||
: selectedProduct
|
||||
? selectedProduct.allowOnlinePurchase !== false
|
||||
: true;
|
||||
const allowOnSite = selectedSku
|
||||
? !!selectedSku.allowOnSitePickup
|
||||
: !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedSku
|
||||
? selectedSku.allowCrossCityDelivery !== false
|
||||
: selectedProduct
|
||||
? selectedProduct.allowCrossCityDelivery !== false
|
||||
: true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedProduct) return;
|
||||
const def =
|
||||
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
||||
skuOptions.find((s) => s.isDefault) ||
|
||||
skuOptions[0];
|
||||
if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
||||
setSkuId(def.id);
|
||||
setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, productId, selectedProduct?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedProduct) return;
|
||||
@@ -115,6 +144,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
productId,
|
||||
quantity,
|
||||
deliveryMode,
|
||||
skuId: skuId || undefined,
|
||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||
}),
|
||||
@@ -124,7 +154,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
.finally(() => setPreviewLoading(false));
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||
}, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||
|
||||
useEffect(() => () => stopPoll(), []);
|
||||
|
||||
@@ -135,6 +165,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
setRegionCodes([]);
|
||||
setAddressDetail('');
|
||||
setQuantity(2);
|
||||
setProductId(undefined);
|
||||
setSkuId(undefined);
|
||||
setPromoCodeId(undefined);
|
||||
setDeliveryMode('ADDRESS');
|
||||
setAutoReceive(false);
|
||||
@@ -202,6 +234,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
productId: productId!,
|
||||
quantity,
|
||||
promoCodeId: promoCodeId || undefined,
|
||||
skuId: skuId || undefined,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -217,6 +250,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payMethod: 'NATIVE' }),
|
||||
});
|
||||
if (pay.mode === 'mock') {
|
||||
message.success(`支付成功:${order.orderNo}`);
|
||||
resetForm();
|
||||
onSuccess({ id: order.id, orderNo: order.orderNo });
|
||||
return;
|
||||
}
|
||||
setCodeUrl(pay.codeUrl ?? null);
|
||||
startPoll(order.id);
|
||||
} catch (e) {
|
||||
@@ -311,7 +350,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
loading={loadingOptions}
|
||||
placeholder="选择商品"
|
||||
value={productId}
|
||||
onChange={setProductId}
|
||||
onChange={(id) => {
|
||||
setProductId(id);
|
||||
setSkuId(undefined);
|
||||
}}
|
||||
options={(options?.products ?? []).map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
||||
@@ -319,7 +361,25 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="数量" required>
|
||||
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
||||
<Form.Item label="规格" required>
|
||||
<Select
|
||||
placeholder="选择规格"
|
||||
value={skuId}
|
||||
onChange={(id) => {
|
||||
setSkuId(id);
|
||||
const sku = skuOptions.find((s) => s.id === id);
|
||||
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
||||
}}
|
||||
options={skuOptions.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.specText || '默认'} · ¥${fmtMoney(s.price)} · ${s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
<Form.Item label={selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'} required>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={quantity}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,71 +1,5 @@
|
||||
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
|
||||
export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed';
|
||||
export const BIG_SCREEN_DEMO_CHANNEL = 'dukang-big-screen';
|
||||
export const BIG_SCREEN_DEMO_STORAGE_KEY = 'dukang:big-screen-demo';
|
||||
|
||||
export type BigScreenDemoOrder = {
|
||||
payAmount: number;
|
||||
items: string;
|
||||
userPhoneMasked: string;
|
||||
};
|
||||
|
||||
export type BigScreenDemoPayload = {
|
||||
type: 'demo';
|
||||
id: string;
|
||||
at: number;
|
||||
orders: BigScreenDemoOrder[];
|
||||
};
|
||||
|
||||
export function buildBigScreenDemoPayload(): BigScreenDemoPayload {
|
||||
return {
|
||||
type: 'demo',
|
||||
id: `demo-${Date.now()}`,
|
||||
at: Date.now(),
|
||||
orders: [
|
||||
{ payAmount: 1288, items: '杜康君酿 · 浓香型 × 6瓶', userPhoneMasked: '138****8888' },
|
||||
{ payAmount: 688, items: '杜康君酿 · 浓香型 × 2瓶', userPhoneMasked: '139****6666' },
|
||||
{ payAmount: 188, items: '杜康君酿 · 浓香型 × 1瓶', userPhoneMasked: '137****1888' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function triggerBigScreenDemo() {
|
||||
const payload = buildBigScreenDemoPayload();
|
||||
try {
|
||||
localStorage.setItem(BIG_SCREEN_DEMO_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
try {
|
||||
const ch = new BroadcastChannel(BIG_SCREEN_DEMO_CHANNEL);
|
||||
ch.postMessage(payload);
|
||||
ch.close();
|
||||
} catch {
|
||||
/* BroadcastChannel 不可用时靠 localStorage 冷启动 */
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function readPendingBigScreenDemo(maxAgeMs = 8000): BigScreenDemoPayload | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(BIG_SCREEN_DEMO_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as BigScreenDemoPayload;
|
||||
if (parsed?.type !== 'demo' || !parsed.id || !Array.isArray(parsed.orders)) return null;
|
||||
if (Date.now() - Number(parsed.at || 0) > maxAgeMs) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingBigScreenDemo() {
|
||||
try {
|
||||
localStorage.removeItem(BIG_SCREEN_DEMO_STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyPackageAuditChanged() {
|
||||
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
BIG_SCREEN_DEMO_CHANNEL,
|
||||
clearPendingBigScreenDemo,
|
||||
readPendingBigScreenDemo,
|
||||
type BigScreenDemoPayload,
|
||||
} from '../lib/admin-events';
|
||||
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
||||
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
||||
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
||||
|
||||
export type BigScreenOrder = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
payAmount: number;
|
||||
items: string;
|
||||
/** 展示用时间(付款成功时间) */
|
||||
createdAt: string;
|
||||
paidAt?: string;
|
||||
userPhoneMasked: string | null;
|
||||
};
|
||||
|
||||
@@ -20,8 +19,61 @@ type AmountTier = 1 | 2 | 3;
|
||||
|
||||
const POLL_MS = 3000;
|
||||
const ROW_MS = 2500;
|
||||
/** 不足此条数时重复填充,保证滚动连贯 */
|
||||
const MIN_SCROLL_ROWS = 10;
|
||||
const FX_DURATION_MS = 10_000;
|
||||
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||
const TIER_DURATION: Record<AmountTier, number> = { 1: 3000, 2: 4500, 3: 6000 };
|
||||
|
||||
const CELEBRATE_SFX: Record<AmountTier, string> = {
|
||||
1: sfxTier1,
|
||||
2: sfxTier2,
|
||||
3: sfxTier3,
|
||||
};
|
||||
|
||||
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
||||
let audioUnlocked = false;
|
||||
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
|
||||
|
||||
function unlockCelebrateAudio() {
|
||||
if (audioUnlocked || !sharedAudio) return;
|
||||
sharedAudio.muted = true;
|
||||
sharedAudio.src = CELEBRATE_SFX[1];
|
||||
void sharedAudio
|
||||
.play()
|
||||
.then(() => {
|
||||
sharedAudio.pause();
|
||||
sharedAudio.currentTime = 0;
|
||||
sharedAudio.muted = false;
|
||||
audioUnlocked = true;
|
||||
})
|
||||
.catch(() => {
|
||||
/* 等待下次手势 */
|
||||
});
|
||||
}
|
||||
|
||||
function playCelebrateSfx(tier: AmountTier): () => void {
|
||||
if (!sharedAudio) return () => undefined;
|
||||
try {
|
||||
sharedAudio.pause();
|
||||
sharedAudio.currentTime = 0;
|
||||
sharedAudio.src = CELEBRATE_SFX[tier];
|
||||
sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65;
|
||||
sharedAudio.muted = false;
|
||||
void sharedAudio.play().catch(() => {
|
||||
/* 未解锁时静默失败 */
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
sharedAudio.pause();
|
||||
sharedAudio.currentTime = 0;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, '0');
|
||||
@@ -35,7 +87,7 @@ function formatDateLine(d: Date) {
|
||||
return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`;
|
||||
}
|
||||
|
||||
function formatHm(iso: string): string {
|
||||
function formatOrderTime(iso: string): string {
|
||||
try {
|
||||
return formatClock(new Date(iso));
|
||||
} catch {
|
||||
@@ -43,11 +95,25 @@ function formatHm(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// function formatAmount(n: number): string {
|
||||
// return Number(n || 0).toLocaleString('zh-CN', {
|
||||
// minimumFractionDigits: 0,
|
||||
// maximumFractionDigits: 0,
|
||||
// });
|
||||
// }
|
||||
|
||||
function formatAmount(n: number): string {
|
||||
return Number(n || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
const num = Number(n || 0);
|
||||
// 先保留两位小数,再分割处理
|
||||
const fixed = num.toFixed(2);
|
||||
const [intStr, decStr] = fixed.split('.');
|
||||
const intFormatted = Number(intStr).toLocaleString('zh-CN');
|
||||
return decStr === '00' ? intFormatted : `${intFormatted}.${decStr}`;
|
||||
}
|
||||
|
||||
function amountCssClass(payAmount: number, base: string): string {
|
||||
const wide = Math.floor(Math.abs(Number(payAmount || 0))) >= 100_000;
|
||||
return wide ? `${base} ${base}--6d` : base;
|
||||
}
|
||||
|
||||
export function amountTier(payAmount: number): AmountTier {
|
||||
@@ -83,9 +149,11 @@ function OrderRow({
|
||||
className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`}
|
||||
>
|
||||
{latest ? <span className="big-screen-row-mark" /> : null}
|
||||
<span className="big-screen-amount">¥ {formatAmount(order.payAmount)}</span>
|
||||
<span className={amountCssClass(order.payAmount, 'big-screen-amount')}>
|
||||
¥ {formatAmount(order.payAmount)}
|
||||
</span>
|
||||
<span className="big-screen-items">{order.items || '—'}</span>
|
||||
<span className="big-screen-time">{formatHm(order.createdAt)}</span>
|
||||
<span className="big-screen-time">{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
|
||||
<span className="big-screen-phone">{order.userPhoneMasked || '—'}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -166,9 +234,13 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
|
||||
const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount);
|
||||
|
||||
useEffect(() => {
|
||||
const duration = TIER_DURATION[tier];
|
||||
const timer = window.setTimeout(() => onDoneRef.current(), duration);
|
||||
const timer = window.setTimeout(() => onDoneRef.current(), FX_DURATION_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [order.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const stop = playCelebrateSfx(tier);
|
||||
return stop;
|
||||
}, [order.id, tier]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -201,7 +273,6 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const started = performance.now();
|
||||
const duration = TIER_DURATION[tier];
|
||||
const gravity = tier === 3 ? 0.18 : 0.14;
|
||||
const loop = (now: number) => {
|
||||
const dt = Math.min(32, now - last) / 16.6;
|
||||
@@ -212,8 +283,12 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.rot += p.vr * dt;
|
||||
p.life -= (tier === 3 ? 0.0032 : 0.005) * dt;
|
||||
if (p.rain && (p.y > canvas.clientHeight + 30 || p.life <= 0) && now - started < duration - 400) {
|
||||
p.life -= (tier === 3 ? 0.002 : 0.003) * dt;
|
||||
if (
|
||||
p.rain &&
|
||||
(p.y > canvas.clientHeight + 30 || p.life <= 0) &&
|
||||
now - started < FX_DURATION_MS - 400
|
||||
) {
|
||||
p.x = Math.random() * canvas.clientWidth;
|
||||
p.y = -16 - Math.random() * 60;
|
||||
p.vx = (Math.random() - 0.5) * 1.4;
|
||||
@@ -253,11 +328,13 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
|
||||
{tier >= 2 ? <div className="big-screen-fx-sweep big-screen-fx-sweep--alt" /> : null}
|
||||
<div className={`big-screen-fx-card big-screen-fx-card--t${tier}`}>
|
||||
<div className="big-screen-fx-kicker">{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}</div>
|
||||
<div className="big-screen-fx-amount">¥ {formatAmount(displayAmount)}</div>
|
||||
<div className={amountCssClass(displayAmount || order.payAmount, 'big-screen-fx-amount')}>
|
||||
¥ {formatAmount(displayAmount)}
|
||||
</div>
|
||||
<div className="big-screen-fx-items">{order.items || '—'}</div>
|
||||
<div className="big-screen-fx-meta">
|
||||
<span>{order.userPhoneMasked || '—'}</span>
|
||||
<span>{formatHm(order.createdAt)}</span>
|
||||
<span>{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,10 +347,11 @@ export default function BigScreenPage() {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
|
||||
const seenIdsRef = useRef<Set<string> | null>(null);
|
||||
const celebratedIdsRef = useRef<Set<string>>(new Set());
|
||||
const queueRef = useRef<BigScreenOrder[]>([]);
|
||||
const celebratingRef = useRef(false);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const demoSeenRef = useRef<Set<string>>(new Set());
|
||||
const dayKeyRef = useRef('');
|
||||
const [viewportH, setViewportH] = useState(0);
|
||||
|
||||
const playNext = useCallback(() => {
|
||||
@@ -285,11 +363,13 @@ export default function BigScreenPage() {
|
||||
|
||||
const enqueueNew = useCallback(
|
||||
(fresh: BigScreenOrder[]) => {
|
||||
if (!fresh.length) return;
|
||||
const ranked = [...fresh].sort((a, b) => {
|
||||
const toCelebrate = fresh.filter((o) => !celebratedIdsRef.current.has(o.id));
|
||||
if (!toCelebrate.length) return;
|
||||
for (const o of toCelebrate) celebratedIdsRef.current.add(o.id);
|
||||
const ranked = [...toCelebrate].sort((a, b) => {
|
||||
const td = amountTier(b.payAmount) - amountTier(a.payAmount);
|
||||
if (td !== 0) return td;
|
||||
return +new Date(b.createdAt) - +new Date(a.createdAt);
|
||||
return +new Date(b.paidAt ?? b.createdAt) - +new Date(a.paidAt ?? a.createdAt);
|
||||
});
|
||||
queueRef.current.push(...ranked);
|
||||
if (!celebratingRef.current) playNext();
|
||||
@@ -297,38 +377,28 @@ export default function BigScreenPage() {
|
||||
[playNext],
|
||||
);
|
||||
|
||||
const playDemo = useCallback(
|
||||
(payload: BigScreenDemoPayload) => {
|
||||
if (!payload?.orders?.length || demoSeenRef.current.has(payload.id)) return;
|
||||
demoSeenRef.current.add(payload.id);
|
||||
const now = new Date().toISOString();
|
||||
const fake: BigScreenOrder[] = payload.orders.map((o, i) => ({
|
||||
id: `${payload.id}-${i}`,
|
||||
orderNo: `${payload.id}-${i}`,
|
||||
payAmount: o.payAmount,
|
||||
items: o.items,
|
||||
createdAt: now,
|
||||
userPhoneMasked: o.userPhoneMasked,
|
||||
}));
|
||||
setItems((prev) => [...fake, ...prev.filter((row) => !row.id.startsWith('demo-'))]);
|
||||
enqueueNew(fake);
|
||||
clearPendingBigScreenDemo();
|
||||
},
|
||||
[enqueueNew],
|
||||
);
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
const now = new Date();
|
||||
const todayKey = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
if (dayKeyRef.current && dayKeyRef.current !== todayKey) {
|
||||
seenIdsRef.current = null;
|
||||
celebratedIdsRef.current = new Set();
|
||||
queueRef.current = [];
|
||||
celebratingRef.current = false;
|
||||
setCelebrate(null);
|
||||
setPaused(false);
|
||||
}
|
||||
dayKeyRef.current = todayKey;
|
||||
|
||||
return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
|
||||
.then((d) => {
|
||||
const list = d.items ?? [];
|
||||
setItems((prev) => {
|
||||
const demos = prev.filter((o) => o.id.startsWith('demo-'));
|
||||
return demos.length ? [...demos, ...list] : list;
|
||||
});
|
||||
setItems(list);
|
||||
setLoadError('');
|
||||
const seen = seenIdsRef.current;
|
||||
if (!seen) {
|
||||
seenIdsRef.current = new Set(list.map((o) => o.id));
|
||||
for (const o of list) celebratedIdsRef.current.add(o.id);
|
||||
return;
|
||||
}
|
||||
const fresh = list.filter((o) => !seen.has(o.id));
|
||||
@@ -347,22 +417,15 @@ export default function BigScreenPage() {
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = readPendingBigScreenDemo();
|
||||
if (pending) playDemo(pending);
|
||||
|
||||
let ch: BroadcastChannel | null = null;
|
||||
try {
|
||||
ch = new BroadcastChannel(BIG_SCREEN_DEMO_CHANNEL);
|
||||
ch.onmessage = (ev: MessageEvent<BigScreenDemoPayload>) => {
|
||||
if (ev.data?.type === 'demo' && ev.data.orders?.length) playDemo(ev.data);
|
||||
};
|
||||
} catch {
|
||||
ch = null;
|
||||
}
|
||||
unlockCelebrateAudio();
|
||||
const unlock = () => unlockCelebrateAudio();
|
||||
window.addEventListener('pointerdown', unlock, { once: true });
|
||||
window.addEventListener('keydown', unlock, { once: true });
|
||||
return () => {
|
||||
ch?.close();
|
||||
window.removeEventListener('pointerdown', unlock);
|
||||
window.removeEventListener('keydown', unlock);
|
||||
};
|
||||
}, [playDemo]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
@@ -390,7 +453,7 @@ export default function BigScreenPage() {
|
||||
if (!items.length) return [];
|
||||
const rowH = 80;
|
||||
const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH));
|
||||
const minCount = visible + 1;
|
||||
const minCount = Math.max(MIN_SCROLL_ROWS, visible + 1);
|
||||
const unit: BigScreenOrder[] = [];
|
||||
while (unit.length < minCount) unit.push(...items);
|
||||
return unit;
|
||||
@@ -428,16 +491,8 @@ export default function BigScreenPage() {
|
||||
<LiveClock />
|
||||
|
||||
<div className="big-screen-list">
|
||||
<div className="big-screen-list-head">
|
||||
<span>下单金额</span>
|
||||
<span>下单商品</span>
|
||||
<span>下单时间</span>
|
||||
<span>下单人</span>
|
||||
</div>
|
||||
<div className="big-screen-list-body" ref={viewportRef}>
|
||||
{items.length === 0 ? (
|
||||
<div className="big-screen-empty">{loadError || '暂无订单'}</div>
|
||||
) : (
|
||||
{items.length === 0 ? null : (
|
||||
<div
|
||||
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
|
||||
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
|
||||
@@ -454,6 +509,10 @@ export default function BigScreenPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
|
||||
) : null}
|
||||
|
||||
{celebrate ? (
|
||||
<CelebrateFx
|
||||
key={celebrate.id}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
@@ -16,9 +17,11 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
INVOICE_CATEGORY_LABELS,
|
||||
INVOICE_KIND_LABELS,
|
||||
INVOICE_STATUS_LABELS,
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceCategory,
|
||||
type InvoiceKind,
|
||||
type InvoiceStatus,
|
||||
type InvoiceTitleType,
|
||||
@@ -34,6 +37,7 @@ type Row = {
|
||||
orderNo?: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
invoiceCategory?: InvoiceCategory;
|
||||
titleName: string;
|
||||
status: InvoiceStatus;
|
||||
overdue?: boolean;
|
||||
@@ -53,6 +57,7 @@ type CreateFormValues = {
|
||||
orderNo: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
invoiceCategory?: InvoiceCategory;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
@@ -63,12 +68,22 @@ type CreateFormValues = {
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStatus = searchParams.get('status')?.trim() || '';
|
||||
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
|
||||
const [filterForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
if (initialStatus) init.status = initialStatus;
|
||||
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
|
||||
return init;
|
||||
});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/invoices',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -81,12 +96,29 @@ export default function InvoicesPage() {
|
||||
const [createForm] = Form.useForm<CreateFormValues>();
|
||||
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||
const titleType = Form.useWatch('titleType', createForm);
|
||||
const deepLinkOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
filterForm.setFieldsValue({
|
||||
status: filters.status || undefined,
|
||||
invoiceNo: filters.invoiceNo || undefined,
|
||||
});
|
||||
}, [filterForm, filters.invoiceNo, filters.status]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetail(await request(`/admin/invoices/${id}`));
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return;
|
||||
const first = data?.items?.[0];
|
||||
if (first && String(first.invoiceNo) === initialInvoiceNo) {
|
||||
deepLinkOpenedRef.current = true;
|
||||
void openDetail(first.id);
|
||||
}
|
||||
}, [data, initialInvoiceNo, loading]);
|
||||
|
||||
async function issueWithFile(file: File) {
|
||||
if (!detail) return false;
|
||||
setUploading(true);
|
||||
@@ -128,6 +160,7 @@ export default function InvoicesPage() {
|
||||
orderNo: values.orderNo.trim(),
|
||||
titleType: values.titleType,
|
||||
invoiceKind: values.invoiceKind,
|
||||
invoiceCategory: values.invoiceCategory,
|
||||
titleName: values.titleName.trim(),
|
||||
taxNo: values.taxNo?.trim() || undefined,
|
||||
addressPhone: values.addressPhone?.trim() || undefined,
|
||||
@@ -158,9 +191,17 @@ export default function InvoicesPage() {
|
||||
},
|
||||
{
|
||||
title: '票种',
|
||||
width: 120,
|
||||
width: 140,
|
||||
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
width: 88,
|
||||
render: (_, r) =>
|
||||
r.invoiceCategory
|
||||
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
||||
: '—',
|
||||
},
|
||||
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
@@ -205,6 +246,7 @@ export default function InvoicesPage() {
|
||||
createForm.setFieldsValue({
|
||||
titleType: 'PERSONAL',
|
||||
invoiceKind: 'NORMAL',
|
||||
invoiceCategory: 'LIQUOR',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
@@ -213,6 +255,7 @@ export default function InvoicesPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
@@ -231,6 +274,9 @@ export default function InvoicesPage() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="invoiceNo" label="申请单号">
|
||||
<Input allowClear placeholder="发票申请单号" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
</Button>
|
||||
@@ -292,6 +338,11 @@ export default function InvoicesPage() {
|
||||
<Descriptions.Item label="发票类型">
|
||||
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
{detail.invoiceCategory
|
||||
? INVOICE_CATEGORY_LABELS[detail.invoiceCategory]
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
||||
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||
@@ -350,6 +401,18 @@ export default function InvoicesPage() {
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="invoiceCategory"
|
||||
label="类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={(Object.keys(INVOICE_CATEGORY_LABELS) as InvoiceCategory[]).map((k) => ({
|
||||
value: k,
|
||||
label: INVOICE_CATEGORY_LABELS[k],
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="titleType"
|
||||
label="抬头类型"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||
import { triggerBigScreenDemo } from '../lib/admin-events';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
DELIVERY_PROVIDER_LABELS,
|
||||
@@ -163,6 +162,8 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||
const [form] = Form.useForm();
|
||||
const [shipForm] = Form.useForm();
|
||||
const [logisticsForm] = Form.useForm();
|
||||
@@ -173,6 +174,7 @@ export default function OrdersPage() {
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const deepLinkOpenedRef = useRef(false);
|
||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||
@@ -192,7 +194,6 @@ export default function OrdersPage() {
|
||||
const [trackOpen, setTrackOpen] = useState(false);
|
||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
const selectedOrders = useMemo(
|
||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||
@@ -203,6 +204,21 @@ export default function OrdersPage() {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialOrderNo) {
|
||||
form.setFieldsValue({ orderNo: initialOrderNo });
|
||||
}
|
||||
}, [form, initialOrderNo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
|
||||
const first = data?.items?.[0];
|
||||
if (first && String(first.orderNo) === initialOrderNo) {
|
||||
deepLinkOpenedRef.current = true;
|
||||
void openDetail(first.id);
|
||||
}
|
||||
}, [data, initialOrderNo, loading]);
|
||||
|
||||
async function openRedeemDetail(redeemId: string) {
|
||||
setRedeemDetailLoading(true);
|
||||
setRedeemDrawerOpen(true);
|
||||
@@ -545,17 +561,6 @@ export default function OrdersPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||
<Space>
|
||||
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
||||
{isSuperAdmin ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.open('/orders/big-screen', 'dukang-big-screen');
|
||||
triggerBigScreenDemo();
|
||||
message.success('已发送测试成交动效(三级 / 二级 / 一级)');
|
||||
}}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
) : null}
|
||||
{canProxyOrder ? (
|
||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||
代下单
|
||||
|
||||
@@ -12,6 +12,7 @@ import OssUpload from '../components/OssUpload';
|
||||
import MultiImageUpload from '../components/MultiImageUpload';
|
||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
|
||||
type ProductDetailContentDto = {
|
||||
@@ -221,8 +222,13 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
||||
<Form.Item label="SKU">
|
||||
<Input disabled placeholder="保存后自动生成" />
|
||||
</Form.Item>
|
||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
<Form.Item
|
||||
name="barcode69"
|
||||
label="69码"
|
||||
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
|
||||
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
|
||||
>
|
||||
<Input placeholder="默认规格 69 码" />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
@@ -402,7 +408,7 @@ export default function ProductsPage() {
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="编辑商品" width={1100} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
@@ -420,8 +426,8 @@ export default function ProductsPage() {
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
|
||||
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
|
||||
<Descriptions.Item label="SKU">{String(detail.skuCode)}(系统生成)</Descriptions.Item>
|
||||
<Descriptions.Item label="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
|
||||
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
@@ -437,6 +443,23 @@ export default function ProductsPage() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'specs',
|
||||
label: '规格与 SKU',
|
||||
children: (
|
||||
<ProductSpecsEditor
|
||||
productId={String(detail.id)}
|
||||
initialAttrs={(detail.specAttrs as never) ?? []}
|
||||
initialSkus={(detail.skus as never) ?? []}
|
||||
onSaved={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/products/${detail.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue(mapDetailToForm(d));
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Form>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
@@ -26,8 +27,14 @@ function maskPhone(phone: string | null | undefined) {
|
||||
}
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||
const init: Record<string, string | boolean> = {};
|
||||
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
|
||||
return init;
|
||||
});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/redeem-records',
|
||||
() => {
|
||||
@@ -43,6 +50,11 @@ export default function RedeemRecordsPage() {
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const deepLinkOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
|
||||
}, [form, initialRedeemNo]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetailLoading(true);
|
||||
@@ -54,6 +66,15 @@ export default function RedeemRecordsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return;
|
||||
const first = data?.items?.[0];
|
||||
if (first && String(first.redeemNo) === initialRedeemNo) {
|
||||
deepLinkOpenedRef.current = true;
|
||||
void openDetail(first.id);
|
||||
}
|
||||
}, [data, initialRedeemNo, loading]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '核销号',
|
||||
|
||||
@@ -64,10 +64,13 @@ export default function StoreBillsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||
const initialStoreId = searchParams.get('storeId') || '';
|
||||
const initialStatus =
|
||||
searchParams.get('status')?.trim() ||
|
||||
(initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '');
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({
|
||||
kind: initialKind,
|
||||
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
||||
status: initialStatus,
|
||||
storeId: initialStoreId,
|
||||
});
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
|
||||
@@ -20,20 +20,18 @@ import type {
|
||||
StoreInfoChangeFieldDiff,
|
||||
StoreInfoChangeRequestDto,
|
||||
StoreInfoChangeStatus,
|
||||
StorePackageAuditDetailDto,
|
||||
StorePackageAuditSummaryDto,
|
||||
StorePackageChangeRequestDto,
|
||||
StorePackageChangeStatus,
|
||||
StorePackageItemDto,
|
||||
StorePackageViewDto,
|
||||
} from '@dukang/shared-types';
|
||||
import {
|
||||
STORE_INFO_CHANGE_STATUS_LABELS,
|
||||
normalizeStorePackageImageUrls,
|
||||
STORE_INFO_CHANGEABLE_FIELD_LABELS,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
||||
|
||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
PENDING: '待审核',
|
||||
@@ -41,293 +39,78 @@ const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||
const name = String(pkg.name ?? '').trim();
|
||||
return name ? `name:${name}` : `idx:${index}`;
|
||||
}
|
||||
|
||||
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||||
return normalizeStorePackageImageUrls(pkg).join('|');
|
||||
}
|
||||
|
||||
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
||||
|
||||
/** 文本逐字差异:LCS 比对,产出 equal / delete / insert 段落,用于高亮具体变了哪些字 */
|
||||
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--) {
|
||||
for (let j = n - 1; j >= 0; j--) {
|
||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (a[i] === b[j]) {
|
||||
raw.push({ type: 'equal', text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
raw.push({ type: 'delete', text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
raw.push({ type: 'insert', text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < m) raw.push({ type: 'delete', text: a[i++] });
|
||||
while (j < n) raw.push({ type: 'insert', text: b[j++] });
|
||||
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||
for (const s of raw) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.type === s.type) last.text += s.text;
|
||||
else merged.push({ ...s });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** 逐字段比较套餐内容,返回发生变化的字段明细(用于“变动的地方详细列出”) */
|
||||
function fieldChanges(
|
||||
live: StorePackageItemDto | StorePackageViewDto,
|
||||
proposed: StorePackageItemDto | StorePackageViewDto,
|
||||
): FieldChange[] {
|
||||
const changes: FieldChange[] = [];
|
||||
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
||||
const pushText = (label: string, oldV: string, newV: string) => {
|
||||
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
||||
};
|
||||
const pushValue = (label: string, oldV: string, newV: string) => {
|
||||
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
|
||||
};
|
||||
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
|
||||
pushText('套餐名称', text(live.name), text(proposed.name));
|
||||
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
|
||||
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
|
||||
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
|
||||
const liveImgs = normalizeStorePackageImageUrls(live);
|
||||
const proposedImgs = normalizeStorePackageImageUrls(proposed);
|
||||
if (imageSignature(live) !== imageSignature(proposed)) {
|
||||
changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
||||
const rows: Array<{
|
||||
key: string;
|
||||
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
live?: StorePackageViewDto;
|
||||
proposed?: StorePackageItemDto;
|
||||
changes?: FieldChange[];
|
||||
}> = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const l = liveMap.get(key);
|
||||
const p = proposedMap.get(key);
|
||||
if (l && !p) {
|
||||
rows.push({ key, change: 'removed', live: l });
|
||||
} else if (!l && p) {
|
||||
rows.push({ key, change: 'added', proposed: p });
|
||||
} else if (l && p) {
|
||||
const changed =
|
||||
l.price !== p.price ||
|
||||
l.dishes !== p.dishes ||
|
||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||
imageSignature(l) !== imageSignature(p);
|
||||
rows.push({
|
||||
key,
|
||||
change: changed ? 'changed' : 'unchanged',
|
||||
live: l,
|
||||
proposed: p,
|
||||
changes: changed ? fieldChanges(l, p) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const CHANGE_LABELS = {
|
||||
added: { text: '新增', color: 'green' },
|
||||
removed: { text: '删除', color: 'red' },
|
||||
changed: { text: '变更', color: 'orange' },
|
||||
unchanged: { text: '未变', color: 'default' },
|
||||
} as const;
|
||||
|
||||
/** 文本逐字差异渲染:原行红色删除线标出被删的字,新行绿色标出新增的字 */
|
||||
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
||||
const segs = diffText(oldText, newText);
|
||||
return (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text type="secondary">原:</Typography.Text>
|
||||
{segs
|
||||
.filter((s) => s.type !== 'insert')
|
||||
.map((s, idx) =>
|
||||
s.type === 'delete' ? (
|
||||
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
|
||||
{s.text || '(空)'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text type="secondary">新:</Typography.Text>
|
||||
{segs
|
||||
.filter((s) => s.type !== 'delete')
|
||||
.map((s, idx) =>
|
||||
s.type === 'insert' ? (
|
||||
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
|
||||
{s.text || '(空)'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageDetailCard({
|
||||
title,
|
||||
pkg,
|
||||
change,
|
||||
changes,
|
||||
}: {
|
||||
title?: string;
|
||||
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||
change?: keyof typeof CHANGE_LABELS;
|
||||
changes?: FieldChange[];
|
||||
}) {
|
||||
const images = normalizeStorePackageImageUrls(pkg);
|
||||
const meta = change ? CHANGE_LABELS[change] : null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<Space style={{ marginBottom: 8 }} wrap>
|
||||
{title ? (
|
||||
<Typography.Text type="secondary">{title}</Typography.Text>
|
||||
) : null}
|
||||
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
||||
</Space>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>{pkg.name}</strong>
|
||||
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||||
</div>
|
||||
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||
{pkg.dishes || '—'}
|
||||
</Typography.Paragraph>
|
||||
{pkg.usableTime ? (
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
className="admin-package-audit-text"
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
可用时间:{pkg.usableTime}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{pkg.otherNotes ? (
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
className="admin-package-audit-text"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
其他说明:{pkg.otherNotes}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{images.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{images.map((url) => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
width={72}
|
||||
height={72}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||||
)}
|
||||
{changes && changes.length ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffe7ba',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 12 }}>
|
||||
变更明细
|
||||
</Typography.Text>
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||
{changes.map((c) => (
|
||||
<li key={c.label} style={{ marginBottom: 6 }}>
|
||||
<Typography.Text type="secondary">{c.label}:</Typography.Text>
|
||||
{c.kind === 'text' ? (
|
||||
<TextDiff oldText={c.old} newText={c.now} />
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text delete type="secondary">
|
||||
{c.old}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> → </Typography.Text>
|
||||
<Typography.Text strong>{c.now}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
|
||||
name: '门店名称',
|
||||
contactPhone: '联系电话',
|
||||
address: '详细地址',
|
||||
intro: '门店简介',
|
||||
benefitUsageRule: '权益券使用规则',
|
||||
latitude: '纬度',
|
||||
longitude: '经度',
|
||||
openTime: '营业开始',
|
||||
closeTime: '营业结束',
|
||||
openTime2: '第二段开始',
|
||||
closeTime2: '第二段结束',
|
||||
avgPrice: '人均费用',
|
||||
};
|
||||
|
||||
function fmtFieldValue(field: string, v: unknown): string {
|
||||
if (v == null || String(v).trim() === '') return '(空)';
|
||||
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
|
||||
return String(v);
|
||||
}
|
||||
if (field === 'envPhotoUrls' && Array.isArray(v)) {
|
||||
return `${v.length} 张`;
|
||||
}
|
||||
if (field === 'coverUrl') return '见对比图';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function InfoChangeImageDiff({
|
||||
field,
|
||||
live,
|
||||
proposed,
|
||||
}: {
|
||||
field: string;
|
||||
live: unknown;
|
||||
proposed: unknown;
|
||||
}) {
|
||||
const liveUrls =
|
||||
field === 'coverUrl'
|
||||
? [String(live || '').trim()].filter(Boolean)
|
||||
: Array.isArray(live)
|
||||
? live.map((u) => String(u || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const proposedUrls =
|
||||
field === 'coverUrl'
|
||||
? [String(proposed || '').trim()].filter(Boolean)
|
||||
: Array.isArray(proposed)
|
||||
? proposed.map((u) => String(u || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
||||
变更前
|
||||
</Typography.Text>
|
||||
{liveUrls.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{liveUrls.map((url) => (
|
||||
<Image key={`live-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">(空)</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
||||
变更后
|
||||
</Typography.Text>
|
||||
{proposedUrls.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{proposedUrls.map((url) => (
|
||||
<Image key={`new-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">(空)</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoChangeAuditPanel({
|
||||
initialRequestId,
|
||||
}: {
|
||||
@@ -425,7 +208,7 @@ function InfoChangeAuditPanel({
|
||||
render: (_, row) =>
|
||||
row.changedFields?.length
|
||||
? row.changedFields.map((f) => (
|
||||
<Tag key={f}>{INFO_CHANGE_FIELD_LABELS[f] ?? f}</Tag>
|
||||
<Tag key={f}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f}</Tag>
|
||||
))
|
||||
: '—',
|
||||
},
|
||||
@@ -542,17 +325,21 @@ function InfoChangeAuditPanel({
|
||||
{detail.diffs.map((d) => (
|
||||
<Descriptions.Item
|
||||
key={d.field}
|
||||
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
|
||||
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
|
||||
>
|
||||
<span>
|
||||
<Typography.Text delete type="secondary">
|
||||
{fmtFieldValue(d.field, d.live)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> → </Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{fmtFieldValue(d.field, d.proposed)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
|
||||
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
|
||||
) : (
|
||||
<span>
|
||||
<Typography.Text delete type="secondary">
|
||||
{fmtFieldValue(d.field, d.live)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> → </Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{fmtFieldValue(d.field, d.proposed)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
@@ -599,8 +386,8 @@ export default function StorePackageAuditsPage() {
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||||
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
|
||||
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
|
||||
|
||||
async function reload(nextPage = page, nextStatus = status) {
|
||||
setLoading(true);
|
||||
@@ -629,30 +416,23 @@ export default function StorePackageAuditsPage() {
|
||||
void reload(1, status);
|
||||
}, [status]);
|
||||
|
||||
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
|
||||
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
||||
const infoRequestId = searchParams.get('infoRequestId');
|
||||
useEffect(() => {
|
||||
const rid = searchParams.get('requestId');
|
||||
if (rid) void openDetail(rid);
|
||||
if (rid) {
|
||||
setActiveRequestId(rid);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
function openDetail(id: string) {
|
||||
setActiveRequestId(id);
|
||||
setDrawerTitle('套餐变更详情');
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${id}`);
|
||||
setDetail(data);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载详情失败');
|
||||
setDetailOpen(false);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||
@@ -672,14 +452,6 @@ export default function StorePackageAuditsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
||||
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||||
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||||
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||||
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||||
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||||
|
||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
{
|
||||
@@ -702,7 +474,7 @@ export default function StorePackageAuditsPage() {
|
||||
title: '操作',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => void openDetail(row.id)}>
|
||||
<Button type="link" onClick={() => openDetail(row.id)}>
|
||||
查看变更
|
||||
</Button>
|
||||
{row.status === 'PENDING' ? (
|
||||
@@ -781,90 +553,23 @@ export default function StorePackageAuditsPage() {
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
||||
title={drawerTitle}
|
||||
width={880}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' ? (
|
||||
<Space>
|
||||
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
setActiveId(detail.id);
|
||||
setRejectReason('');
|
||||
setRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
destroyOnClose
|
||||
>
|
||||
{detailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : detail ? (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{detail.rejectReason ? (
|
||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||
) : null}
|
||||
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
||||
<Typography.Text type="secondary">
|
||||
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Tag color="green">新增 {addedCount}</Tag>
|
||||
<Tag color="red">删除 {removedCount}</Tag>
|
||||
<Tag color="orange">变更 {changedCount}</Tag>
|
||||
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
||||
</Space>
|
||||
</Space>
|
||||
<div className="admin-package-audit-cols">
|
||||
<div className="admin-package-audit-col">
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
线上已审核套餐
|
||||
</Typography.Title>
|
||||
{(detail.livePackages ?? []).length ? (
|
||||
(detail.livePackages ?? []).map((pkg, index) => (
|
||||
<PackageDetailCard
|
||||
key={`live-${packageKey(pkg, index)}`}
|
||||
title={`套餐 ${index + 1}`}
|
||||
pkg={pkg}
|
||||
change={changeByKey.get(packageKey(pkg, index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-package-audit-col">
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
待审核套餐
|
||||
</Typography.Title>
|
||||
{(detail.packages ?? []).length ? (
|
||||
(detail.packages ?? []).map((pkg, index) => (
|
||||
<PackageDetailCard
|
||||
key={`pending-${packageKey(pkg, index)}`}
|
||||
title={`套餐 ${index + 1}`}
|
||||
pkg={pkg}
|
||||
change={changeByKey.get(packageKey(pkg, index))}
|
||||
changes={changesByKey.get(packageKey(pkg, index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
{activeRequestId ? (
|
||||
<StorePackageAuditPanel
|
||||
requestId={activeRequestId}
|
||||
onAudited={() => {
|
||||
setDetailOpen(false);
|
||||
void reload(page, status);
|
||||
}}
|
||||
onDetailLoaded={(d) => {
|
||||
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
@@ -892,3 +597,4 @@ export default function StorePackageAuditsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
@@ -46,6 +47,9 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||
import AdminStorePackagesSection, {
|
||||
type AdminStorePackagesHandle,
|
||||
} from '../components/AdminStorePackagesSection';
|
||||
import StorePackageAuditPanel, {
|
||||
auditStorePackageRequest,
|
||||
} from '../components/StorePackageAuditPanel';
|
||||
|
||||
const CREATE_STEPS = [
|
||||
{ title: '基本信息' },
|
||||
@@ -242,6 +246,8 @@ export default function StoresPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialCityId = searchParams.get('cityId') ?? '';
|
||||
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
||||
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
|
||||
const initialStoreId = searchParams.get('storeId') ?? '';
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||
@@ -249,6 +255,7 @@ export default function StoresPage() {
|
||||
const init: Record<string, string | boolean> = {};
|
||||
if (initialCityId) init.cityId = initialCityId;
|
||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||
if (initialAuditStatus) init.auditStatus = initialAuditStatus;
|
||||
return init;
|
||||
});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
||||
@@ -270,7 +277,11 @@ export default function StoresPage() {
|
||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState('basic');
|
||||
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
||||
const [packageRejectOpen, setPackageRejectOpen] = useState(false);
|
||||
const [packageRejectReason, setPackageRejectReason] = useState('');
|
||||
const [packageAuditing, setPackageAuditing] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
@@ -286,6 +297,7 @@ export default function StoresPage() {
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
||||
const deepLinkStoreOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
@@ -296,6 +308,12 @@ export default function StoresPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialAuditStatus) {
|
||||
form.setFieldsValue({ auditStatus: initialAuditStatus });
|
||||
}
|
||||
}, [form, initialAuditStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const cityId = searchParams.get('cityId') ?? '';
|
||||
const partnerId = searchParams.get('partnerId') ?? '';
|
||||
@@ -348,13 +366,18 @@ export default function StoresPage() {
|
||||
.map((n) => ({ value: n.id, label: n.name }));
|
||||
}, [categoryTree, editCategoryParentId]);
|
||||
|
||||
async function openStoreDetail(row: StoreRow) {
|
||||
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
|
||||
const [d, cats] = await Promise.all([
|
||||
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
||||
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
|
||||
]);
|
||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||
setDetail(d);
|
||||
setDetail({
|
||||
...d,
|
||||
pendingPackageAuditId: row.pendingPackageAuditId ?? d.pendingPackageAuditId,
|
||||
pendingInfoChangeId: row.pendingInfoChangeId ?? d.pendingInfoChangeId,
|
||||
});
|
||||
setDetailTab(opts?.tab || 'basic');
|
||||
const category = d.category && typeof d.category === 'object'
|
||||
? (d.category as { id?: string; parentId?: string | null })
|
||||
: null;
|
||||
@@ -441,6 +464,19 @@ export default function StoresPage() {
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialStoreId || deepLinkStoreOpenedRef.current || loading) return;
|
||||
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
|
||||
if (row) {
|
||||
deepLinkStoreOpenedRef.current = true;
|
||||
void openStoreDetail(row);
|
||||
} else if (data && (data.items?.length ?? 0) >= 0) {
|
||||
// 列表无该店时仍尝试直拉详情
|
||||
deepLinkStoreOpenedRef.current = true;
|
||||
void openStoreDetail({ id: initialStoreId } as StoreRow);
|
||||
}
|
||||
}, [data, initialStoreId, loading]);
|
||||
|
||||
async function saveStoreDetail() {
|
||||
if (!detail) return;
|
||||
setSaving(true);
|
||||
@@ -776,22 +812,13 @@ export default function StoresPage() {
|
||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
||||
{row.pendingPackageAuditId ? (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||
>
|
||||
审核套餐
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||
>
|
||||
对比
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => void openStoreDetail(row, { tab: 'packages' })}
|
||||
>
|
||||
审核套餐
|
||||
</Button>
|
||||
) : null}
|
||||
{row.pendingInfoChangeId ? (
|
||||
<>
|
||||
@@ -903,7 +930,7 @@ export default function StoresPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="门店详情" width={880} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space wrap>
|
||||
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||
@@ -940,6 +967,41 @@ export default function StoresPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{detail.pendingPackageAuditId ? (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
loading={packageAuditing}
|
||||
onClick={async () => {
|
||||
setPackageAuditing(true);
|
||||
try {
|
||||
await auditStorePackageRequest(String(detail.pendingPackageAuditId), 'APPROVE');
|
||||
message.success('套餐已通过');
|
||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '套餐审核失败');
|
||||
} finally {
|
||||
setPackageAuditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
通过套餐
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
ghost
|
||||
loading={packageAuditing}
|
||||
onClick={() => {
|
||||
setPackageRejectReason('');
|
||||
setPackageRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回套餐
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Select value={String(detail.status)} style={{ width: 120 }}
|
||||
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
@@ -954,6 +1016,8 @@ export default function StoresPage() {
|
||||
{detail && (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Tabs
|
||||
activeKey={detailTab}
|
||||
onChange={setDetailTab}
|
||||
destroyInactiveTabPane={false}
|
||||
items={[
|
||||
{
|
||||
@@ -1202,15 +1266,76 @@ export default function StoresPage() {
|
||||
},
|
||||
{
|
||||
key: 'packages',
|
||||
label: '套餐',
|
||||
label: detail.pendingPackageAuditId ? (
|
||||
<Badge dot offset={[4, 0]}>
|
||||
套餐
|
||||
</Badge>
|
||||
) : (
|
||||
'套餐'
|
||||
),
|
||||
forceRender: true,
|
||||
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
|
||||
children: (
|
||||
<>
|
||||
{detail.pendingPackageAuditId ? (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
待审核套餐变更
|
||||
</Typography.Title>
|
||||
<StorePackageAuditPanel
|
||||
requestId={String(detail.pendingPackageAuditId)}
|
||||
showActions
|
||||
onAudited={() => {
|
||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal
|
||||
title="驳回套餐变更"
|
||||
open={packageRejectOpen}
|
||||
confirmLoading={packageAuditing}
|
||||
onCancel={() => setPackageRejectOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail?.pendingPackageAuditId) return;
|
||||
if (!packageRejectReason.trim()) {
|
||||
message.warning('请填写驳回原因');
|
||||
return;
|
||||
}
|
||||
setPackageAuditing(true);
|
||||
try {
|
||||
await auditStorePackageRequest(
|
||||
String(detail.pendingPackageAuditId),
|
||||
'REJECT',
|
||||
packageRejectReason.trim(),
|
||||
);
|
||||
message.success('套餐已驳回');
|
||||
setPackageRejectOpen(false);
|
||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '套餐驳回失败');
|
||||
} finally {
|
||||
setPackageAuditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={packageRejectReason}
|
||||
placeholder="驳回原因"
|
||||
onChange={(e) => setPackageRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="新建门店"
|
||||
open={createOpen}
|
||||
|
||||
@@ -249,15 +249,70 @@ export default function SystemSettingsPage() {
|
||||
|
||||
const collapseItems = useMemo(() => {
|
||||
if (!meta) return [];
|
||||
return meta.groups.map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
forceRender: true,
|
||||
children: (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{meta.fields
|
||||
.filter((f) => f.group === group.key)
|
||||
.map((f) =>
|
||||
const SHARE_SUBGROUP_LABELS: Record<string, string> = {
|
||||
global: '全局',
|
||||
home: '首页',
|
||||
stores: '门店列表',
|
||||
storeDetail: '门店详情',
|
||||
benefit: '权益页',
|
||||
mine: '我的',
|
||||
productDetail: '商品详情',
|
||||
orderDetail: '订单详情',
|
||||
};
|
||||
const SHARE_SUBGROUP_ORDER = [
|
||||
'global',
|
||||
'home',
|
||||
'stores',
|
||||
'storeDetail',
|
||||
'benefit',
|
||||
'mine',
|
||||
'productDetail',
|
||||
'orderDetail',
|
||||
];
|
||||
|
||||
return meta.groups.map((group) => {
|
||||
const fields = meta.fields.filter((f) => f.group === group.key);
|
||||
const hasSubgroups = fields.some((f) => f.subgroup);
|
||||
|
||||
let children: ReactNode;
|
||||
if (group.key === 'wechat_mini_share' && hasSubgroups) {
|
||||
const bySub = new Map<string, SystemConfigFieldMeta[]>();
|
||||
for (const f of fields) {
|
||||
const sk = f.subgroup || 'global';
|
||||
if (!bySub.has(sk)) bySub.set(sk, []);
|
||||
bySub.get(sk)!.push(f);
|
||||
}
|
||||
const orderedKeys = [
|
||||
...SHARE_SUBGROUP_ORDER.filter((k) => bySub.has(k)),
|
||||
...[...bySub.keys()].filter((k) => !SHARE_SUBGROUP_ORDER.includes(k)),
|
||||
];
|
||||
children = (
|
||||
<Collapse
|
||||
defaultActiveKey={[]}
|
||||
items={orderedKeys.map((sk) => ({
|
||||
key: sk,
|
||||
label: SHARE_SUBGROUP_LABELS[sk] || sk,
|
||||
forceRender: true,
|
||||
children: (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{(bySub.get(sk) ?? []).map((f) =>
|
||||
renderField(
|
||||
f,
|
||||
meta.configuredSecrets,
|
||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||
) : undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
children = (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{fields.map((f) =>
|
||||
renderField(
|
||||
f,
|
||||
meta.configuredSecrets,
|
||||
@@ -266,9 +321,17 @@ export default function SystemSettingsPage() {
|
||||
) : undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
forceRender: true,
|
||||
children,
|
||||
};
|
||||
});
|
||||
}, [meta, mockSmsEnabled, loading]);
|
||||
|
||||
async function onSave() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
@@ -21,8 +22,13 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WECOM_PUSH_CONDITION_GROUPS,
|
||||
WECOM_PUSH_CONDITION_LABELS,
|
||||
WECOM_TEMPLATE_EVENT_KEYS,
|
||||
WECOM_TEMPLATE_EVENT_LABELS,
|
||||
WECOM_TEMPLATE_PLACEHOLDERS,
|
||||
type WecomMessagePushDto,
|
||||
type WecomPushCondition,
|
||||
type WecomPushTemplateDto,
|
||||
type WecomTemplateEventKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
@@ -78,7 +84,7 @@ function WecomPushConditionPicker({
|
||||
);
|
||||
}
|
||||
|
||||
export default function WecomMessagePushesPage() {
|
||||
function PushRoutesTab() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
@@ -257,11 +263,9 @@ export default function WecomMessagePushesPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
||||
<>
|
||||
<Typography.Paragraph type="secondary">
|
||||
配置群机器人 Webhook 多实例,按推送条件分发运营告警、技术支持工单、开发任务派发等消息。运行时不再读取
|
||||
.env 中的 Webhook URL。
|
||||
配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
@@ -332,10 +336,10 @@ export default function WecomMessagePushesPage() {
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="如:运营告警" />
|
||||
<Input placeholder="如:业务待办通知群" />
|
||||
</Form.Item>
|
||||
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
||||
<OssUpload />
|
||||
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="webhookUrl"
|
||||
@@ -400,6 +404,208 @@ export default function WecomMessagePushesPage() {
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatesTab() {
|
||||
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
|
||||
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [preview, setPreview] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
|
||||
setTemplates(list);
|
||||
setEventKey((prev) => {
|
||||
const current = list.find((t) => t.eventKey === prev) ?? list[0];
|
||||
if (current) {
|
||||
form.setFieldsValue({
|
||||
title: current.title,
|
||||
body: current.body,
|
||||
handleLabel: current.handleLabel,
|
||||
});
|
||||
return current.eventKey;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载模板失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function selectEvent(key: WecomTemplateEventKey) {
|
||||
setEventKey(key);
|
||||
const row = templates.find((t) => t.eventKey === key);
|
||||
if (row) {
|
||||
form.setFieldsValue({
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
handleLabel: row.handleLabel,
|
||||
});
|
||||
setPreview('');
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await request<WecomPushTemplateDto>(
|
||||
`/admin/wecom-push-templates/${eventKey}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
},
|
||||
);
|
||||
message.success('模板已保存');
|
||||
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await request<WecomPushTemplateDto>(
|
||||
`/admin/wecom-push-templates/${eventKey}/reset`,
|
||||
{ method: 'POST', body: '{}' },
|
||||
);
|
||||
message.success('已恢复默认文案');
|
||||
form.setFieldsValue({
|
||||
title: updated.title,
|
||||
body: updated.body,
|
||||
handleLabel: updated.handleLabel,
|
||||
});
|
||||
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
||||
setPreview('');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '恢复失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSend() {
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await request<{ ok: boolean; message: string; preview: string }>(
|
||||
`/admin/wecom-push-templates/${eventKey}/test`,
|
||||
{ method: 'POST', body: '{}' },
|
||||
);
|
||||
setPreview(res.preview || '');
|
||||
message.success(res.message || '已发送');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '测试失败');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Paragraph type="secondary">
|
||||
每种业务事件全站一份文案,使用 {'{{orderNo}}'} 等形式占位。快链由系统注入 {'{{handleUrl}}'}
|
||||
;须在「推送路由」中勾选对应条件并配置 Webhook 才会发出。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space align="start" style={{ width: '100%' }} size={24} wrap>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<Typography.Text strong>事件</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
|
||||
<div key={k} style={{ marginBottom: 4 }}>
|
||||
<Button
|
||||
type={k === eventKey ? 'primary' : 'text'}
|
||||
size="small"
|
||||
onClick={() => selectEvent(k)}
|
||||
block
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
{WECOM_TEMPLATE_EVENT_LABELS[k]}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 360 }}>
|
||||
<Form form={form} layout="vertical" disabled={loading}>
|
||||
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="body"
|
||||
label="正文(企微 markdown)"
|
||||
rules={[{ required: true }]}
|
||||
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
|
||||
>
|
||||
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
|
||||
<Input placeholder="去处理" />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
|
||||
<Button loading={saving}>恢复默认</Button>
|
||||
</Popconfirm>
|
||||
<Button loading={testing} onClick={() => void testSend()}>
|
||||
用示例数据测试推送
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
{preview ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Text strong>预览</Typography.Text>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 12,
|
||||
background: '#f5f5f5',
|
||||
whiteSpace: 'pre-wrap',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WecomMessagePushesPage() {
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
|
||||
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.mp3' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
@@ -17,6 +17,11 @@ type Props = {
|
||||
accept?: string;
|
||||
/** 计量单位文案,如「张」「个」 */
|
||||
unit?: string;
|
||||
/**
|
||||
* stack:缩略图 + 下方独立上传按钮(合同等)
|
||||
* grid:九宫格,末尾「+」格上传,无独立大按钮(环境图)
|
||||
*/
|
||||
variant?: 'stack' | 'grid';
|
||||
};
|
||||
|
||||
function isCancelError(msg: string): boolean {
|
||||
@@ -42,6 +47,7 @@ export default function MultiOssUploadField({
|
||||
label,
|
||||
accept = 'image/*',
|
||||
unit = '张',
|
||||
variant = 'stack',
|
||||
}: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const pickingRef = useRef(false);
|
||||
@@ -53,6 +59,7 @@ export default function MultiOssUploadField({
|
||||
const onChangeRef = useRef(onChange);
|
||||
const remaining = Math.max(0, maxCount - urls.length);
|
||||
const inWechat = isWechatEnv();
|
||||
const isGrid = variant === 'grid';
|
||||
|
||||
useEffect(() => {
|
||||
urlsRef.current = urls;
|
||||
@@ -126,11 +133,78 @@ export default function MultiOssUploadField({
|
||||
}
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (inWechat) void pickWechat();
|
||||
else inputRef.current?.click();
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
if (disabled) return;
|
||||
onChange?.(urls.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
const thumb = (url: string, index: number) => (
|
||||
<div key={`${url}-${index}`} className={isGrid ? 'partner-upload-grid-thumb' : undefined} style={isGrid ? undefined : { position: 'relative', width: 88, height: 88 }}>
|
||||
{isPdf(url) ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: isGrid ? '100%' : 88,
|
||||
height: isGrid ? '100%' : 88,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: '#f7f7f7',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
|
||||
description
|
||||
</span>
|
||||
<span className="text-muted">PDF</span>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{
|
||||
width: isGrid ? '100%' : 88,
|
||||
height: isGrid ? '100%' : 88,
|
||||
objectFit: 'cover',
|
||||
borderRadius: isGrid ? 12 : 8,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-packages-remove"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
margin: 0,
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
color: '#fff',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onClick={() => removeAt(index)}
|
||||
>
|
||||
删
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="partner-oss-upload">
|
||||
<input
|
||||
@@ -146,86 +220,49 @@ export default function MultiOssUploadField({
|
||||
}}
|
||||
/>
|
||||
|
||||
{urls.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{urls.map((url, index) => (
|
||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
||||
{isPdf(url) ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: 88,
|
||||
height: 88,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: '#f7f7f7',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
|
||||
description
|
||||
</span>
|
||||
<span className="text-muted">PDF</span>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-packages-remove"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
margin: 0,
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
color: '#fff',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onClick={() => removeAt(index)}
|
||||
>
|
||||
删
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{isGrid ? (
|
||||
<div className="partner-upload-grid">
|
||||
{urls.map((url, index) => thumb(url, index))}
|
||||
{remaining > 0 && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||
disabled={uploading}
|
||||
onClick={openPicker}
|
||||
aria-label={uploading ? '上传中' : `添加${unit}(${urls.length}/${maxCount})`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||
{uploading ? 'hourglass_top' : 'add'}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
onClick={() => {
|
||||
if (inWechat) void pickWechat();
|
||||
else inputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
||||
</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||
{uploading
|
||||
? '上传中…'
|
||||
: remaining <= 0
|
||||
? `已达上限 ${maxCount}${unit}`
|
||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{urls.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{urls.map((url, index) => thumb(url, index))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
onClick={openPicker}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
||||
</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||
{uploading
|
||||
? '上传中…'
|
||||
: remaining <= 0
|
||||
? `已达上限 ${maxCount}${unit}`
|
||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error ? (
|
||||
<p className="partner-form-error" role="alert">
|
||||
{error}
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function ProxyOrderPage() {
|
||||
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
||||
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
||||
const [productId, setProductId] = useState(draft0.productId);
|
||||
const [skuId, setSkuId] = useState('');
|
||||
const [quantity, setQuantity] = useState(draft0.quantity);
|
||||
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
||||
@@ -156,9 +157,37 @@ export default function ProxyOrderPage() {
|
||||
]);
|
||||
|
||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
||||
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||
const selectedSku =
|
||||
skuOptions.find((s) => s.id === skuId) ||
|
||||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
||||
skuOptions[0];
|
||||
const allowOnline = selectedSku
|
||||
? selectedSku.allowOnlinePurchase !== false
|
||||
: selectedProduct
|
||||
? selectedProduct.allowOnlinePurchase !== false
|
||||
: true;
|
||||
const allowOnSite = selectedSku
|
||||
? !!selectedSku.allowOnSitePickup
|
||||
: !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedSku
|
||||
? selectedSku.allowCrossCityDelivery !== false
|
||||
: selectedProduct
|
||||
? selectedProduct.allowCrossCityDelivery !== false
|
||||
: true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProduct) return;
|
||||
const def =
|
||||
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
||||
skuOptions.find((s) => s.isDefault) ||
|
||||
skuOptions[0];
|
||||
if (def && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
||||
setSkuId(def.id);
|
||||
if (def.saleUnit === 'BOX') setQuantity(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [productId, selectedProduct?.id, options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProduct) return;
|
||||
@@ -184,6 +213,7 @@ export default function ProxyOrderPage() {
|
||||
productId,
|
||||
quantity,
|
||||
deliveryMode,
|
||||
skuId: skuId || undefined,
|
||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||
}),
|
||||
@@ -200,7 +230,7 @@ export default function ProxyOrderPage() {
|
||||
.finally(() => setPreviewLoading(false));
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
|
||||
}, [productId, skuId, quantity, deliveryMode, region?.city, region?.district]);
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||
@@ -359,6 +389,7 @@ export default function ProxyOrderPage() {
|
||||
productId,
|
||||
quantity,
|
||||
promoCodeId: promoCodeId || undefined,
|
||||
skuId: skuId || undefined,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -524,8 +555,35 @@ export default function ProxyOrderPage() {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">规格</label>
|
||||
<div className="partner-input-wrap">
|
||||
<select
|
||||
className="partner-input"
|
||||
value={skuId}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
setSkuId(id);
|
||||
const sku = skuOptions.find((s) => s.id === id);
|
||||
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
||||
}}
|
||||
>
|
||||
{skuOptions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.specText || '默认'} · ¥{fmtMoney(s.price)} ·{' '}
|
||||
{s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">数量</label>
|
||||
<label className="partner-form-label">
|
||||
{selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'}
|
||||
</label>
|
||||
<div className="partner-input-wrap">
|
||||
<input
|
||||
className="partner-input"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# 企业微信客服链接(覆盖 shared-types 默认值)
|
||||
# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>杜康好客</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600&family=Inter:wght@500&family=Manrope:wght@600;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "@dukang/h5-user",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"build": "vite build",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dukang/client-logging": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/weixin-sdk": "workspace:*",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ayPJ4CQqbUcec3jX
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 329 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 301 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 356 KiB |
@@ -1,75 +0,0 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import LegalPage from './pages/LegalPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import ProductDetailPage from './pages/ProductDetailPage';
|
||||
import OrderConfirmPage from './pages/OrderConfirmPage';
|
||||
import AddressListPage from './pages/AddressListPage';
|
||||
import AddressEditPage from './pages/AddressEditPage';
|
||||
import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
import StoreListPage from './pages/StoreListPage';
|
||||
import StoreDetailPage from './pages/StoreDetailPage';
|
||||
import BenefitPage from './pages/BenefitPage';
|
||||
import BenefitDetailPage from './pages/BenefitDetailPage';
|
||||
import MinePage from './pages/MinePage';
|
||||
import RedeemPage from './pages/RedeemPage';
|
||||
import RedeemCodePage from './pages/RedeemCodePage';
|
||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import PayPage from './pages/PayPage';
|
||||
import CustomerServicePage from './pages/CustomerServicePage';
|
||||
import AfterSalePage from './pages/AfterSalePage';
|
||||
import AfterSaleListPage from './pages/AfterSaleListPage';
|
||||
import InvoiceApplyPage from './pages/InvoiceApplyPage';
|
||||
import InvoiceListPage from './pages/InvoiceListPage';
|
||||
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||
import { capturePromoFromUrl } from './lib/promo';
|
||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||
|
||||
function PromoBootstrap() {
|
||||
useEffect(() => {
|
||||
capturePromoFromUrl();
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<UserSessionProvider>
|
||||
<PromoBootstrap />
|
||||
<WechatShareBootstrap />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
|
||||
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/benefit" element={<BenefitPage />} />
|
||||
<Route path="/mine" element={<MinePage />} />
|
||||
</Route>
|
||||
<Route path="/product/:id" element={<ProductDetailPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/order/confirm" element={<OrderConfirmPage />} />
|
||||
<Route path="/pay" element={<PayPage />} />
|
||||
<Route path="/customer-service" element={<CustomerServicePage />} />
|
||||
<Route path="/after-sale" element={<AfterSalePage />} />
|
||||
<Route path="/after-sale/list" element={<AfterSaleListPage />} />
|
||||
<Route path="/invoices" element={<InvoiceListPage />} />
|
||||
<Route path="/invoices/apply" element={<InvoiceApplyPage />} />
|
||||
<Route path="/addresses" element={<AddressListPage />} />
|
||||
<Route path="/addresses/new" element={<AddressEditPage />} />
|
||||
<Route path="/addresses/:id/edit" element={<AddressEditPage />} />
|
||||
<Route path="/orders" element={<OrderListPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="/benefit/:id" element={<BenefitDetailPage />} />
|
||||
<Route path="/redeem" element={<RedeemPage />} />
|
||||
<Route path="/redeem/code" element={<RedeemCodePage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</UserSessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
type AppToastProps = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export default function AppToast({ message }: AppToastProps) {
|
||||
if (!message) return null;
|
||||
return <div className="app-toast">{message}</div>;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { track } from '../lib/analytics';
|
||||
import {
|
||||
getCustomerServicePhone,
|
||||
loadCustomerServicePhone,
|
||||
openWecomCustomerService,
|
||||
} from '../lib/customer-service';
|
||||
|
||||
type ContactCustomerSheetProps = {
|
||||
orderId?: string;
|
||||
orderNo?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
|
||||
const [phone, setPhone] = useState(getCustomerServicePhone);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCustomerServicePhone().then(setPhone);
|
||||
}, []);
|
||||
|
||||
const tel = phone.replace(/-/g, '');
|
||||
|
||||
function openPhone() {
|
||||
track('cs_contact', { type: 'phone', orderId });
|
||||
window.location.href = `tel:${tel}`;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function openOnline() {
|
||||
track('cs_contact', { type: 'wecom_kf', orderId });
|
||||
if (openWecomCustomerService()) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contact-customer-overlay" onClick={onClose}>
|
||||
<div className="contact-customer-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="contact-customer-head">
|
||||
<h3>联系客服</h3>
|
||||
<button type="button" className="contact-customer-close" aria-label="关闭" onClick={onClose}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="contact-customer-options">
|
||||
<button type="button" className="contact-customer-option" onClick={openPhone}>
|
||||
<div className="contact-customer-option-icon">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
</div>
|
||||
<div className="contact-customer-option-body">
|
||||
<p className="contact-customer-option-title">拨打总部客服电话</p>
|
||||
<p className="contact-customer-option-sub">{phone}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="contact-customer-option" onClick={openOnline}>
|
||||
<div className="contact-customer-option-icon">
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
</div>
|
||||
<div className="contact-customer-option-body">
|
||||
<p className="contact-customer-option-title">在线客服</p>
|
||||
<p className="contact-customer-option-sub">专业客服实时解答</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" className="contact-customer-cancel" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import { SmsScene } from '@dukang/shared-types';
|
||||
import { bindPhone, request, type SessionPayload } from '../lib/api';
|
||||
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||
import { useSmsCode } from '../lib/use-sms-code';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
|
||||
type PhoneVerifySheetProps = {
|
||||
open: boolean;
|
||||
/** 打开时预填手机号(如收货地址中的手机号) */
|
||||
defaultPhone?: string;
|
||||
mode?: 'bind_phone' | 'wechat_bind_phone';
|
||||
wxSessionKey?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export default function PhoneVerifySheet({
|
||||
open,
|
||||
defaultPhone,
|
||||
mode = 'bind_phone',
|
||||
wxSessionKey,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: PhoneVerifySheetProps) {
|
||||
const { applySession } = useUserSession();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { sendCode, sending, codeCooldown, sentHint, error, setError, clearMessages } = useSmsCode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPhone('');
|
||||
setCode('');
|
||||
setError('');
|
||||
clearMessages();
|
||||
return;
|
||||
}
|
||||
if (defaultPhone) {
|
||||
const normalized = normalizePhoneInput(defaultPhone);
|
||||
if (validateMobilePhone(normalized).ok) {
|
||||
setPhone(normalized);
|
||||
}
|
||||
}
|
||||
}, [open, defaultPhone, clearMessages, setError]);
|
||||
|
||||
async function onSendCode() {
|
||||
clearMessages();
|
||||
await sendCode(phone, SmsScene.BIND_PHONE);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const phoneCheck = validateMobilePhone(phone);
|
||||
if (!phoneCheck.ok) {
|
||||
setError(phoneCheck.message ?? '请输入正确的手机号码');
|
||||
return;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
setError('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
if (mode === 'wechat_bind_phone') {
|
||||
if (!wxSessionKey) {
|
||||
setError('微信会话已过期,请重新授权');
|
||||
return;
|
||||
}
|
||||
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ wxSessionKey, phone, code }),
|
||||
});
|
||||
if (data.accessToken) {
|
||||
applySession({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken ?? '',
|
||||
deviceKey: data.deviceKey,
|
||||
phoneVerified: !!data.phoneVerified,
|
||||
user: data.user as SessionPayload['user'],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const session = await bindPhone(phone, code);
|
||||
applySession(session as SessionPayload);
|
||||
}
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '验证失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const sheetTitle = title ?? (mode === 'wechat_bind_phone' ? '绑定手机号' : '验证手机号');
|
||||
const sheetDesc =
|
||||
description ??
|
||||
(mode === 'wechat_bind_phone'
|
||||
? '建议绑定手机号,便于订单通知与售后;关闭可跳过继续支付'
|
||||
: '建议绑定手机号,便于订单通知与售后;关闭可跳过继续下单');
|
||||
|
||||
return (
|
||||
<div className="phone-verify-overlay" role="dialog" aria-modal="true">
|
||||
<button type="button" className="phone-verify-backdrop" aria-label="关闭" onClick={onClose} />
|
||||
<div className="phone-verify-sheet">
|
||||
<h3 className="phone-verify-title">{sheetTitle}</h3>
|
||||
<p className="phone-verify-desc">{sheetDesc}</p>
|
||||
<div className="login-field">
|
||||
<span className="login-field-prefix">+86</span>
|
||||
<input
|
||||
type="tel"
|
||||
className="login-field-input"
|
||||
placeholder="请输入手机号"
|
||||
maxLength={11}
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(normalizePhoneInput(e.target.value));
|
||||
setError('');
|
||||
clearMessages();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="login-field-input"
|
||||
placeholder="请输入验证码"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
|
||||
disabled={codeCooldown > 0 || sending}
|
||||
onClick={onSendCode}
|
||||
>
|
||||
{sending
|
||||
? '发送中...'
|
||||
: codeCooldown > 0
|
||||
? `${codeCooldown}s 后重新获取`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{(error || sentHint) && (
|
||||
<p className={`login-msg${sentHint && !error ? ' login-msg--hint' : ''}`}>{error || sentHint}</p>
|
||||
)}
|
||||
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
|
||||
{loading ? '验证中...' : mode === 'wechat_bind_phone' ? '确认绑定' : '确认验证'}
|
||||
</button>
|
||||
<button type="button" className="phone-verify-skip" onClick={onClose}>
|
||||
暂不绑定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
|
||||
type Props = {
|
||||
images: string[];
|
||||
alt: string;
|
||||
variant?: 'home' | 'detail' | 'store';
|
||||
};
|
||||
|
||||
export default function ProductCarousel({ images, alt, variant = 'home' }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const slides = images.length > 0 ? images : [''];
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollRef.current;
|
||||
if (!el || el.offsetWidth === 0) return;
|
||||
setActiveIndex(Math.round(el.scrollLeft / el.offsetWidth));
|
||||
}
|
||||
|
||||
const wrapClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel-wrap'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel-wrap'
|
||||
: 'home-carousel-wrap';
|
||||
const trackClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel'
|
||||
: 'home-carousel';
|
||||
const dotClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel-dot'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel-dot'
|
||||
: 'home-carousel-dot';
|
||||
const itemClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel-item'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel-item'
|
||||
: 'home-carousel-item';
|
||||
const placeholderClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel-placeholder'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel-placeholder'
|
||||
: 'home-carousel-placeholder';
|
||||
|
||||
return (
|
||||
<div className={wrapClass}>
|
||||
<div className={trackClass} ref={scrollRef} onScroll={onScroll}>
|
||||
{slides.map((src, i) => (
|
||||
<div key={i} className={itemClass}>
|
||||
{src ? (
|
||||
<AppImage src={src} alt={alt} wrapperClassName="app-image--fill" />
|
||||
) : (
|
||||
<div className={placeholderClass} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{slides.length > 1 && (
|
||||
<div className={variant === 'store' ? 'store-detail-carousel-dots' : variant === 'detail' ? 'detail-carousel-dots' : 'home-carousel-dots'}>
|
||||
{slides.map((_, i) => (
|
||||
<span key={i} className={`${dotClass}${i === activeIndex ? ' active' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
REGION_ALL,
|
||||
getCities,
|
||||
getCitiesForPicker,
|
||||
getDistricts,
|
||||
getDistrictsForPicker,
|
||||
getProvincesForPicker,
|
||||
normalizeRegionSelection,
|
||||
toCityLevelRegion,
|
||||
type RegionSelection,
|
||||
} from '../lib/region-data';
|
||||
|
||||
type RegionPickerProps = {
|
||||
open: boolean;
|
||||
value: RegionSelection;
|
||||
onClose: () => void;
|
||||
onConfirm: (region: RegionSelection) => void;
|
||||
/** 2 = 仅省/市(门店列表);3 = 省/市/区(地址等) */
|
||||
levels?: 2 | 3;
|
||||
};
|
||||
|
||||
type PickerLevel = 'province' | 'city' | 'district';
|
||||
|
||||
const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [
|
||||
{ key: 'province', label: '省份' },
|
||||
{ key: 'city', label: '城市' },
|
||||
{ key: 'district', label: '区县' },
|
||||
];
|
||||
|
||||
function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel {
|
||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
||||
if (levels === 2) {
|
||||
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
|
||||
}
|
||||
if (normalized.district && normalized.district !== REGION_ALL) return 'district';
|
||||
if (normalized.city && normalized.city !== REGION_ALL) return 'city';
|
||||
return 'province';
|
||||
}
|
||||
|
||||
function tabLabel(tab: PickerLevel, draft: RegionSelection, fallback: string) {
|
||||
if (tab === 'province') {
|
||||
return draft.province && draft.province !== REGION_ALL ? draft.province : fallback;
|
||||
}
|
||||
if (tab === 'city') {
|
||||
return draft.city && draft.city !== REGION_ALL ? draft.city : fallback;
|
||||
}
|
||||
return draft.district && draft.district !== REGION_ALL ? draft.district : fallback;
|
||||
}
|
||||
|
||||
export default function RegionPicker({
|
||||
open,
|
||||
value,
|
||||
onClose,
|
||||
onConfirm,
|
||||
levels = 3,
|
||||
}: RegionPickerProps) {
|
||||
const [draft, setDraft] = useState<RegionSelection>(value);
|
||||
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
||||
setDraft(normalized);
|
||||
setActiveTab(initialTab(value, levels));
|
||||
}, [open, value, levels]);
|
||||
|
||||
const listItems = useMemo(() => {
|
||||
if (activeTab === 'province') return getProvincesForPicker();
|
||||
if (activeTab === 'city') return getCitiesForPicker(draft.province);
|
||||
return getDistrictsForPicker(draft.province, draft.city);
|
||||
}, [activeTab, draft.province, draft.city]);
|
||||
|
||||
const selectedValue =
|
||||
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
|
||||
|
||||
const canConfirm =
|
||||
levels === 2
|
||||
? Boolean(draft.province && draft.city)
|
||||
: Boolean(draft.province && draft.city && draft.district);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
scrollActiveIntoView(listRef.current, selectedValue);
|
||||
}, [open, activeTab, selectedValue, listItems.length]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function scrollActiveIntoView(container: HTMLDivElement | null, label: string) {
|
||||
if (!container || !label) return;
|
||||
const active = container.querySelector<HTMLElement>(`[data-label="${CSS.escape(label)}"]`);
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function selectProvince(province: string) {
|
||||
if (province === REGION_ALL) {
|
||||
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
|
||||
setActiveTab('city');
|
||||
return;
|
||||
}
|
||||
const nextCities = getCities(province);
|
||||
const city = nextCities[0] ?? '';
|
||||
if (levels === 2) {
|
||||
setDraft({
|
||||
province,
|
||||
city,
|
||||
district: REGION_ALL,
|
||||
});
|
||||
setActiveTab('city');
|
||||
return;
|
||||
}
|
||||
const nextDistricts = getDistricts(province, city);
|
||||
setDraft({
|
||||
province,
|
||||
city,
|
||||
district: nextDistricts[0] ?? '',
|
||||
});
|
||||
setActiveTab('city');
|
||||
}
|
||||
|
||||
function selectCity(city: string) {
|
||||
if (city === REGION_ALL) {
|
||||
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
|
||||
if (levels === 3) setActiveTab('district');
|
||||
return;
|
||||
}
|
||||
if (levels === 2) {
|
||||
setDraft({
|
||||
...draft,
|
||||
city,
|
||||
district: REGION_ALL,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nextDistricts = getDistricts(draft.province, city);
|
||||
setDraft({
|
||||
...draft,
|
||||
city,
|
||||
district: nextDistricts[0] ?? '',
|
||||
});
|
||||
setActiveTab('district');
|
||||
}
|
||||
|
||||
function selectDistrict(district: string) {
|
||||
setDraft({ ...draft, district });
|
||||
}
|
||||
|
||||
function onSelectItem(item: string) {
|
||||
if (activeTab === 'province') selectProvince(item);
|
||||
else if (activeTab === 'city') selectCity(item);
|
||||
else selectDistrict(item);
|
||||
}
|
||||
|
||||
function onTabClick(tab: PickerLevel) {
|
||||
if (tab === 'city' && !draft.province) return;
|
||||
if (tab === 'district' && (!draft.province || !draft.city)) return;
|
||||
setActiveTab(tab);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!canConfirm) return;
|
||||
const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft);
|
||||
onConfirm(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="region-picker-overlay" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="region-picker-sheet"
|
||||
role="dialog"
|
||||
aria-label="选择地区"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="region-picker-toolbar">
|
||||
<div className="region-picker-tabs" role="tablist">
|
||||
{tabs.map((tab) => {
|
||||
const disabled =
|
||||
(tab.key === 'city' && !draft.province) ||
|
||||
(tab.key === 'district' && (!draft.province || !draft.city));
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
disabled={disabled}
|
||||
className={`region-picker-tab${activeTab === tab.key ? ' active' : ''}`}
|
||||
onClick={() => onTabClick(tab.key)}
|
||||
>
|
||||
{tabLabel(tab.key, draft, tab.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`region-picker-confirm${canConfirm ? ' ready' : ''}`}
|
||||
disabled={!canConfirm}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="region-picker-list" ref={listRef}>
|
||||
{listItems.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
data-label={item}
|
||||
className={`region-picker-option${selectedValue === item ? ' selected' : ''}${
|
||||
item === REGION_ALL ? ' region-picker-option--all' : ''
|
||||
}`}
|
||||
onClick={() => onSelectItem(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
type SubPageHeaderProps = {
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export default function SubPageHeader({ title, onBack }: SubPageHeaderProps) {
|
||||
return (
|
||||
<header className="sub-page-header" aria-label={title}>
|
||||
<button type="button" className="sub-page-header-back" aria-label="返回" onClick={onBack}>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type TabMainHeaderProps = {
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function TabMainHeader({ title, extra, className = '' }: TabMainHeaderProps) {
|
||||
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
|
||||
if (!extra) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={`tab-main-header${className ? ` ${className}` : ''}`} aria-label={title}>
|
||||
{extra ? <div className="tab-main-header-extra">{extra}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { applyDefaultWechatShare } from '../lib/wechat-share';
|
||||
|
||||
/** 路由变化时刷新微信右上角分享卡片 */
|
||||
export default function WechatShareBootstrap() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
void applyDefaultWechatShare().catch(() => {});
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
bootstrapSession,
|
||||
clearAuth,
|
||||
ensureSession,
|
||||
getDeviceKey,
|
||||
request,
|
||||
saveSession,
|
||||
type SessionPayload,
|
||||
type UserProfile,
|
||||
} from '../lib/api';
|
||||
import { touchPromoIfNeeded } from '../lib/promo';
|
||||
|
||||
type UserSessionContextValue = {
|
||||
ready: boolean;
|
||||
profile: UserProfile | null;
|
||||
phoneVerified: boolean;
|
||||
applySession: (session: SessionPayload) => void;
|
||||
refreshProfile: () => Promise<void>;
|
||||
resetSession: () => Promise<void>;
|
||||
};
|
||||
|
||||
const UserSessionContext = createContext<UserSessionContextValue | null>(null);
|
||||
|
||||
export function UserSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [phoneVerified, setPhoneVerified] = useState(false);
|
||||
|
||||
const applySession = useCallback((session: SessionPayload) => {
|
||||
saveSession(session);
|
||||
if (session.user) setProfile(session.user);
|
||||
setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified);
|
||||
}, []);
|
||||
|
||||
const refreshProfile = useCallback(async () => {
|
||||
const me = await request<UserProfile>('USER_H5', '/auth/me');
|
||||
setProfile(me);
|
||||
setPhoneVerified(!!me.phoneVerified);
|
||||
}, []);
|
||||
|
||||
const resetSession = useCallback(async () => {
|
||||
clearAuth();
|
||||
const session = await bootstrapSession();
|
||||
applySession(session);
|
||||
}, [applySession]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const session = await ensureSession();
|
||||
if (cancelled) return;
|
||||
applySession(session);
|
||||
if (!session.user) {
|
||||
await refreshProfile();
|
||||
}
|
||||
await touchPromoIfNeeded();
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
const session = await bootstrapSession();
|
||||
applySession(session);
|
||||
await refreshProfile();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applySession, refreshProfile]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
ready,
|
||||
profile,
|
||||
phoneVerified,
|
||||
applySession,
|
||||
refreshProfile,
|
||||
resetSession,
|
||||
}),
|
||||
[ready, profile, phoneVerified, applySession, refreshProfile, resetSession],
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="session-boot">
|
||||
<p className="session-boot-text">加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <UserSessionContext.Provider value={value}>{children}</UserSessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useUserSession() {
|
||||
const ctx = useContext(UserSessionContext);
|
||||
if (!ctx) throw new Error('useUserSession must be used within UserSessionProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalUserSession() {
|
||||
return useContext(UserSessionContext);
|
||||
}
|
||||
|
||||
/** @deprecated use profile from useUserSession */
|
||||
export { getDeviceKey };
|
||||
@@ -1,42 +0,0 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/', end: true, icon: 'home', label: '首页', fillActive: false },
|
||||
{ to: '/stores', icon: 'storefront', label: '门店', fillActive: true },
|
||||
{ to: '/benefit', icon: 'card_giftcard', label: '好客权益', fillActive: false },
|
||||
{ to: '/mine', icon: 'person', label: '我的', fillActive: true },
|
||||
] as const;
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<nav className="app-tabbar">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) => `app-tabbar-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className="material-symbols-outlined app-tabbar-icon"
|
||||
style={
|
||||
isActive && tab.fillActive
|
||||
? { fontVariationSettings: "'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tab.icon}
|
||||
</span>
|
||||
<span className="app-tabbar-label">{tab.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createUserTracker, getSessionId } from '@dukang/client-logging';
|
||||
import { apiBase } from './api';
|
||||
|
||||
const tracker = createUserTracker({
|
||||
apiBase,
|
||||
clientApp: 'USER_H5',
|
||||
});
|
||||
|
||||
export { getSessionId };
|
||||
|
||||
export function track(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.track(eventName, params);
|
||||
}
|
||||
|
||||
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.trackPageView(eventName, params);
|
||||
}
|
||||
|
||||
export function initUserAnalytics() {
|
||||
tracker.trackSessionStart();
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { reportApiError } from '@dukang/client-logging';
|
||||
|
||||
export const BRAND = {
|
||||
red: '#A02D30',
|
||||
yellow: '#FFC107',
|
||||
bg: '#f5f5f5',
|
||||
text: '#333',
|
||||
muted: '#999',
|
||||
};
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'USER_H5';
|
||||
|
||||
export type UserProfile = {
|
||||
id: string;
|
||||
userNo: string;
|
||||
phone: string | null;
|
||||
phoneVerified: boolean;
|
||||
nickname: string | null;
|
||||
avatarUrl: string | null;
|
||||
hasWechat: boolean;
|
||||
};
|
||||
|
||||
export type SessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
deviceKey?: string;
|
||||
phoneVerified: boolean;
|
||||
user?: UserProfile;
|
||||
};
|
||||
|
||||
const DEVICE_KEY = 'deviceKey';
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
const REFRESH_TOKEN = 'refreshToken';
|
||||
|
||||
export function getDeviceKey() {
|
||||
return localStorage.getItem(DEVICE_KEY);
|
||||
}
|
||||
|
||||
export function saveSession(data: SessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
||||
}
|
||||
|
||||
export function saveAuth(data: { accessToken: string; refreshToken?: string; deviceKey?: string }) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(ACCESS_TOKEN);
|
||||
localStorage.removeItem(REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = ['/auth/session/bootstrap', '/auth/token/refresh'];
|
||||
|
||||
async function recoverSession(): Promise<SessionPayload> {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed) return refreshed;
|
||||
clearAuth();
|
||||
return bootstrapSession();
|
||||
}
|
||||
|
||||
async function rawRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
token?: string | null,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
|
||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = json.code;
|
||||
if (json.code === 400) {
|
||||
reportApiError(
|
||||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
async function requestWithAuthRetry<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
retried = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await rawRequest<T>(path, options);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
const canRecover =
|
||||
err.status === 401 &&
|
||||
!retried &&
|
||||
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
||||
if (!canRecover) throw e;
|
||||
await recoverSession();
|
||||
return requestWithAuthRetry<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
_clientApp: string,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
if (!isLoggedIn() && !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p))) {
|
||||
await bootstrapSession();
|
||||
}
|
||||
return requestWithAuthRetry<T>(path, options);
|
||||
}
|
||||
|
||||
export async function bootstrapSession(): Promise<SessionPayload> {
|
||||
const deviceKey = getDeviceKey();
|
||||
const data = await rawRequest<SessionPayload>(
|
||||
'/auth/session/bootstrap',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(deviceKey ? { deviceKey } : {}),
|
||||
},
|
||||
null,
|
||||
);
|
||||
saveSession(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshSession(): Promise<SessionPayload | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const data = await rawRequest<SessionPayload>(
|
||||
'/auth/token/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
},
|
||||
null,
|
||||
);
|
||||
saveSession(data);
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSession(): Promise<SessionPayload> {
|
||||
if (isLoggedIn()) {
|
||||
try {
|
||||
const me = await rawRequest<UserProfile>('/auth/me');
|
||||
return {
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
deviceKey: getDeviceKey() ?? undefined,
|
||||
phoneVerified: !!me.phoneVerified,
|
||||
user: me,
|
||||
};
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
clearAuth();
|
||||
} else {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed) return refreshed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bootstrapSession();
|
||||
}
|
||||
|
||||
export async function bindPhone(phone: string, code: string): Promise<SessionPayload> {
|
||||
if (!isLoggedIn()) {
|
||||
await bootstrapSession();
|
||||
}
|
||||
const data = await requestWithAuthRetry<SessionPayload>('/auth/phone/bind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveSession(data);
|
||||
return data;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { getWechatLocation } from '@dukang/weixin-sdk';
|
||||
import { weixinSdk } from './weixin';
|
||||
|
||||
export type ClientGpsLocation = {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */
|
||||
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
|
||||
const loc = await weixinSdk.getLocation();
|
||||
if (!loc) return null;
|
||||
return {
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated 使用 tryGetClientGpsLocation */
|
||||
export { getWechatLocation };
|
||||
@@ -1,43 +0,0 @@
|
||||
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
import { isWechatEnv } from './weixin';
|
||||
|
||||
let cachedPhone = CUSTOMER_SERVICE_PHONE;
|
||||
|
||||
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
|
||||
export function getCustomerServiceWecomUrl(): string {
|
||||
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
|
||||
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
|
||||
}
|
||||
|
||||
export function getCustomerServicePhone(): string {
|
||||
return cachedPhone;
|
||||
}
|
||||
|
||||
/** 从系统设置拉取客服电话(失败则保持默认常量) */
|
||||
export async function loadCustomerServicePhone(): Promise<string> {
|
||||
try {
|
||||
const cfg = await fetchClientConfig();
|
||||
const phone = cfg.customerServicePhone?.trim();
|
||||
if (phone) cachedPhone = phone;
|
||||
} catch {
|
||||
/* keep fallback */
|
||||
}
|
||||
return cachedPhone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
|
||||
* @returns true 已跳转;false 非微信环境已提示
|
||||
*/
|
||||
export function openWecomCustomerService(): boolean {
|
||||
if (!isWechatEnv()) {
|
||||
window.alert('请在微信中打开以联系在线客服');
|
||||
return false;
|
||||
}
|
||||
window.location.href = getCustomerServiceWecomUrl();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */
|
||||
export { CUSTOMER_SERVICE_PHONE };
|
||||
@@ -1,62 +0,0 @@
|
||||
export type CheckoutContext = {
|
||||
productId?: string | null;
|
||||
qty?: string | number | null;
|
||||
addressId?: string | null;
|
||||
cross?: boolean | string | null;
|
||||
select?: boolean | string | null;
|
||||
};
|
||||
|
||||
export function readCheckoutContext(params: URLSearchParams): CheckoutContext {
|
||||
return {
|
||||
productId: params.get('productId'),
|
||||
qty: params.get('qty'),
|
||||
addressId: params.get('addressId'),
|
||||
cross: params.get('cross'),
|
||||
select: params.get('select'),
|
||||
};
|
||||
}
|
||||
|
||||
export function appendCheckoutContext(qs: URLSearchParams, ctx: CheckoutContext) {
|
||||
if (ctx.productId) qs.set('productId', ctx.productId);
|
||||
if (ctx.qty != null && ctx.qty !== '') qs.set('qty', String(ctx.qty));
|
||||
if (ctx.addressId) qs.set('addressId', ctx.addressId);
|
||||
if (ctx.cross === true || ctx.cross === '1') qs.set('cross', '1');
|
||||
if (ctx.select === true || ctx.select === '1') qs.set('select', '1');
|
||||
}
|
||||
|
||||
export function buildOrderConfirmUrl(search: CheckoutContext) {
|
||||
const qs = new URLSearchParams();
|
||||
if (search.productId) qs.set('productId', search.productId);
|
||||
if (search.qty != null && search.qty !== '') qs.set('qty', String(search.qty));
|
||||
if (search.addressId) qs.set('addressId', search.addressId);
|
||||
if (search.cross === true || search.cross === '1') qs.set('cross', '1');
|
||||
const query = qs.toString();
|
||||
return query ? `/order/confirm?${query}` : '/order/confirm';
|
||||
}
|
||||
|
||||
export function buildAddressListUrl(ctx: CheckoutContext = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
appendCheckoutContext(qs, ctx);
|
||||
const query = qs.toString();
|
||||
return query ? `/addresses?${query}` : '/addresses';
|
||||
}
|
||||
|
||||
export function buildAddressEditUrl(id: string | 'new', ctx: CheckoutContext = {}) {
|
||||
const path = id === 'new' ? '/addresses/new' : `/addresses/${id}/edit`;
|
||||
const qs = new URLSearchParams();
|
||||
appendCheckoutContext(qs, ctx);
|
||||
const query = qs.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
export function buildProductDetailUrl(productId?: string | null) {
|
||||
return productId ? `/product/${productId}` : '/';
|
||||
}
|
||||
|
||||
export function buildOrderAddressSelectUrl(orderId: string) {
|
||||
return `/addresses?orderId=${orderId}&select=1`;
|
||||
}
|
||||
|
||||
export function hasCheckoutContext(ctx: CheckoutContext) {
|
||||
return Boolean(ctx.productId || ctx.select === true || ctx.select === '1');
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
/** Stitch 确认订单页商品缩略图 */
|
||||
export const STITCH_ORDER_PRODUCT_IMAGE =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAfqy5X1jKiMBB-L5amwR3xfLYbFBc_qsPbB9mdQZxWlV3rrOARPFVhLRDlW7r8Ig03O6c_ZJKLcVEsgYCblwKg8FZ4-EWwcc5bMNc3UsmBycu3bZ5E6S_aH9UBv0_nEP0sMD8rJsC_rMYBiGDMvRbd52taX-Ir_sfRiVvQu7ImFV-YvU54iXE2x51naVuR8qxwmK7YKitPClg0Pysga859a2-yiJ_ID0QR5xM2o84QbMwNyOEoDDTKSDNqG6J9jfeTsiIYb5vdVmc';
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { toAppPath } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { request, saveSession, type UserProfile } from './api';
|
||||
|
||||
const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
||||
|
||||
export function isWechatAuthRequiredError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('USER_H5', '/common/client-config');
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||
return request<UserProfile>('USER_H5', '/auth/me');
|
||||
}
|
||||
|
||||
/** 真实微信支付且未绑定微信时需要授权 */
|
||||
export function needsWechatAuthForPay(
|
||||
config: ClientRuntimeConfig,
|
||||
profile: UserProfile | null,
|
||||
): boolean {
|
||||
if (!isWxAuthorizeEnabled(config)) return false;
|
||||
return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
|
||||
}
|
||||
|
||||
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
if (!result.accessToken) return false;
|
||||
saveSession({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken ?? '',
|
||||
deviceKey: result.deviceKey,
|
||||
phoneVerified: !!result.phoneVerified,
|
||||
user: result.user as never,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开以完成授权');
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
export function buildLoginReturnUrl(pathname: string, search: string) {
|
||||
return `${toAppPath('/login')}?return=${encodeURIComponent(`${pathname}${search}`)}`;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
|
||||
|
||||
export function normalizePhoneInput(value: string): string {
|
||||
return value.replace(/\D/g, '').slice(0, 11);
|
||||
}
|
||||
|
||||
export function validateMobilePhone(phone: string): { ok: boolean; message?: string } {
|
||||
const trimmed = phone.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, message: '请输入手机号码' };
|
||||
}
|
||||
if (trimmed.length !== 11) {
|
||||
return { ok: false, message: '手机号码须为 11 位' };
|
||||
}
|
||||
if (!MOBILE_PHONE_RE.test(trimmed)) {
|
||||
return { ok: false, message: '请输入正确的手机号码' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/** 商品无图时的占位图 */
|
||||
export const PRODUCT_IMAGE_FALLBACK = '/images/1.png';
|
||||
|
||||
export type ProductImageSource = {
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
};
|
||||
|
||||
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
result.push(url);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 首页/列表轮播图:优先 CAROUSEL,否则封面 */
|
||||
export function getProductImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
|
||||
const main = source?.mainImageUrl;
|
||||
if (main) return [main];
|
||||
|
||||
return [PRODUCT_IMAGE_FALLBACK];
|
||||
}
|
||||
|
||||
/** 单张主图:封面优先 */
|
||||
export function getProductMainImage(source?: ProductImageSource | null): string {
|
||||
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
|
||||
}
|
||||
|
||||
/** 详情页顶部轮播 */
|
||||
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
return getProductImages(source);
|
||||
}
|
||||
|
||||
/** 详情页图文长图 */
|
||||
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
|
||||
return uniqueUrls(source?.detailImageUrls ?? []);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { apiBase } from './api';
|
||||
|
||||
export const PROMO_STORAGE_KEY = 'dukang_promo_code';
|
||||
export const PROMO_PID_STORAGE_KEY = 'dukang_promo_pid';
|
||||
|
||||
function readPromoFromSearch(search: string): { code: string | null; pid: string | null } {
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||
const code = params.get('promo')?.trim();
|
||||
const pid = params.get('pid')?.trim();
|
||||
return {
|
||||
code: code ? code.toUpperCase() : null,
|
||||
pid: pid || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析 URL 中的 ?promo= / ?pid= 并写入 sessionStorage */
|
||||
export function capturePromoFromUrl(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
let parsed = readPromoFromSearch(window.location.search);
|
||||
if (!parsed.code && !parsed.pid && window.location.hash.includes('?')) {
|
||||
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
|
||||
parsed = readPromoFromSearch(hashQuery);
|
||||
}
|
||||
if (parsed.code) {
|
||||
sessionStorage.setItem(PROMO_STORAGE_KEY, parsed.code);
|
||||
}
|
||||
if (parsed.pid) {
|
||||
sessionStorage.setItem(PROMO_PID_STORAGE_KEY, parsed.pid);
|
||||
}
|
||||
return parsed.code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getStoredPromoCode(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getStoredPromoPid(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return sessionStorage.getItem(PROMO_PID_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count) */
|
||||
export async function touchPromoIfNeeded(): Promise<void> {
|
||||
const promoCode = getStoredPromoCode();
|
||||
const qrcodeId = getStoredPromoPid();
|
||||
if (!promoCode && !qrcodeId) return;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': 'USER_H5',
|
||||
};
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/promo/touch`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...(promoCode ? { promoCode } : {}),
|
||||
...(qrcodeId ? { qrcodeId } : {}),
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) return;
|
||||
} catch {
|
||||
/* 静默失败,不阻断用户流程 */
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { regionData } from 'element-china-area-data';
|
||||
|
||||
export type RegionTree = Record<string, Record<string, string[]>>;
|
||||
|
||||
/** 由国家标准省市区数据构建三级树 */
|
||||
function buildRegionTree(): RegionTree {
|
||||
const tree: RegionTree = {};
|
||||
for (const province of regionData) {
|
||||
const cities: Record<string, string[]> = {};
|
||||
for (const city of province.children ?? []) {
|
||||
cities[city.label] = (city.children ?? []).map((district) => district.label);
|
||||
}
|
||||
tree[province.label] = cities;
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
export const REGION_TREE: RegionTree = buildRegionTree();
|
||||
|
||||
export const PROVINCES = Object.keys(REGION_TREE);
|
||||
|
||||
/** 三级选择「全市」选项(省/市/区列表首项) */
|
||||
export const REGION_ALL = '全市';
|
||||
|
||||
export function getCities(province: string): string[] {
|
||||
if (province === REGION_ALL) return [];
|
||||
return Object.keys(REGION_TREE[province] ?? {});
|
||||
}
|
||||
|
||||
export function getDistricts(province: string, city: string): string[] {
|
||||
if (province === REGION_ALL || city === REGION_ALL) return [];
|
||||
return REGION_TREE[province]?.[city] ?? [];
|
||||
}
|
||||
|
||||
/** 省份列表(含全市) */
|
||||
export function getProvincesForPicker(): string[] {
|
||||
return [...PROVINCES];
|
||||
}
|
||||
|
||||
/** 城市列表(含全市) */
|
||||
export function getCitiesForPicker(province: string): string[] {
|
||||
if (province === REGION_ALL) return [REGION_ALL];
|
||||
return [...getCities(province)];
|
||||
}
|
||||
|
||||
/** 区县列表(含全市) */
|
||||
export function getDistrictsForPicker(province: string, city: string): string[] {
|
||||
if (province === REGION_ALL || city === REGION_ALL) return [REGION_ALL];
|
||||
return [REGION_ALL, ...getDistricts(province, city)];
|
||||
}
|
||||
|
||||
export function formatRegion(province: string, city: string, district: string): string {
|
||||
if (!province) return '';
|
||||
if (province === REGION_ALL) return REGION_ALL;
|
||||
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
|
||||
if (district === REGION_ALL) return `${province} ${city} ${REGION_ALL}`;
|
||||
if (!city || !district) return '';
|
||||
return `${province} ${city} ${district}`;
|
||||
}
|
||||
|
||||
/** 仅展示省、市两级(门店列表等场景) */
|
||||
export function formatRegionCity(province: string, city: string): string {
|
||||
if (!province) return '';
|
||||
if (province === REGION_ALL) return REGION_ALL;
|
||||
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
|
||||
if (!city) return province;
|
||||
return `${province} ${city}`;
|
||||
}
|
||||
|
||||
/** 门店筛选:固定为市级,不按区县过滤 */
|
||||
export function toCityLevelRegion(selection: RegionSelection): RegionSelection {
|
||||
const normalized = normalizeRegionSelection(selection);
|
||||
return {
|
||||
province: normalized.province,
|
||||
city: normalized.city,
|
||||
district: REGION_ALL,
|
||||
};
|
||||
}
|
||||
|
||||
export type RegionSelection = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_REGION: RegionSelection = {
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: '金水区',
|
||||
};
|
||||
|
||||
export const FALLBACK_CITY_REGION: RegionSelection = {
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: REGION_ALL,
|
||||
};
|
||||
|
||||
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||
const cityName = city.endsWith('市') ? city : `${city}市`;
|
||||
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
||||
const cities = getCities(provinceInTree);
|
||||
const matchedCity = cities.includes(cityName)
|
||||
? cityName
|
||||
: cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName;
|
||||
const districts = getDistricts(provinceInTree, matchedCity);
|
||||
const districtName =
|
||||
district && districts.includes(district)
|
||||
? district
|
||||
: REGION_ALL;
|
||||
return normalizeRegionSelection({
|
||||
province: provinceInTree,
|
||||
city: cities.includes(matchedCity) ? matchedCity : matchedCity,
|
||||
district: districtName,
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验已选地区是否仍存在于数据源中 */
|
||||
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
|
||||
if (selection.province === REGION_ALL) {
|
||||
return { province: REGION_ALL, city: REGION_ALL, district: REGION_ALL };
|
||||
}
|
||||
|
||||
const province = PROVINCES.includes(selection.province)
|
||||
? selection.province
|
||||
: DEFAULT_REGION.province;
|
||||
|
||||
if (selection.city === REGION_ALL) {
|
||||
return { province, city: REGION_ALL, district: REGION_ALL };
|
||||
}
|
||||
|
||||
const cities = getCities(province);
|
||||
const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_REGION.city);
|
||||
|
||||
if (selection.district === REGION_ALL) {
|
||||
return { province, city, district: REGION_ALL };
|
||||
}
|
||||
|
||||
const districts = getDistricts(province, city);
|
||||
const district = districts.includes(selection.district)
|
||||
? selection.district
|
||||
: (districts[0] ?? DEFAULT_REGION.district);
|
||||
|
||||
return { province, city, district };
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/** Stitch user/22 门店详情页 — 图集与地图占位 */
|
||||
export const STITCH_STORE_GALLERY = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDqN0DeYRsWNcXyfSRec8k2fhjJsqdji3-7zrtegkiEs5lwt3Sx4l79Uzmfys2pnl_gUY_m3Dpy5cAM8HW7JcR8qPtfO2G8YNcZ3x0DGSN1DUPJPq4emVhmIuwmaLEQ944UT9hjpNQsjdqieKV8R-X-2YvSOrsEa74kyfI5UNgRQaGdinhLw6co29ji3F9BRgfgWCQ1KqjotRBC4r9lzWBdeue-xryXvN8jEp_7hjwrBNOOZoIDPnKkpQQwLLpaa7Di6kfEfwzamCg',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAW3oxOc6XpywVJmpwYxIPBlP32ftPIOUB8JbYcqOAcLg1gbzKIgbDgBaPVUyH0gjdoWa7Hi0u1-NBYBwc5Jd3YpqufVWIou_ySFB2oLXA6T0u7DgUWKhtxbMnqMue-oasf8GlEy_e7-Rh41ZxVFkc30tQVAYz-Psm3CNgfRFqXoHDXCZAZz5ggOFOB2dScURBVN9qp_Ribo4DuE4LARgf19R8eKZR8mbDQdHesLaVl0icifdcQEb75QM6-VqCR3ch9BNKzLJ5hKMM',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDuv4URDejJ5j26kuBPG2fqmOmI90qQomZki-aHr3MmdF47Pq5HM7tiH68E77rrF0XjeaZjkQ0e39j5gY1-N_981-eguGZn8VIRZI0n6t-f8QVIhAyjL8kg-5ZD2yRsfgw5mnOYYPMyNUI54efLiU4M6mni6nJvTAXMvX0oBMXtTItj5U66d9BIvie7VfHoVYMelEW9ppZsSRzA7ZoIu6aRp_72OwAIcTFuiI2zccaAmfTk7dChjjHIHZ85B8dDc2G8Tym34SuJAZc',
|
||||
] as const;
|
||||
|
||||
export const STITCH_STORE_MAP =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuByauC4oncButUsGa_t2ntIVz-iPk9zVUnYA6_P_URyFYzrWALFa2TKfdpEyrGs61N_sEjRYksO_HeKCZGJQXfRhXqf1iXrk8JPIfzDwb33bDacTr2J0HM-cSnNjcM1c5l6r_yuzsE0zuLBZpuAWVPwkOJUkdfk6xxNpABh-OQ0B6736YmxFQM-WJ5h0eLHpRB7RuyTFr5c_TwTysKyY6QVDZ-oJrx9Vc3FQE7Pc64o3bzP6qW3GEqdqm4WVIAMKiNKqUcXvkN8E48';
|
||||
|
||||
export function getStoreGalleryImages(coverUrl?: string | null, media?: Array<{ url: string }>) {
|
||||
const fromMedia = (media || []).map((m) => m.url).filter(Boolean);
|
||||
if (fromMedia.length > 0) return fromMedia;
|
||||
if (coverUrl) return [coverUrl, ...STITCH_STORE_GALLERY.slice(1)];
|
||||
return [...STITCH_STORE_GALLERY];
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { apiBase } from './api';
|
||||
import {
|
||||
compressImageFileIfNeeded,
|
||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||
formatOssMaxSizeMb,
|
||||
} from '@dukang/shared-ui/compressImage';
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
export type UploadFileResult = {
|
||||
url: string;
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
/** 经 API 服务端转存 OSS */
|
||||
export async function uploadFileToOss(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||
let prepared = file;
|
||||
if (mediaType === 'IMAGE') {
|
||||
prepared = await compressImageFileIfNeeded(file);
|
||||
if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
||||
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
||||
}
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', prepared);
|
||||
formData.append('bizType', options.bizType);
|
||||
formData.append('mediaType', mediaType);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-Client-App': 'USER_H5',
|
||||
};
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '上传失败');
|
||||
return json.data as UploadFileResult;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { SmsScene } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
import { validateMobilePhone } from './phone';
|
||||
|
||||
export function useSmsCode() {
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentHint, setSentHint] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [mockSms, setMockSms] = useState(true);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((cfg) => setMockSms(cfg.mockSms))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startCooldown = useCallback(() => {
|
||||
setCodeCooldown(60);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const sendCode = useCallback(
|
||||
async (phone: string, scene: SmsScene) => {
|
||||
const phoneCheck = validateMobilePhone(phone);
|
||||
if (!phoneCheck.ok) {
|
||||
setError(phoneCheck.message ?? '请输入正确的手机号码');
|
||||
return false;
|
||||
}
|
||||
setSending(true);
|
||||
setError('');
|
||||
setSentHint('');
|
||||
try {
|
||||
await request('USER_H5', '/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene }),
|
||||
});
|
||||
setSentHint(mockSms ? '验证码已发送(开发模式)' : '验证码已发送,请注意查收');
|
||||
startCooldown();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '发送失败');
|
||||
return false;
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[mockSms, startCooldown],
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setError('');
|
||||
setSentHint('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sendCode,
|
||||
sending,
|
||||
codeCooldown,
|
||||
sentHint,
|
||||
error,
|
||||
setError,
|
||||
clearMessages,
|
||||
mockSms,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { trackPageView } from './analytics';
|
||||
|
||||
export function usePageView(eventName: string, params?: Record<string, unknown>) {
|
||||
const fired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fired.current) return;
|
||||
fired.current = true;
|
||||
trackPageView(eventName, params);
|
||||
}, [eventName, params]);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import {
|
||||
authorizeWechatForPay,
|
||||
fetchClientConfig,
|
||||
fetchUserProfile,
|
||||
needsWechatAuthForPay,
|
||||
saveWechatLoginResult,
|
||||
} from './pay-wechat';
|
||||
|
||||
export type WechatAuthEnsureResult =
|
||||
| { ok: true }
|
||||
| { ok: false; redirecting: true }
|
||||
| { ok: false; needBindPhone: true; wxSessionKey: string };
|
||||
|
||||
export async function checkNeedsWechatAuth(): Promise<boolean> {
|
||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||
return needsWechatAuthForPay(config, profile);
|
||||
}
|
||||
|
||||
/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */
|
||||
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
|
||||
if (!isWechatEnv()) return { ok: true };
|
||||
if (!(await checkNeedsWechatAuth())) return { ok: true };
|
||||
|
||||
const result = await authorizeWechatForPay();
|
||||
if (!result) return { ok: false, redirecting: true };
|
||||
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
|
||||
}
|
||||
|
||||
if (saveWechatLoginResult(result)) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, redirecting: true };
|
||||
}
|
||||
|
||||
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
|
||||
if (!isWechatEnv()) return null;
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
return weixinSdk.handleOAuthCallback();
|
||||
}
|
||||
|
||||
export async function loginWithWechatSdk(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开以使用微信一键授权');
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
return saveWechatLoginResult(result);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
|
||||
import { apiBase } from './api';
|
||||
import { weixinSdk } from './weixin';
|
||||
import { regionFromGeo, type RegionSelection } from './region-data';
|
||||
|
||||
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||
export const CITY_STORAGE_KEY = 'dukang_selected_city';
|
||||
export const FALLBACK_CITY_CODE = '410100';
|
||||
|
||||
export type ResolvedUserCity = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity: boolean;
|
||||
region: RegionSelection;
|
||||
displayCity: string;
|
||||
};
|
||||
|
||||
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
||||
|
||||
function readCache(): GpsCityCache | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(GPS_CITY_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as GpsCityCache;
|
||||
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(data: ResolvedUserCity) {
|
||||
sessionStorage.setItem(
|
||||
GPS_CITY_STORAGE_KEY,
|
||||
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
|
||||
);
|
||||
}
|
||||
|
||||
async function reportLocationToServer(payload: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
sdk: 'jssdk' | 'geolocation';
|
||||
status: 'success' | 'fail';
|
||||
errMsg?: string;
|
||||
}) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': 'USER_H5',
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/wechat/location`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || '定位上报失败');
|
||||
}
|
||||
return json.data as {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function toResolved(data: {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity?: boolean;
|
||||
}): ResolvedUserCity | null {
|
||||
if (!data.province || !data.city) return null;
|
||||
const region = regionFromGeo(data.province, data.city, data.district);
|
||||
const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}市`);
|
||||
return {
|
||||
province: data.province,
|
||||
city: data.city,
|
||||
district: data.district ?? '',
|
||||
cityCode: data.cityCode,
|
||||
cityName: data.cityName,
|
||||
openCity: !!data.openCity,
|
||||
region,
|
||||
displayCity,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取并解析用户当前城市(微信 JSSDK 优先),失败返回 null */
|
||||
export async function resolveUserCity(force = false): Promise<ResolvedUserCity | null> {
|
||||
if (!force) {
|
||||
const cached = readCache();
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const outcome = await getWechatLocationDetailed({
|
||||
apiBase,
|
||||
clientApp: 'USER_H5',
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
});
|
||||
|
||||
if (!outcome.location) {
|
||||
await reportLocationToServer({
|
||||
sdk: outcome.sdk,
|
||||
status: 'fail',
|
||||
errMsg: outcome.errMsg,
|
||||
}).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await reportLocationToServer({
|
||||
latitude: outcome.location.latitude,
|
||||
longitude: outcome.location.longitude,
|
||||
sdk: outcome.sdk,
|
||||
status: 'success',
|
||||
});
|
||||
const resolved = toResolved(data);
|
||||
if (resolved) {
|
||||
writeCache(resolved);
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function syncCityCodeFromGps(resolved: ResolvedUserCity) {
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { WechatShareData } from '@dukang/weixin-sdk';
|
||||
import { getWechatShareLink, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
|
||||
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
|
||||
export const DEFAULT_SHARE_DESC = '杜康好客 · 买酒享权益,全城门店可用';
|
||||
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
|
||||
|
||||
export function getDefaultShareImageUrl(): string {
|
||||
if (typeof window === 'undefined') return toAppPath('/logo.png');
|
||||
return new URL(toAppPath('/logo.png'), window.location.origin).href;
|
||||
}
|
||||
|
||||
export function buildDefaultShareData(
|
||||
overrides?: Partial<WechatShareData>,
|
||||
): WechatShareData {
|
||||
return {
|
||||
title: overrides?.title ?? DEFAULT_SHARE_TITLE,
|
||||
desc: overrides?.desc ?? DEFAULT_SHARE_DESC,
|
||||
link: overrides?.link ?? getWechatShareLink(),
|
||||
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyDefaultWechatShare(
|
||||
overrides?: Partial<WechatShareData>,
|
||||
): Promise<void> {
|
||||
if (!isWechatEnv()) return;
|
||||
await weixinSdk.setShare(buildDefaultShareData(overrides));
|
||||
}
|
||||
|
||||
export function handleShareButtonClick(onHint: (message: string) => void): void {
|
||||
const showHint = (message: string) => {
|
||||
onHint(message);
|
||||
if (message) {
|
||||
window.setTimeout(() => onHint(''), 2500);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
showHint('请在微信内打开后分享');
|
||||
return;
|
||||
}
|
||||
void applyDefaultWechatShare()
|
||||
.then(() => showHint(WECHAT_SHARE_HINT))
|
||||
.catch(() => showHint('分享配置失败,请刷新页面后重试'));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||
|
||||
const CLIENT_APP = 'USER_H5';
|
||||
|
||||
export const weixinSdk = createWeixinSdk({
|
||||
apiBase: '/api/v1',
|
||||
clientApp: CLIENT_APP,
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
});
|
||||
|
||||
export { isWechatEnv };
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { getRouterBasename } from '@dukang/weixin-sdk';
|
||||
import { installClientErrorReporting } from '@dukang/client-logging';
|
||||
import App from './App';
|
||||
import { initUserAnalytics } from './lib/analytics';
|
||||
import { apiBase } from './lib/api';
|
||||
import './styles.css';
|
||||
import './styles/legal.css';
|
||||
|
||||
installClientErrorReporting({ apiBase, clientApp: 'USER_H5' });
|
||||
initUserAnalytics();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={getRouterBasename()}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,237 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import RegionPicker from '../components/RegionPicker';
|
||||
import { request } from '../lib/api';
|
||||
import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation';
|
||||
import { DEFAULT_REGION, formatRegion } from '../lib/region-data';
|
||||
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
type AddressForm = {
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export default function AddressEditPage() {
|
||||
const { id } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const isEdit = Boolean(id);
|
||||
usePageView('address_edit', { mode: isEdit ? 'edit' : 'create' });
|
||||
const navigate = useNavigate();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<AddressForm>({
|
||||
receiverName: '',
|
||||
phone: '',
|
||||
province: DEFAULT_REGION.province,
|
||||
city: params.get('city') || DEFAULT_REGION.city,
|
||||
district: DEFAULT_REGION.district,
|
||||
detail: '',
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const checkoutCtx = readCheckoutContext(params);
|
||||
|
||||
function goBackToList() {
|
||||
navigate(buildAddressListUrl(checkoutCtx));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
request<Array<Record<string, unknown>>>('USER_H5', '/user/addresses').then((list) => {
|
||||
const found = list.find((a) => String(a.id) === id);
|
||||
if (found) {
|
||||
setForm({
|
||||
receiverName: String(found.receiverName),
|
||||
phone: String(found.phone),
|
||||
province: String(found.province),
|
||||
city: String(found.city),
|
||||
district: String(found.district),
|
||||
detail: String(found.detail),
|
||||
isDefault: found.isDefault === 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const regionText = formatRegion(form.province, form.city, form.district);
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!form.receiverName.trim()) return '请输入收货人姓名';
|
||||
const phoneCheck = validateMobilePhone(form.phone);
|
||||
if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码';
|
||||
if (!form.province || !form.city || !form.district) return '请选择所在地区';
|
||||
if (!form.detail.trim()) return '请输入详细地址';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await request('USER_H5', `/user/addresses/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
} else {
|
||||
await request('USER_H5', '/user/addresses', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
}
|
||||
navigate(buildAddressListUrl(checkoutCtx));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="address-edit-page">
|
||||
<SubPageHeader
|
||||
title={isEdit ? '编辑收货地址' : '添加收货地址'}
|
||||
onBack={goBackToList}
|
||||
/>
|
||||
|
||||
<main className="address-edit-main sub-page-body">
|
||||
<section className="address-edit-card">
|
||||
<div className="address-edit-field">
|
||||
<label className="address-edit-label">收货人姓名</label>
|
||||
<div className="address-edit-line">
|
||||
<input
|
||||
type="text"
|
||||
className="address-edit-input"
|
||||
placeholder="请输入姓名"
|
||||
value={form.receiverName}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, receiverName: e.target.value });
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
<span className="material-symbols-outlined address-edit-field-icon">person</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="address-edit-field">
|
||||
<label className="address-edit-label">手机号码</label>
|
||||
<div className="address-edit-line">
|
||||
<span className="address-edit-prefix">+86</span>
|
||||
<input
|
||||
type="tel"
|
||||
className="address-edit-input"
|
||||
placeholder="请输入手机号"
|
||||
maxLength={11}
|
||||
inputMode="numeric"
|
||||
value={form.phone}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, phone: normalizePhoneInput(e.target.value) });
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
<span className="material-symbols-outlined address-edit-field-icon">smartphone</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="address-edit-field address-edit-region" onClick={() => setPickerOpen(true)}>
|
||||
<div className="address-edit-line address-edit-line--picker address-edit-line--region">
|
||||
<span className={regionText ? 'address-edit-region-value' : 'address-edit-region-placeholder'}>
|
||||
{regionText || '省份、城市、区县'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="address-edit-field address-edit-field--last">
|
||||
<label className="address-edit-label">详细地址</label>
|
||||
<div className="address-edit-textarea-wrap">
|
||||
<textarea
|
||||
className="address-edit-textarea"
|
||||
placeholder="街道、门牌号、小区名称等"
|
||||
rows={3}
|
||||
value={form.detail}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, detail: e.target.value });
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="address-edit-card address-edit-default">
|
||||
<div className="address-edit-default-info">
|
||||
<div className="address-edit-default-icon">
|
||||
<span className="material-symbols-outlined fill-icon">stars</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="address-edit-default-title">设为默认地址</h3>
|
||||
<p className="address-edit-default-desc">每次下单时将优先使用此地址</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="address-edit-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isDefault}
|
||||
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
|
||||
/>
|
||||
<span className="address-edit-toggle-track" />
|
||||
<span className="address-edit-toggle-thumb" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{error && <p className="address-edit-error">{error}</p>}
|
||||
|
||||
<div className="address-edit-security">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>已通过杜康云安全加密处理</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav className="address-edit-footer">
|
||||
<button type="button" className="address-edit-cancel-btn" onClick={goBackToList}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
<span>取消</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="address-edit-save-btn"
|
||||
disabled={saving}
|
||||
onClick={save}
|
||||
>
|
||||
<span className="material-symbols-outlined">publish</span>
|
||||
<span>{saving ? '保存中...' : '保存并发布'}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<RegionPicker
|
||||
open={pickerOpen}
|
||||
value={{ province: form.province, city: form.city, district: form.district }}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={(region) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
province: region.province,
|
||||
city: region.city,
|
||||
district: region.district,
|
||||
}));
|
||||
setPickerOpen(false);
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault?: number;
|
||||
};
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||
}
|
||||
|
||||
export default function AddressListPage() {
|
||||
usePageView('address_list_view');
|
||||
const { profile } = useUserSession();
|
||||
const [list, setList] = useState<Address[]>([]);
|
||||
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
|
||||
const [savingOrderAddress, setSavingOrderAddress] = useState(false);
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const selectMode = params.get('select') === '1';
|
||||
const orderId = params.get('orderId') || '';
|
||||
const productId = params.get('productId') || '';
|
||||
const qty = params.get('qty') || '';
|
||||
const cross = params.get('cross') === '1';
|
||||
const currentAddressId = params.get('addressId') || '';
|
||||
|
||||
const loadList = useCallback(() => {
|
||||
request<Address[]>('USER_H5', '/user/addresses').then(setList);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, [loadList, profile?.id]);
|
||||
|
||||
function selectAddress(addr: Address) {
|
||||
if (!selectMode) return;
|
||||
if (orderId) {
|
||||
setPendingAddress(addr);
|
||||
return;
|
||||
}
|
||||
const qs = new URLSearchParams();
|
||||
if (productId) qs.set('productId', productId);
|
||||
if (qty) qs.set('qty', qty);
|
||||
if (cross) qs.set('cross', '1');
|
||||
qs.set('addressId', addr.id);
|
||||
navigate(`/order/confirm?${qs.toString()}`);
|
||||
}
|
||||
|
||||
async function confirmOrderAddress() {
|
||||
if (!orderId || !pendingAddress) return;
|
||||
setSavingOrderAddress(true);
|
||||
try {
|
||||
await request('USER_H5', `/trade/orders/${orderId}/address`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
receiverName: pendingAddress.receiverName,
|
||||
receiverPhone: pendingAddress.phone,
|
||||
receiverProvince: pendingAddress.province,
|
||||
receiverCity: pendingAddress.city,
|
||||
receiverDistrict: pendingAddress.district,
|
||||
receiverAddress: `${pendingAddress.province}${pendingAddress.city}${pendingAddress.district}${pendingAddress.detail}`,
|
||||
}),
|
||||
});
|
||||
navigate(`/orders/${orderId}`);
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '修改地址失败');
|
||||
} finally {
|
||||
setSavingOrderAddress(false);
|
||||
setPendingAddress(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(addr: Address, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (addr.isDefault === 1) return;
|
||||
await request('USER_H5', `/user/addresses/${addr.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
receiverName: addr.receiverName,
|
||||
phone: addr.phone,
|
||||
province: addr.province,
|
||||
city: addr.city,
|
||||
district: addr.district,
|
||||
detail: addr.detail,
|
||||
isDefault: true,
|
||||
}),
|
||||
});
|
||||
loadList();
|
||||
}
|
||||
|
||||
async function removeAddress(id: string, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('确定删除该收货地址吗?')) return;
|
||||
await request('USER_H5', `/user/addresses/${id}`, { method: 'DELETE' });
|
||||
loadList();
|
||||
}
|
||||
|
||||
const checkoutCtx = readCheckoutContext(params);
|
||||
|
||||
function goEdit(id: string, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
navigate(buildAddressEditUrl(id, checkoutCtx));
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (orderId) {
|
||||
navigate(`/orders/${orderId}`);
|
||||
return;
|
||||
}
|
||||
if (hasCheckoutContext(checkoutCtx)) {
|
||||
navigate(buildOrderConfirmUrl(checkoutCtx));
|
||||
return;
|
||||
}
|
||||
navigate('/mine');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="address-list-page">
|
||||
<SubPageHeader title={orderId ? '选择收货地址' : '我的地址'} onBack={goBack} />
|
||||
|
||||
<main className="address-list-main sub-page-body">
|
||||
{list.length === 0 && (
|
||||
<p className="address-list-empty">暂无收货地址,请新增</p>
|
||||
)}
|
||||
|
||||
<div className="address-list-cards">
|
||||
{list.map((a) => {
|
||||
const isDefault = Number(a.isDefault) === 1;
|
||||
const isSelected = selectMode && currentAddressId === String(a.id);
|
||||
return (
|
||||
<article
|
||||
key={a.id}
|
||||
className={`address-list-card${isDefault ? ' is-default' : ''}${isSelected ? ' is-selected' : ''}${selectMode ? ' is-selectable' : ''}`}
|
||||
onClick={() => selectAddress(a)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectAddress(a);
|
||||
}
|
||||
}}
|
||||
role={selectMode ? 'button' : undefined}
|
||||
tabIndex={selectMode ? 0 : undefined}
|
||||
>
|
||||
<div className="address-list-card-head">
|
||||
<div className="address-list-card-contact">
|
||||
<span className="address-list-name">{a.receiverName}</span>
|
||||
<span className="address-list-phone">{maskPhone(a.phone)}</span>
|
||||
</div>
|
||||
{isDefault && <span className="address-list-default-badge">默认</span>}
|
||||
</div>
|
||||
|
||||
<p className="address-list-detail">{formatAddress(a)}</p>
|
||||
|
||||
<div className="address-list-divider" />
|
||||
|
||||
<div className="address-list-actions">
|
||||
{isDefault ? (
|
||||
<div className="address-list-default-label">
|
||||
<span className="material-symbols-outlined fill-icon">check_circle</span>
|
||||
<span>默认地址</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="address-list-set-default"
|
||||
onClick={(e) => setDefault(a, e)}
|
||||
>
|
||||
<span className="material-symbols-outlined">radio_button_unchecked</span>
|
||||
<span>设为默认</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="address-list-action-btns">
|
||||
<button type="button" className="address-list-action-btn" onClick={(e) => goEdit(String(a.id), e)}>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="address-list-action-btn"
|
||||
onClick={(e) => removeAddress(String(a.id), e)}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{list.length > 0 && (
|
||||
<div className="address-list-brand" aria-hidden>
|
||||
<div className="address-list-brand-icon">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
</div>
|
||||
<p>DUKANG HERITAGE SERVICE</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="address-list-footer">
|
||||
<Link
|
||||
to={buildAddressEditUrl('new', checkoutCtx)}
|
||||
className="address-list-add-btn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
<span>新增收货地址</span>
|
||||
</Link>
|
||||
</footer>
|
||||
|
||||
{pendingAddress && (
|
||||
<div className="order-address-modal-overlay" onClick={() => setPendingAddress(null)}>
|
||||
<div className="order-address-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="order-address-modal-head">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<h3>修改收货地址</h3>
|
||||
</div>
|
||||
<div className="order-address-modal-body">
|
||||
<p>确认将订单收货地址修改为:</p>
|
||||
<p className="order-address-modal-target">
|
||||
{pendingAddress.receiverName} {maskPhone(pendingAddress.phone)}
|
||||
<br />
|
||||
{formatAddress(pendingAddress)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="order-address-modal-actions">
|
||||
<button type="button" onClick={() => setPendingAddress(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
disabled={savingOrderAddress}
|
||||
onClick={confirmOrderAddress}
|
||||
>
|
||||
{savingOrderAddress ? '保存中...' : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
type TicketRow = {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: TicketTypeDto;
|
||||
status: string;
|
||||
orderNo?: string;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING: '待处理',
|
||||
OPEN: '处理中',
|
||||
RESOLVED: '已完成',
|
||||
REJECTED: '已驳回',
|
||||
CLOSED: '已关闭',
|
||||
};
|
||||
|
||||
export default function AfterSaleListPage() {
|
||||
usePageView('after_sale_list_view');
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<TicketRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ items: TicketRow[] }>('USER_H5', '/trade/after-sale-tickets?pageSize=50')
|
||||
.then((res) => setItems(res.items ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="after-sale-page">
|
||||
<SubPageHeader title="我的售后" onBack={() => navigate(-1)} />
|
||||
<main className="after-sale-body">
|
||||
{loading ? (
|
||||
<p className="after-sale-empty">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="after-sale-empty-box">
|
||||
<p className="after-sale-empty">暂无售后工单</p>
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
|
||||
申请售后
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="after-sale-order-list">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className="after-sale-order-item after-sale-ticket-card">
|
||||
<div className="after-sale-ticket-head">
|
||||
<span>{TICKET_TYPE_LABELS[t.ticketType] ?? t.ticketType}</span>
|
||||
<span className="after-sale-ticket-status">{STATUS_LABEL[t.status] ?? t.status}</span>
|
||||
</div>
|
||||
<p className="after-sale-order-no">{t.ticketNo}</p>
|
||||
<p className="after-sale-order-meta">订单 {t.orderNo ?? '—'} · {new Date(t.createdAt).toLocaleString()}</p>
|
||||
{t.remark ? <p className="after-sale-order-meta">{t.remark}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
|
||||
新建售后
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
AFTER_SALE_TICKET_TYPES,
|
||||
TICKET_TYPE_LABELS,
|
||||
type AfterSaleTicketType,
|
||||
} from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
import { track } from '../lib/analytics';
|
||||
|
||||
type OrderRow = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
payAmount: number | string;
|
||||
productName?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
const STEPS = ['类型', '订单', '凭证', '完成'] as const;
|
||||
|
||||
export default function AfterSalePage() {
|
||||
usePageView('after_sale_apply');
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const presetOrderId = params.get('orderId') || '';
|
||||
const presetType = (params.get('type') as AfterSaleTicketType | null) || null;
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [ticketType, setTicketType] = useState<AfterSaleTicketType | null>(
|
||||
presetType && AFTER_SALE_TICKET_TYPES.includes(presetType) ? presetType : null,
|
||||
);
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [orderId, setOrderId] = useState(presetOrderId);
|
||||
const [remark, setRemark] = useState('');
|
||||
const [evidenceUrls, setEvidenceUrls] = useState<string[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [ticketNo, setTicketNo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=paid&pageSize=50'),
|
||||
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50'),
|
||||
])
|
||||
.then(([paid, completed]) => {
|
||||
const map = new Map<string, OrderRow>();
|
||||
[...(paid.list ?? []), ...(completed.list ?? [])].forEach((o) => map.set(o.id, o));
|
||||
setOrders([...map.values()]);
|
||||
})
|
||||
.catch(() => setOrders([]));
|
||||
}, []);
|
||||
|
||||
const selectedOrder = useMemo(() => orders.find((o) => o.id === orderId), [orders, orderId]);
|
||||
|
||||
async function onPickFiles(files: FileList | null) {
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded: string[] = [];
|
||||
for (const file of Array.from(files).slice(0, 6 - evidenceUrls.length)) {
|
||||
const res = await uploadFileToOss(file, { bizType: 'after-sale' });
|
||||
uploaded.push(res.url);
|
||||
}
|
||||
setEvidenceUrls((prev) => [...prev, ...uploaded].slice(0, 6));
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!ticketType || !orderId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ticket = await request<{ ticketNo: string }>('USER_H5', `/trade/orders/${orderId}/after-sale-tickets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ticketType,
|
||||
remark: remark.trim() || undefined,
|
||||
evidenceUrls,
|
||||
}),
|
||||
});
|
||||
setTicketNo(ticket.ticketNo);
|
||||
setStep(3);
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function nextFromType() {
|
||||
if (!ticketType) {
|
||||
window.alert('请选择售后类型');
|
||||
return;
|
||||
}
|
||||
setStep(1);
|
||||
}
|
||||
|
||||
function nextFromOrder() {
|
||||
if (!orderId) {
|
||||
window.alert('请选择订单');
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="after-sale-page">
|
||||
<SubPageHeader title="申请售后" onBack={() => navigate(-1)} />
|
||||
|
||||
<div className="after-sale-steps">
|
||||
{STEPS.map((label, i) => (
|
||||
<span key={label} className={`after-sale-step${i === step ? ' is-active' : i < step ? ' is-done' : ''}`}>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<main className="after-sale-body">
|
||||
{step === 0 && (
|
||||
<div className="after-sale-type-list">
|
||||
{AFTER_SALE_TICKET_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`after-sale-type-item${ticketType === t ? ' is-selected' : ''}`}
|
||||
onClick={() => setTicketType(t)}
|
||||
>
|
||||
<span>{TICKET_TYPE_LABELS[t]}</span>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="after-sale-primary" onClick={nextFromType}>
|
||||
下一步
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="after-sale-order-list">
|
||||
{orders.length === 0 ? (
|
||||
<p className="after-sale-empty">暂无可售后订单</p>
|
||||
) : (
|
||||
orders.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
|
||||
onClick={() => setOrderId(o.id)}
|
||||
>
|
||||
<p className="after-sale-order-no">{o.orderNo}</p>
|
||||
<p className="after-sale-order-meta">
|
||||
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<div className="after-sale-actions">
|
||||
<button type="button" className="after-sale-secondary" onClick={() => setStep(0)}>
|
||||
上一步
|
||||
</button>
|
||||
<button type="button" className="after-sale-primary" onClick={nextFromOrder}>
|
||||
下一步
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="after-sale-form">
|
||||
<p className="after-sale-summary">
|
||||
{ticketType ? TICKET_TYPE_LABELS[ticketType] : ''} · {selectedOrder?.orderNo ?? orderId}
|
||||
</p>
|
||||
<label className="after-sale-label">问题描述</label>
|
||||
<textarea
|
||||
className="after-sale-textarea"
|
||||
rows={4}
|
||||
placeholder="请描述问题(选填)"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
/>
|
||||
<label className="after-sale-label">凭证图片(破损类建议上传)</label>
|
||||
<div className="after-sale-evidence">
|
||||
{evidenceUrls.map((url) => (
|
||||
<img key={url} src={url} alt="" className="after-sale-evidence-img" />
|
||||
))}
|
||||
{evidenceUrls.length < 6 && (
|
||||
<label className="after-sale-evidence-add">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
disabled={uploading}
|
||||
onChange={(e) => void onPickFiles(e.target.files)}
|
||||
/>
|
||||
{uploading ? '上传中' : '+'}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="after-sale-actions">
|
||||
<button type="button" className="after-sale-secondary" onClick={() => setStep(1)}>
|
||||
上一步
|
||||
</button>
|
||||
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
|
||||
{submitting ? '提交中…' : '提交工单'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="after-sale-success">
|
||||
<span className="material-symbols-outlined after-sale-success-icon">check_circle</span>
|
||||
<p className="after-sale-success-title">售后已提交</p>
|
||||
<p className="after-sale-success-no">工单号 {ticketNo}</p>
|
||||
<p className="after-sale-success-hint">总部将尽快审核,请留意处理进度</p>
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale/list')}>
|
||||
查看我的售后
|
||||
</button>
|
||||
<button type="button" className="after-sale-secondary" onClick={() => navigate('/orders')}>
|
||||
返回订单
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
export default function BenefitDetailPage() {
|
||||
const { id } = useParams();
|
||||
usePageView('benefit_detail_view', { couponId: id });
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<{ coupon: Record<string, unknown>; ledgers: Array<Record<string, unknown>> } | null>(null);
|
||||
useEffect(() => {
|
||||
if (id) request('USER_H5', `/benefit/coupons/${id}`).then(setData);
|
||||
}, [id]);
|
||||
if (!data) return <div className="empty">加载中...</div>;
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="好客权益明细" onBack={() => navigate(-1)} />
|
||||
<div className="card">
|
||||
<p className="body-md">可用余额:<span className="amount-lg">¥{Number(data.coupon.balance)}</span></p>
|
||||
<p className="text-variant body-md">总额:¥{Number(data.coupon.totalAmount)}</p>
|
||||
</div>
|
||||
{data.ledgers.map((l) => (
|
||||
<div key={String(l.id)} className="card">
|
||||
<div className="card-row">
|
||||
<span className="body-md">{String(l.type)}</span>
|
||||
<span className={Number(l.amount) < 0 ? 'text-primary amount-lg' : 'body-md'}>{Number(l.amount) > 0 ? '+' : ''}{Number(l.amount)}</span>
|
||||
</div>
|
||||
<div className="label-md text-muted">{String(l.remark || '')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import BrandLogo from '@dukang/shared-ui/BrandLogo';
|
||||
import { request } from '../lib/api';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
maxRedeemAmount: number;
|
||||
activeCouponCount: number;
|
||||
};
|
||||
|
||||
type CouponItem = {
|
||||
id: string;
|
||||
couponNo: string;
|
||||
totalAmount: number;
|
||||
usedAmount: number;
|
||||
balance: number;
|
||||
status: string;
|
||||
sourceProduct: string;
|
||||
};
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatCouponNo(no: string) {
|
||||
const tail = no.replace(/^BC/i, '').slice(-6);
|
||||
return `NO. DK${tail}`;
|
||||
}
|
||||
|
||||
function usagePercent(coupon: CouponItem) {
|
||||
const total = Number(coupon.totalAmount);
|
||||
if (total <= 0) return 0;
|
||||
return Math.min(100, Math.round((Number(coupon.usedAmount) / total) * 100));
|
||||
}
|
||||
|
||||
function buildRedeemUrl(coupon?: CouponItem) {
|
||||
if (!coupon) return '/redeem';
|
||||
const params = new URLSearchParams({
|
||||
couponId: coupon.id,
|
||||
amount: String(coupon.balance),
|
||||
});
|
||||
return `/redeem?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default function BenefitPage() {
|
||||
usePageView('benefit_page_view');
|
||||
const navigate = useNavigate();
|
||||
const listRef = useRef<HTMLElement>(null);
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
request<BenefitSummary>('USER_H5', '/benefit/summary'),
|
||||
request<CouponItem[]>('USER_H5', '/benefit/coupons'),
|
||||
])
|
||||
.then(([s, list]) => {
|
||||
setSummary(s);
|
||||
setCoupons(
|
||||
list.map((c) => ({
|
||||
id: String(c.id),
|
||||
couponNo: String(c.couponNo),
|
||||
totalAmount: Number(c.totalAmount),
|
||||
usedAmount: Number(c.usedAmount),
|
||||
balance: Number(c.balance),
|
||||
status: String(c.status),
|
||||
sourceProduct: String(c.sourceProduct),
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
|
||||
const visible = tab === 'available' ? available : history;
|
||||
|
||||
function scrollToList() {
|
||||
listRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function goRedeem(coupon?: CouponItem) {
|
||||
navigate(buildRedeemUrl(coupon));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="benefit-page">
|
||||
<header className="benefit-header">
|
||||
<button type="button" className="benefit-header-city">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>郑州市</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">好客权益</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="benefit-header-btn"
|
||||
aria-label="通知"
|
||||
onClick={() => window.alert('preV1:消息通知即将开放')}
|
||||
>
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="benefit-main">
|
||||
<section className="benefit-hero">
|
||||
<div className="benefit-hero-top">
|
||||
<div>
|
||||
<p className="benefit-hero-label">当前好客权益余额</p>
|
||||
<div className="benefit-hero-amount">
|
||||
<span className="benefit-hero-symbol">¥</span>
|
||||
<span className="benefit-hero-value">
|
||||
{summary ? formatMoney(summary.totalBalance) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<BrandLogo className="benefit-hero-logo" />
|
||||
</div>
|
||||
<button type="button" className="benefit-hero-link" onClick={scrollToList}>
|
||||
<span>查看权益明细</span>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="benefit-action">
|
||||
<button type="button" className="benefit-use-btn" onClick={() => goRedeem()}>
|
||||
<span className="material-symbols-outlined filled">qr_code_2</span>
|
||||
去使用
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<nav className="benefit-tabs" ref={listRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`benefit-tab${tab === 'available' ? ' active' : ''}`}
|
||||
onClick={() => setTab('available')}
|
||||
>
|
||||
待使用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`benefit-tab${tab === 'history' ? ' active' : ''}`}
|
||||
onClick={() => setTab('history')}
|
||||
>
|
||||
已用完/已过期
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{visible.length > 0 ? (
|
||||
<div className="benefit-list">
|
||||
{visible.map((c) => (
|
||||
<article
|
||||
key={c.id}
|
||||
className={`benefit-card${tab === 'available' && c.balance > 0 ? ' benefit-card--clickable' : ''}`}
|
||||
role={tab === 'available' && c.balance > 0 ? 'button' : undefined}
|
||||
tabIndex={tab === 'available' && c.balance > 0 ? 0 : undefined}
|
||||
onClick={() => {
|
||||
if (tab === 'available' && c.balance > 0) goRedeem(c);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (tab === 'available' && c.balance > 0 && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
goRedeem(c);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="benefit-card-inner">
|
||||
<div className="benefit-card-value">
|
||||
<span className="benefit-card-value-label">好客权益</span>
|
||||
<div className="benefit-card-value-amount">
|
||||
<span>¥</span>
|
||||
<span>{Math.round(c.totalAmount)}</span>
|
||||
</div>
|
||||
<span className="benefit-card-notch" aria-hidden />
|
||||
</div>
|
||||
<div className="benefit-card-body">
|
||||
<div className="benefit-card-main">
|
||||
<div className="benefit-card-title-row">
|
||||
<h3 className="benefit-card-title">{c.sourceProduct}</h3>
|
||||
<span className="benefit-card-badge">永久有效</span>
|
||||
</div>
|
||||
<p className="benefit-card-desc">适用于合作酒店餐饮消费,到店核销使用</p>
|
||||
<div className="benefit-card-progress-wrap">
|
||||
<div className="benefit-card-progress-labels">
|
||||
<span>已使用 ¥{formatMoney(c.usedAmount)}</span>
|
||||
<span>未使用 ¥{formatMoney(c.balance)}</span>
|
||||
</div>
|
||||
<div className="benefit-card-progress">
|
||||
<div
|
||||
className="benefit-card-progress-bar"
|
||||
style={{ width: `${usagePercent(c)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="benefit-card-foot">
|
||||
<Link
|
||||
to={`/benefit/${c.id}`}
|
||||
className="benefit-card-no"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatCouponNo(c.couponNo)}
|
||||
</Link>
|
||||
{tab === 'available' && c.balance > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="benefit-card-redeem"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goRedeem(c);
|
||||
}}
|
||||
>
|
||||
立即核销
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="benefit-empty">
|
||||
{tab === 'available' ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined">card_giftcard</span>
|
||||
<p>暂无可用权益,购酒后自动发放</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined">history_edu</span>
|
||||
<p>暂无过期或已使用的权益记录</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import {
|
||||
getCustomerServicePhone,
|
||||
loadCustomerServicePhone,
|
||||
openWecomCustomerService,
|
||||
} from '../lib/customer-service';
|
||||
import { track } from '../lib/analytics';
|
||||
|
||||
export default function CustomerServicePage() {
|
||||
const navigate = useNavigate();
|
||||
const [phone, setPhone] = useState(getCustomerServicePhone);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCustomerServicePhone().then(setPhone);
|
||||
}, []);
|
||||
|
||||
const tel = phone.replace(/-/g, '');
|
||||
|
||||
function openOnline() {
|
||||
track('cs_contact', { type: 'wecom_kf' });
|
||||
openWecomCustomerService();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="customer-service-page customer-service-page--oa">
|
||||
<SubPageHeader title="在线客服" onBack={() => navigate(-1)} />
|
||||
|
||||
<div className="customer-service-oa-body">
|
||||
<p className="customer-service-wecom-title">杜康好客客服</p>
|
||||
<p className="customer-service-wecom-hint">点击下方按钮,在微信内进入在线客服会话</p>
|
||||
|
||||
<button type="button" className="customer-service-wecom-btn" onClick={openOnline}>
|
||||
<span className="material-symbols-outlined">headset_mic</span>
|
||||
联系在线客服
|
||||
</button>
|
||||
|
||||
<a className="customer-service-phone-link" href={`tel:${tel}`}>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
或拨打客服电话 {phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import TabMainHeader from '../components/TabMainHeader';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { getProductImages } from '../lib/product-images';
|
||||
import { track } from '../lib/analytics';
|
||||
import {
|
||||
CITY_STORAGE_KEY,
|
||||
FALLBACK_CITY_CODE,
|
||||
resolveUserCity,
|
||||
} from '../lib/wechat-location';
|
||||
import { formatRegionCity } from '../lib/region-data';
|
||||
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
price: number;
|
||||
benefitDisplay: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
aromaType: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type City = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const AROMA_TABS = [
|
||||
{ key: 'QINGXIANG', label: '清香型', open: true },
|
||||
{ key: 'JIANGXIANG', label: '酱香型', open: false },
|
||||
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
const [tab, setTab] = useState('QINGXIANG');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || FALLBACK_CITY_CODE);
|
||||
const [locationLabel, setLocationLabel] = useState('定位中...');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
track('home_view', { pagePath: '/', cityCode });
|
||||
}, [cityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
||||
setCities(list);
|
||||
if (!list.some((c) => c.code === cityCode) && list[0]) {
|
||||
setCityCode(list[0].code);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
resolveUserCity().then((resolved) => {
|
||||
if (!resolved) {
|
||||
setLocationLabel('郑州市');
|
||||
setCityCode(FALLBACK_CITY_CODE);
|
||||
return;
|
||||
}
|
||||
setLocationLabel(formatRegionCity(resolved.province, resolved.city));
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
setCityCode(resolved.cityCode);
|
||||
} else {
|
||||
setCityCode(FALLBACK_CITY_CODE);
|
||||
setToast('当前城市暂未开城,已展示郑州商品');
|
||||
window.setTimeout(() => setToast(''), 2200);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cityCode) return;
|
||||
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
||||
request<Product[]>('USER_H5', `/catalog/products?cityCode=${encodeURIComponent(cityCode)}`).then(setProducts);
|
||||
}, [cityCode]);
|
||||
|
||||
function showToast(message: string) {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(''), 2200);
|
||||
}
|
||||
|
||||
function onAromaTabClick(key: string, open: boolean) {
|
||||
if (!open) {
|
||||
showToast('暂未开放');
|
||||
return;
|
||||
}
|
||||
setTab(key);
|
||||
}
|
||||
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
return (
|
||||
<div className="page home-page">
|
||||
<TabMainHeader
|
||||
title="杜康好客"
|
||||
extra={(
|
||||
<div className="tab-main-city tab-main-city--readonly" aria-label={`当前位置 ${locationLabel}`}>
|
||||
<span className="material-symbols-outlined" aria-hidden>location_on</span>
|
||||
<span className="tab-main-city-label">{locationLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<nav className="home-aroma-nav">
|
||||
{AROMA_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={`home-aroma-tab${tab === t.key ? ' active' : ''}${!t.open ? ' muted' : ''}`}
|
||||
onClick={() => onAromaTabClick(t.key, t.open)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="home-product-list">
|
||||
{!onSale && <div className="home-empty">该香型暂未上线,敬请期待</div>}
|
||||
{onSale &&
|
||||
filtered.map((p) => (
|
||||
<article key={p.id} className="home-product-card">
|
||||
<Link to={`/product/${p.id}`} className="home-product-link">
|
||||
<ProductCarousel images={getProductImages(p)} alt={p.name} />
|
||||
<div className="home-product-body">
|
||||
<div className="home-product-row">
|
||||
<h3 className="home-product-name">{p.name}</h3>
|
||||
<span className="home-product-price">¥{p.price}</span>
|
||||
</div>
|
||||
<p className="home-product-sub">{p.subtitle}</p>
|
||||
<div className="home-product-footer">
|
||||
<CouponBadge amount={p.benefitDisplay} label="好客权益" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="home-product-actions">
|
||||
<Link to={`/product/${p.id}`} className="home-buy-btn">
|
||||
立即购买
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<AppToast message={toast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
INVOICE_KIND_LABELS,
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceKind,
|
||||
type InvoiceTitleType,
|
||||
} from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type OrderRow = { id: string; orderNo: string; payAmount: number | string; productName?: string };
|
||||
|
||||
export default function InvoiceApplyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const presetOrderId = params.get('orderId') || '';
|
||||
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [orderId, setOrderId] = useState(presetOrderId);
|
||||
const [titleType, setTitleType] = useState<InvoiceTitleType>('PERSONAL');
|
||||
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
|
||||
const [titleName, setTitleName] = useState('');
|
||||
const [taxNo, setTaxNo] = useState('');
|
||||
const [addressPhone, setAddressPhone] = useState('');
|
||||
const [bankAccount, setBankAccount] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50')
|
||||
.then((res) => setOrders(res.list ?? []))
|
||||
.catch(() => setOrders([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (invoiceKind === 'SPECIAL') setTitleType('ENTERPRISE');
|
||||
}, [invoiceKind]);
|
||||
|
||||
async function submit() {
|
||||
if (!orderId) {
|
||||
window.alert('请选择订单');
|
||||
return;
|
||||
}
|
||||
if (!titleName.trim() || !email.trim() || !phone.trim()) {
|
||||
window.alert('请填写抬头名称、邮箱与手机号');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request('USER_H5', `/trade/orders/${orderId}/invoices`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
titleType,
|
||||
invoiceKind,
|
||||
titleName: titleName.trim(),
|
||||
taxNo: taxNo.trim() || undefined,
|
||||
addressPhone: addressPhone.trim() || undefined,
|
||||
bankAccount: bankAccount.trim() || undefined,
|
||||
email: email.trim(),
|
||||
phone: phone.trim(),
|
||||
}),
|
||||
});
|
||||
window.alert('发票申请已提交,总部将在 2 个工作日内开具');
|
||||
navigate('/invoices');
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '申请失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="after-sale-page">
|
||||
<SubPageHeader title="申请发票" onBack={() => navigate(-1)} />
|
||||
<main className="after-sale-body after-sale-form">
|
||||
<label className="after-sale-label">选择已完成订单</label>
|
||||
<div className="after-sale-order-list" style={{ marginBottom: 16 }}>
|
||||
{orders.length === 0 ? (
|
||||
<p className="after-sale-empty">暂无已完成订单</p>
|
||||
) : (
|
||||
orders.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
|
||||
onClick={() => setOrderId(o.id)}
|
||||
>
|
||||
<p className="after-sale-order-no">{o.orderNo}</p>
|
||||
<p className="after-sale-order-meta">
|
||||
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="after-sale-label">发票类型</label>
|
||||
<div className="invoice-chip-row">
|
||||
{(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className={`invoice-chip${invoiceKind === k ? ' is-selected' : ''}`}
|
||||
onClick={() => setInvoiceKind(k)}
|
||||
>
|
||||
{INVOICE_KIND_LABELS[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="after-sale-label">抬头类型</label>
|
||||
<div className="invoice-chip-row">
|
||||
{(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`invoice-chip${titleType === t ? ' is-selected' : ''}`}
|
||||
disabled={invoiceKind === 'SPECIAL' && t === 'PERSONAL'}
|
||||
onClick={() => setTitleType(t)}
|
||||
>
|
||||
{INVOICE_TITLE_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="after-sale-label">抬头名称</label>
|
||||
<input className="after-sale-input" value={titleName} onChange={(e) => setTitleName(e.target.value)} placeholder="个人姓名或公司全称" />
|
||||
|
||||
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
|
||||
<>
|
||||
<label className="after-sale-label">税号</label>
|
||||
<input className="after-sale-input" value={taxNo} onChange={(e) => setTaxNo(e.target.value)} placeholder="纳税人识别号" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{invoiceKind === 'SPECIAL' && (
|
||||
<>
|
||||
<label className="after-sale-label">地址与电话</label>
|
||||
<input className="after-sale-input" value={addressPhone} onChange={(e) => setAddressPhone(e.target.value)} />
|
||||
<label className="after-sale-label">开户行与账号</label>
|
||||
<input className="after-sale-input" value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="after-sale-label">接收邮箱</label>
|
||||
<input className="after-sale-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<label className="after-sale-label">手机号</label>
|
||||
<input className="after-sale-input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
|
||||
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
|
||||
{submitting ? '提交中…' : '提交申请'}
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
INVOICE_KIND_LABELS,
|
||||
INVOICE_STATUS_LABELS,
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceDto,
|
||||
} from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
export default function InvoiceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<InvoiceDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ items: InvoiceDto[] }>('USER_H5', '/trade/invoices?pageSize=50')
|
||||
.then((res) => setItems(res.items ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="after-sale-page">
|
||||
<SubPageHeader title="我的发票" onBack={() => navigate(-1)} />
|
||||
<main className="after-sale-body">
|
||||
{loading ? (
|
||||
<p className="after-sale-empty">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="after-sale-empty-box">
|
||||
<p className="after-sale-empty">暂无发票申请</p>
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
|
||||
申请发票
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="after-sale-order-list">
|
||||
{items.map((inv) => (
|
||||
<div key={inv.id} className="after-sale-order-item after-sale-ticket-card">
|
||||
<div className="after-sale-ticket-head">
|
||||
<span>
|
||||
{INVOICE_TITLE_TYPE_LABELS[inv.titleType]} · {INVOICE_KIND_LABELS[inv.invoiceKind]}
|
||||
</span>
|
||||
<span className="after-sale-ticket-status">
|
||||
{INVOICE_STATUS_LABELS[inv.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="after-sale-order-no">{inv.titleName}</p>
|
||||
<p className="after-sale-order-meta">
|
||||
{inv.invoiceNo} · 订单 {inv.orderNo ?? inv.orderId}
|
||||
</p>
|
||||
{inv.status === 'ISSUED' && inv.fileUrl ? (
|
||||
<a className="after-sale-file-link" href={inv.fileUrl} target="_blank" rel="noreferrer">
|
||||
查看/下载发票
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
|
||||
申请发票
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
|
||||
|
||||
type LegalPageProps = {
|
||||
docId: LegalDocument['id'];
|
||||
/** 返回登录页的路径,如 /login */
|
||||
backTo?: string;
|
||||
};
|
||||
|
||||
/** H5 各端共用的协议/隐私正文页 */
|
||||
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
|
||||
const doc = getLegalDocument(docId);
|
||||
|
||||
return (
|
||||
<div className="legal-h5-page">
|
||||
<header className="legal-h5-header">
|
||||
<Link to={backTo} className="legal-h5-back" aria-label="返回">
|
||||
‹
|
||||
</Link>
|
||||
<h1 className="legal-h5-title">{doc.title}</h1>
|
||||
</header>
|
||||
<main className="legal-h5-body">
|
||||
<p className="legal-h5-updated">更新日期:{doc.updatedAt}</p>
|
||||
<p className="legal-h5-intro">{doc.intro}</p>
|
||||
{doc.sections.map((section) => (
|
||||
<section key={section.heading} className="legal-h5-section">
|
||||
<h2>{section.heading}</h2>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<p key={`${section.heading}-${i}`}>{p}</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import { SmsScene } from '@dukang/shared-types';
|
||||
import { request, type SessionPayload } from '../lib/api';
|
||||
import { fetchClientConfig } from '../lib/pay-wechat';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||
import { useSmsCode } from '../lib/use-sms-code';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { loginWithWechatSdk, handleWechatAuthCallback as handleWechatOAuthCallback } from '../lib/wechat-auth';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
import { touchPromoIfNeeded } from '../lib/promo';
|
||||
|
||||
async function finishLogin(navigate: (path: string) => void, returnTo: string) {
|
||||
await touchPromoIfNeeded();
|
||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnTo = searchParams.get('return') || '/';
|
||||
const { applySession } = useUserSession();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||
const [bindMode, setBindMode] = useState(false);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
||||
useSmsCode();
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize) return;
|
||||
handleWechatOAuthCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
handleWechatLoginResult(result);
|
||||
})
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败'));
|
||||
}, [wxAuthorize]);
|
||||
|
||||
function handleWechatLoginResult(result: WechatLoginResult) {
|
||||
if (result.accessToken) {
|
||||
applySession({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken ?? '',
|
||||
deviceKey: result.deviceKey,
|
||||
phoneVerified: !!result.phoneVerified,
|
||||
user: result.user as SessionPayload['user'],
|
||||
});
|
||||
void finishLogin(navigate, returnTo);
|
||||
return;
|
||||
}
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function onSendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
clearMessages();
|
||||
setMsg('');
|
||||
await sendCode(phone, bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
const phoneCheck = validateMobilePhone(phone);
|
||||
if (!phoneCheck.ok) {
|
||||
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
||||
return;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
setMsg('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
setSmsError('');
|
||||
try {
|
||||
if (bindMode && wxSessionKey) {
|
||||
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ wxSessionKey, phone, code }),
|
||||
});
|
||||
handleWechatLoginResult(data);
|
||||
return;
|
||||
}
|
||||
const data = await request<SessionPayload>('USER_H5', '/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
applySession(data);
|
||||
await finishLogin(navigate, returnTo);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await loginWithWechatSdk();
|
||||
if (result) handleWechatLoginResult(result);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '微信登录失败');
|
||||
}
|
||||
}
|
||||
|
||||
const displayMsg = msg || smsError;
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<header className="login-header">
|
||||
<div className="login-logo-wrap">
|
||||
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="login-logo" fit="contain" />
|
||||
<span className="login-logo-badge">官方</span>
|
||||
</div>
|
||||
<div className="login-welcome">
|
||||
<h1 className="login-welcome-title">欢迎来到杜康好客</h1>
|
||||
<p className="login-welcome-sub">买美酒,享好礼</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="login-main">
|
||||
<div className="login-card">
|
||||
<h3 className="login-card-title">{bindMode ? '绑定手机号' : '手机验证码登录'}</h3>
|
||||
<div className="login-field">
|
||||
<span className="login-field-prefix">+86</span>
|
||||
<input
|
||||
type="tel"
|
||||
className="login-field-input"
|
||||
placeholder="请输入手机号"
|
||||
maxLength={11}
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(normalizePhoneInput(e.target.value));
|
||||
setMsg('');
|
||||
clearMessages();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="login-field-input"
|
||||
placeholder="请输入验证码"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
|
||||
disabled={codeCooldown > 0 || sending}
|
||||
onClick={onSendCode}
|
||||
>
|
||||
{sending
|
||||
? '发送中...'
|
||||
: codeCooldown > 0
|
||||
? `${codeCooldown}s 后重新获取`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{(displayMsg || sentHint) && (
|
||||
<p className={`login-msg${sentHint && !displayMsg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg || sentHint}
|
||||
</p>
|
||||
)}
|
||||
<label className="login-agreement" ref={agreementRef}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
我已阅读并同意
|
||||
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
||||
《用户协议》
|
||||
</Link>
|
||||
和
|
||||
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
||||
《隐私政策》
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="login-sms-btn"
|
||||
disabled={loading}
|
||||
onClick={login}
|
||||
>
|
||||
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!bindMode && wxAuthorize && (
|
||||
<>
|
||||
<div className="login-divider">
|
||||
<span className="login-divider-line" />
|
||||
<span className="login-divider-text">或者</span>
|
||||
<span className="login-divider-line" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
|
||||
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
||||
<span>微信一键授权</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import TabMainHeader from '../components/TabMainHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request, type UserProfile } from '../lib/api';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
import ContactCustomerSheet from '../components/ContactCustomerSheet';
|
||||
|
||||
const DEFAULT_AVATAR =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAz_9Pnpk_Md4sEU6PXkeybus8oLZO9e-3pOpLuSwBX0jm_Z0JCfX1w2oZxz1VZayTh0PKUPjwjSuxJVX410fjtWFGR_f55f-nWppXWUweHRnEC7WyIWEqx4AyVHt-k02OhyaSGQfvY5cHG5IuRe9EqdcHy47gBQ82_cxGgX-DrKV4oYcwLoNRynAV0_xv2p1GOhisnQVulHwZcQClUJcP8q4nTY0Y3DR1w4ioa0DYTHePE43mLDJptjZcQqS7V8LihJdn4ze6fvQA';
|
||||
|
||||
const ORDER_SHORTCUTS = [
|
||||
{ tab: 'pending_pay', icon: 'payments', label: '待付款' },
|
||||
{ tab: 'paid', icon: 'package_2', label: '已付款' },
|
||||
{ tab: 'completed', icon: 'task_alt', label: '已完成' },
|
||||
] as const;
|
||||
|
||||
const SERVICES = [
|
||||
{ icon: 'location_on', label: '地址管理', to: '/addresses' },
|
||||
{ icon: 'assignment_return', label: '售后工单', to: '/after-sale/list' },
|
||||
{ icon: 'receipt_long', label: '我的发票', to: '/invoices' },
|
||||
{ icon: 'storefront', label: '可用门店', to: '/stores' },
|
||||
{ icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const },
|
||||
{ icon: 'info', label: '关于我们', action: 'about' as const },
|
||||
] as const;
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function MinePage() {
|
||||
const navigate = useNavigate();
|
||||
const { profile: sessionProfile, refreshProfile, resetSession } = useUserSession();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(sessionProfile);
|
||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||
const [toast, setToast] = useState('');
|
||||
const [showCs, setShowCs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
request<UserProfile>('USER_H5', '/auth/me'),
|
||||
request<Array<Record<string, unknown>>>('USER_H5', '/benefit/coupons'),
|
||||
...ORDER_SHORTCUTS.map((s) =>
|
||||
request<{ total: number }>('USER_H5', `/trade/orders?tab=${s.tab}&pageSize=1`),
|
||||
),
|
||||
])
|
||||
.then(([me, coupons, ...totals]) => {
|
||||
setProfile(me);
|
||||
void refreshProfile();
|
||||
const balance = coupons.reduce((sum, c) => {
|
||||
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||
return sum;
|
||||
}, 0);
|
||||
setBenefitBalance(balance);
|
||||
const counts: Record<string, number> = {};
|
||||
ORDER_SHORTCUTS.forEach((s, i) => {
|
||||
counts[s.tab] = totals[i]?.total ?? 0;
|
||||
});
|
||||
setOrderCounts(counts);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [refreshProfile]);
|
||||
|
||||
function showToast(msg: string) {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(''), 2200);
|
||||
}
|
||||
|
||||
function handleService(item: (typeof SERVICES)[number]) {
|
||||
if ('to' in item && item.to) return;
|
||||
if (item.action === 'cs') setShowCs(true);
|
||||
if (item.action === 'about') showToast('杜康好客 · 传承千年酒文化');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
void resetSession().then(() => navigate('/'));
|
||||
}
|
||||
|
||||
const nickname = profile?.nickname || '用户';
|
||||
const avatar = profile?.avatarUrl || DEFAULT_AVATAR;
|
||||
const hasWechat = !!profile?.hasWechat;
|
||||
|
||||
return (
|
||||
<div className="mine-page">
|
||||
<TabMainHeader title="我的" />
|
||||
<header className="mine-header">
|
||||
<div className="mine-header-texture" aria-hidden />
|
||||
<div className="mine-profile">
|
||||
<div className="mine-avatar-wrap">
|
||||
<AppImage src={avatar} alt="" wrapperClassName="mine-avatar app-image--fill" />
|
||||
{hasWechat && (
|
||||
<span className="mine-wechat-badge" title="已绑定微信">
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mine-profile-info">
|
||||
<h1 className="mine-profile-name">{nickname}</h1>
|
||||
<div className="mine-profile-meta">
|
||||
<span className="mine-member-tag">{hasWechat ? '微信会员' : '好客会员'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mine-header-glow" aria-hidden />
|
||||
</header>
|
||||
|
||||
<main className="mine-main">
|
||||
<section className="mine-card">
|
||||
<div className="mine-card-head">
|
||||
<h2 className="mine-card-title">
|
||||
<span className="material-symbols-outlined mine-card-title-icon">account_balance_wallet</span>
|
||||
我的资产
|
||||
</h2>
|
||||
<Link to="/benefit" className="mine-card-link">
|
||||
查看明细
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mine-asset-panel">
|
||||
<div className="mine-asset-notch" aria-hidden />
|
||||
<div className="mine-asset-notch-line" aria-hidden />
|
||||
<div>
|
||||
<p className="mine-asset-label">好客权益余额</p>
|
||||
<div className="mine-asset-amount">
|
||||
<span className="mine-asset-currency">¥</span>
|
||||
<span className="mine-asset-value">{formatMoney(benefitBalance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/redeem" className="mine-asset-cta">
|
||||
去使用
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mine-card">
|
||||
<div className="mine-card-head mine-card-head-orders">
|
||||
<h2 className="mine-card-title">我的订单</h2>
|
||||
<Link to="/orders" className="mine-card-link">
|
||||
全部订单
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mine-order-grid">
|
||||
{ORDER_SHORTCUTS.map((item) => {
|
||||
const count = orderCounts[item.tab] ?? 0;
|
||||
return (
|
||||
<Link key={item.tab} to={`/orders?tab=${item.tab}`} className="mine-order-item">
|
||||
<div className="mine-order-icon-wrap">
|
||||
<span className="material-symbols-outlined mine-order-icon">{item.icon}</span>
|
||||
{count > 0 && (
|
||||
<span className="mine-order-badge">{count > 99 ? '99+' : count}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="mine-order-label">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mine-card mine-services">
|
||||
{SERVICES.map((item, index) => {
|
||||
const inner = (
|
||||
<>
|
||||
<div className="mine-service-left">
|
||||
<span className="mine-service-icon-wrap">
|
||||
<span className="material-symbols-outlined">{item.icon}</span>
|
||||
</span>
|
||||
<span className="mine-service-label">{item.label}</span>
|
||||
</div>
|
||||
<div className="mine-service-right">
|
||||
{'badge' in item && item.badge && (
|
||||
<span className="mine-service-badge">{item.badge}</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined mine-service-chevron">chevron_right</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if ('to' in item && item.to) {
|
||||
return (
|
||||
<Link key={item.label} to={item.to} className="mine-service-item">
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="mine-service-item"
|
||||
onClick={() => handleService(item)}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<div className="mine-footer">
|
||||
<p className="mine-version">杜康好客 V2.4.0</p>
|
||||
<button type="button" className="mine-logout" onClick={logout}>
|
||||
退出当前账号
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{toast && <div className="mine-toast">{toast}</div>}
|
||||
|
||||
{showCs && <ContactCustomerSheet onClose={() => setShowCs(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { buildProductDetailUrl } from '../lib/navigation';
|
||||
import { getProductMainImage } from '../lib/product-images';
|
||||
import { track } from '../lib/analytics';
|
||||
import PhoneVerifySheet from '../components/PhoneVerifySheet';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
import { tryGetClientGpsLocation } from '../lib/client-location';
|
||||
import {
|
||||
applyWechatLoginResult,
|
||||
ensureWechatAuthForPay,
|
||||
handleWechatAuthCallback,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault?: number;
|
||||
};
|
||||
|
||||
type PreviewProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
product: PreviewProduct;
|
||||
quantity: number;
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY';
|
||||
productAmount: number;
|
||||
freightPayType: 'COD' | null;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
city?: { localMinQty: number; crossMinQty: number };
|
||||
};
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||
}
|
||||
|
||||
export default function OrderConfirmPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { phoneVerified, refreshProfile } = useUserSession();
|
||||
const [showPhoneVerify, setShowPhoneVerify] = useState(false);
|
||||
const [showWechatBindPhone, setShowWechatBindPhone] = useState(false);
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||
const [pendingSubmit, setPendingSubmit] = useState(false);
|
||||
const productId = params.get('productId') || '';
|
||||
const forceCross = params.get('cross') === '1';
|
||||
const [quantity, setQuantity] = useState(Number(params.get('qty') || 2));
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [addressId, setAddressId] = useState(params.get('addressId') || '');
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const phonePromptSkipped = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<Address[]>('USER_H5', '/user/addresses').then((list) => {
|
||||
setAddresses(list);
|
||||
const fromUrl = params.get('addressId');
|
||||
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
|
||||
setAddressId(fromUrl);
|
||||
return;
|
||||
}
|
||||
const def = list.find((a) => a.isDefault === 1) || list[0];
|
||||
if (def) setAddressId(String(def.id));
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
if (productId) {
|
||||
track('order_confirm_view', { refType: 'PRODUCT', refId: productId, productId, quantity });
|
||||
}
|
||||
}, [productId, quantity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv()) return;
|
||||
handleWechatAuthCallback()
|
||||
.then((result) => {
|
||||
if (result && applyWechatLoginResult(result)) {
|
||||
void refreshProfile();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [refreshProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
let cancelled = false;
|
||||
setPreviewLoading(true);
|
||||
const body: { productId: string; quantity: number; addressId?: string } = {
|
||||
productId,
|
||||
quantity,
|
||||
};
|
||||
if (addressId) body.addressId = addressId;
|
||||
|
||||
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((data) => {
|
||||
if (!cancelled) setPreview(data);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId, quantity, addressId]);
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
const delivery = forceCross || preview?.deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL';
|
||||
const localMin = preview?.city?.localMinQty ?? 2;
|
||||
const crossMin = preview?.city?.crossMinQty ?? 6;
|
||||
const min = delivery === 'LOCAL' ? localMin : crossMin;
|
||||
if (next < min) {
|
||||
setMsg(
|
||||
delivery === 'LOCAL'
|
||||
? `同城配送至少购买 ${min} 瓶`
|
||||
: `跨城配送至少购买 ${min} 瓶(1箱)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
setQuantity(next);
|
||||
const qs = new URLSearchParams(params);
|
||||
qs.set('qty', String(next));
|
||||
navigate({ search: qs.toString() }, { replace: true });
|
||||
}
|
||||
|
||||
const selectedAddress = useMemo(
|
||||
() => addresses.find((a) => String(a.id) === addressId),
|
||||
[addresses, addressId],
|
||||
);
|
||||
|
||||
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
|
||||
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
|
||||
|
||||
async function doSubmit() {
|
||||
let clientLocation = null;
|
||||
try {
|
||||
clientLocation = await tryGetClientGpsLocation();
|
||||
} catch {
|
||||
/* GPS 获取失败不阻塞下单 */
|
||||
}
|
||||
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
quantity,
|
||||
addressId,
|
||||
...(clientLocation ? { clientLocation } : {}),
|
||||
}),
|
||||
});
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('orderId', order.id);
|
||||
qs.set('productId', productId);
|
||||
qs.set('qty', String(quantity));
|
||||
qs.set('addressId', addressId);
|
||||
if (forceCross) qs.set('cross', '1');
|
||||
navigate(`/pay?${qs.toString()}`);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!addressId) {
|
||||
setMsg('请选择收货地址');
|
||||
return;
|
||||
}
|
||||
if (!phoneVerified && !phonePromptSkipped.current) {
|
||||
setPendingSubmit(true);
|
||||
setShowPhoneVerify(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const authResult = await ensureWechatAuthForPay();
|
||||
if (!authResult.ok) {
|
||||
if ('needBindPhone' in authResult) {
|
||||
// 兼容旧接口:微信授权后提示可选绑定
|
||||
setWxSessionKey(authResult.wxSessionKey);
|
||||
setShowWechatBindPhone(true);
|
||||
setPendingSubmit(true);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWechatPhoneBound() {
|
||||
await refreshProfile();
|
||||
if (!pendingSubmit) return;
|
||||
setPendingSubmit(false);
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePhoneVerified() {
|
||||
await refreshProfile();
|
||||
if (!pendingSubmit) return;
|
||||
setPendingSubmit(false);
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="order-confirm-page">
|
||||
<SubPageHeader
|
||||
title="确认订单"
|
||||
onBack={() => navigate(buildProductDetailUrl(productId))}
|
||||
/>
|
||||
|
||||
<main className="order-confirm-main sub-page-body">
|
||||
<button
|
||||
type="button"
|
||||
className="order-confirm-card order-confirm-address"
|
||||
onClick={() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (productId) qs.set('productId', productId);
|
||||
qs.set('qty', String(quantity));
|
||||
if (forceCross) qs.set('cross', '1');
|
||||
if (addressId) qs.set('addressId', addressId);
|
||||
qs.set('select', '1');
|
||||
navigate(`/addresses?${qs.toString()}`);
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined order-confirm-pin fill-icon">location_on</span>
|
||||
{selectedAddress ? (
|
||||
<div className="order-confirm-address-body">
|
||||
<div className="order-confirm-address-row">
|
||||
<span className="order-confirm-address-name">{selectedAddress.receiverName}</span>
|
||||
<span className="order-confirm-address-phone">{maskPhone(selectedAddress.phone)}</span>
|
||||
</div>
|
||||
<p className="order-confirm-address-detail">{formatAddress(selectedAddress)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="order-confirm-address-placeholder">请选择收货地址</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined order-confirm-chevron">chevron_right</span>
|
||||
</button>
|
||||
|
||||
{isCross && (
|
||||
<div className="order-confirm-card order-confirm-warning">
|
||||
<span className="material-symbols-outlined order-confirm-warning-icon">warning</span>
|
||||
<p className="order-confirm-warning-text">
|
||||
提示:该地址超出同城配送范围,将由总部通过物流快递发货。物流费用需由您承担(到付),请确认是否继续。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<section className="order-confirm-card order-confirm-product">
|
||||
<div className="order-confirm-product-thumb">
|
||||
<AppImage
|
||||
src={productImage}
|
||||
alt={preview.product.name}
|
||||
wrapperClassName="app-image--fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="order-confirm-product-info">
|
||||
<div>
|
||||
<h3 className="order-confirm-product-name">{preview.product.name}</h3>
|
||||
<p className="order-confirm-product-spec">
|
||||
{preview.product.spec || preview.product.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="order-confirm-product-meta">
|
||||
<span className="order-confirm-product-price">¥{preview.product.price}</span>
|
||||
<div className="order-confirm-qty-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="order-confirm-qty-btn"
|
||||
disabled={quantity <= minQty}
|
||||
aria-label="减少数量"
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<span className="material-symbols-outlined">remove</span>
|
||||
</button>
|
||||
<span className="order-confirm-qty-value">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="order-confirm-qty-btn order-confirm-qty-btn--plus"
|
||||
aria-label="增加数量"
|
||||
onClick={() => updateQuantity(quantity + 1)}
|
||||
>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="order-confirm-card order-confirm-benefit coupon-notch">
|
||||
<div className="order-confirm-benefit-inner">
|
||||
<span className="order-confirm-benefit-badge">好客权益</span>
|
||||
<span className="order-confirm-benefit-text">
|
||||
本单可享好客权益 ¥{preview.benefitAmount}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="order-confirm-card order-confirm-row-card">
|
||||
<span className="order-confirm-row-label">配送方式</span>
|
||||
<div className="order-confirm-delivery-value">
|
||||
<p className="order-confirm-row-value">
|
||||
{!addressId
|
||||
? '选择地址后确认'
|
||||
: isCross
|
||||
? '物流配送'
|
||||
: '小飞侠配送'}
|
||||
</p>
|
||||
{addressId && !isCross && (
|
||||
<p className="order-confirm-delivery-hint">预计24小时内送达</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="order-confirm-card order-confirm-summary">
|
||||
<div className="order-confirm-summary-line">
|
||||
<span>商品总额</span>
|
||||
<span>¥{preview.productAmount}</span>
|
||||
</div>
|
||||
<div className="order-confirm-summary-line">
|
||||
<span>运费</span>
|
||||
<span className={isCross ? 'order-confirm-freight-cod' : ''}>
|
||||
{isCross ? '到付' : '免运费'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="order-confirm-summary-total">
|
||||
<span>合计</span>
|
||||
<span className="order-confirm-total-amount">¥{preview.payAmount}</span>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{previewLoading && !preview && productId && (
|
||||
<div className="order-confirm-loading">加载订单信息...</div>
|
||||
)}
|
||||
|
||||
{!previewLoading && !preview && productId && (
|
||||
<div className="order-confirm-loading">无法加载商品信息</div>
|
||||
)}
|
||||
|
||||
{msg && <p className="order-confirm-msg">{msg}</p>}
|
||||
</main>
|
||||
|
||||
<footer className="order-confirm-footer">
|
||||
<div className="order-confirm-footer-inner">
|
||||
<div className="order-confirm-pay-label">
|
||||
<span className="order-confirm-pay-prefix">实付:</span>
|
||||
<span className="order-confirm-pay-amount">
|
||||
¥{preview?.payAmount ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="order-confirm-pay-btn"
|
||||
disabled={loading || !preview || !addressId}
|
||||
onClick={submit}
|
||||
>
|
||||
{loading ? '支付中...' : !addressId ? '请选择地址' : '微信支付'}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<PhoneVerifySheet
|
||||
open={showPhoneVerify}
|
||||
defaultPhone={selectedAddress?.phone}
|
||||
title="建议绑定手机号"
|
||||
description="绑定后便于订单通知与售后联系;关闭即可跳过,不绑定也能继续下单。"
|
||||
onClose={() => {
|
||||
setShowPhoneVerify(false);
|
||||
if (pendingSubmit) {
|
||||
phonePromptSkipped.current = true;
|
||||
setPendingSubmit(false);
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const authResult = await ensureWechatAuthForPay();
|
||||
if (!authResult.ok) {
|
||||
if ('needBindPhone' in authResult) {
|
||||
setWxSessionKey(authResult.wxSessionKey);
|
||||
setShowWechatBindPhone(true);
|
||||
setPendingSubmit(true);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}}
|
||||
onSuccess={handlePhoneVerified}
|
||||
/>
|
||||
|
||||
<PhoneVerifySheet
|
||||
open={showWechatBindPhone}
|
||||
mode="wechat_bind_phone"
|
||||
wxSessionKey={wxSessionKey ?? undefined}
|
||||
defaultPhone={selectedAddress?.phone}
|
||||
title="建议绑定手机号"
|
||||
description="绑定后便于订单通知与售后联系;关闭即可跳过继续下单。"
|
||||
onClose={() => {
|
||||
setShowWechatBindPhone(false);
|
||||
setWxSessionKey(null);
|
||||
if (pendingSubmit) {
|
||||
phonePromptSkipped.current = true;
|
||||
setPendingSubmit(false);
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}}
|
||||
onSuccess={handleWechatPhoneBound}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { request } from '../lib/api';
|
||||
import { buildOrderAddressSelectUrl } from '../lib/navigation';
|
||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||
import { handleShareButtonClick } from '../lib/wechat-share';
|
||||
import ContactCustomerSheet from '../components/ContactCustomerSheet';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
type OrderItem = {
|
||||
productName: string;
|
||||
productSpec: string;
|
||||
productImage: string;
|
||||
unitPrice: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
type OrderDelivery = {
|
||||
provider?: string;
|
||||
trackingNo?: string;
|
||||
logisticsCompany?: string;
|
||||
manualQueryUrl?: string;
|
||||
providerOrderNo?: string;
|
||||
};
|
||||
|
||||
type TrackNode = {
|
||||
trackInfo?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type OrderPayment = {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type Order = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
orderType?: string;
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
status: string;
|
||||
originOrderId?: string | null;
|
||||
remark?: string | null;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
receiverProvince?: string;
|
||||
receiverCity?: string;
|
||||
receiverDistrict?: string;
|
||||
receiverAddress: string;
|
||||
productAmount?: number;
|
||||
freightAmount?: number;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
createdAt: string;
|
||||
items?: OrderItem[];
|
||||
delivery?: OrderDelivery | null;
|
||||
payment?: OrderPayment | null;
|
||||
};
|
||||
|
||||
const PROGRESS_STEPS = [
|
||||
{ label: '下单成功' },
|
||||
{ label: '出库中' },
|
||||
{ label: '配送中' },
|
||||
{ label: '待签收' },
|
||||
{ label: '完成' },
|
||||
] as const;
|
||||
|
||||
const STATUS_BANNER: Record<string, { title: string; subtitle: string }> = {
|
||||
PENDING_PAY: { title: '待付款', subtitle: '请尽快完成支付' },
|
||||
PENDING_SHIP: { title: '待发货', subtitle: '商家正在备货,请耐心等待' },
|
||||
OUT_WAREHOUSE: { title: '出库中', subtitle: '商品正在出库打包' },
|
||||
SHIPPING: { title: '配送中', subtitle: '您的佳酿正在赶往您的餐桌,请保持电话畅通' },
|
||||
PENDING_RECEIVE: { title: '待签收', subtitle: '包裹已送达,请确认签收' },
|
||||
COMPLETED: { title: '已完成', subtitle: '感谢您的购买,期待再次光临' },
|
||||
RESHIP: { title: '补发中', subtitle: '包裹正在揽收,请耐心等待' },
|
||||
};
|
||||
|
||||
const EDITABLE_STATUSES = new Set(['PENDING_PAY', 'PENDING_SHIP', 'OUT_WAREHOUSE']);
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function progressActiveIndex(status: string) {
|
||||
switch (status) {
|
||||
case 'PENDING_PAY':
|
||||
return 0;
|
||||
case 'PENDING_SHIP':
|
||||
return 1;
|
||||
case 'OUT_WAREHOUSE':
|
||||
return 1;
|
||||
case 'SHIPPING':
|
||||
return 2;
|
||||
case 'PENDING_RECEIVE':
|
||||
return 3;
|
||||
case 'COMPLETED':
|
||||
return 4;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryProviderLabel(provider?: string, company?: string) {
|
||||
if (company) return company;
|
||||
if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') return '小飞侠配送';
|
||||
if (provider === 'LOGISTICS') return '快递配送';
|
||||
return provider;
|
||||
}
|
||||
|
||||
function fullReceiverAddress(order: Order) {
|
||||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
if (region && order.receiverAddress.startsWith(region)) {
|
||||
return order.receiverAddress;
|
||||
}
|
||||
return `${region}${order.receiverAddress}`;
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { id } = useParams();
|
||||
usePageView('order_detail_view', { orderId: id });
|
||||
const navigate = useNavigate();
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [showCs, setShowCs] = useState(false);
|
||||
const [copyHint, setCopyHint] = useState('');
|
||||
const [shareToast, setShareToast] = useState('');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
|
||||
|
||||
const isReship = order?.orderType === 'RESHIPMENT';
|
||||
const isProxy = order?.orderType === 'PROXY' || order?.isProxyOrder === true;
|
||||
|
||||
async function loadTrack() {
|
||||
if (!id) return;
|
||||
try {
|
||||
const data = await request<{ nodes?: TrackNode[] }>('USER_H5', `/trade/orders/${id}/track`);
|
||||
setTrackNodes(data.nodes ?? []);
|
||||
} catch {
|
||||
setTrackNodes([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrder() {
|
||||
if (!id) return;
|
||||
const data = await request<Order>('USER_H5', `/trade/orders/${id}`);
|
||||
setOrder(data);
|
||||
if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') {
|
||||
void loadTrack();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadOrder().catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
const banner = useMemo(() => {
|
||||
if (!order) return { title: '', subtitle: '' };
|
||||
if (isReship) return STATUS_BANNER.RESHIP;
|
||||
return STATUS_BANNER[order.status] ?? { title: order.status, subtitle: '' };
|
||||
}, [order, isReship]);
|
||||
|
||||
const activeProgress = order ? progressActiveIndex(order.status) : 0;
|
||||
const item = order?.items?.[0];
|
||||
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
|
||||
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
|
||||
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
|
||||
const canPay = order?.status === 'PENDING_PAY' && !isReship;
|
||||
const canRefund =
|
||||
order &&
|
||||
!canPay &&
|
||||
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
||||
const canInvoice = order?.status === 'COMPLETED';
|
||||
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
||||
const freightTotal = Number(order?.freightAmount ?? 0);
|
||||
|
||||
async function copyOrderNo() {
|
||||
if (!order) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(order.orderNo);
|
||||
setCopyHint('已复制');
|
||||
window.setTimeout(() => setCopyHint(''), 2000);
|
||||
} catch {
|
||||
setCopyHint('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReceive() {
|
||||
if (!id || !canConfirmReceive) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
const updated = await request<Order>('USER_H5', `/trade/orders/${id}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
});
|
||||
setOrder(updated);
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '确认收货失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!order) return <div className="empty">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div className="order-detail-page order-detail-page--stitch">
|
||||
<header className="order-detail-topbar">
|
||||
<button type="button" className="order-detail-topbar-btn" aria-label="返回" onClick={() => navigate(-1)}>
|
||||
<span className="material-symbols-outlined">arrow_back_ios</span>
|
||||
</button>
|
||||
<h1 className="order-detail-topbar-title">杜康好客</h1>
|
||||
<div className="order-detail-topbar-actions">
|
||||
<button type="button" className="order-detail-topbar-btn" aria-label="分享" onClick={() => handleShareButtonClick(setShareToast)}>
|
||||
<span className="material-symbols-outlined">share</span>
|
||||
</button>
|
||||
<button type="button" className="order-detail-topbar-btn" aria-label="更多" onClick={() => {}}>
|
||||
<span className="material-symbols-outlined">more_horiz</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AppToast message={shareToast} />
|
||||
|
||||
<main className="order-detail-main order-detail-main--stitch">
|
||||
<section className="order-detail-status-card">
|
||||
<div className="order-detail-status-deco" aria-hidden>
|
||||
<span className="material-symbols-outlined">local_shipping</span>
|
||||
</div>
|
||||
<div className="order-detail-status-body">
|
||||
<div className="order-detail-status-head">
|
||||
<span className="material-symbols-outlined filled">local_shipping</span>
|
||||
<h2>{banner.title}</h2>
|
||||
</div>
|
||||
<p>{banner.subtitle}</p>
|
||||
{isProxy && order?.proxyPartnerName ? (
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
代下单 · 由合伙人 {order.proxyPartnerName} 代下
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!isReship && (
|
||||
<section className="order-detail-card order-detail-progress-card">
|
||||
<div className="order-detail-progress">
|
||||
{PROGRESS_STEPS.map((step, index) => {
|
||||
const done = index < activeProgress;
|
||||
const active = index === activeProgress;
|
||||
const pending = index > activeProgress;
|
||||
return (
|
||||
<div
|
||||
key={step.label}
|
||||
className={`order-detail-progress-step${active ? ' active' : ''}${done ? ' done' : ''}${pending ? ' pending' : ''}`}
|
||||
>
|
||||
{index < PROGRESS_STEPS.length - 1 && (
|
||||
<span className={`order-detail-progress-line${done ? ' done' : ''}`} aria-hidden />
|
||||
)}
|
||||
<div className="order-detail-progress-dot">
|
||||
{done && <span className="material-symbols-outlined">check</span>}
|
||||
{active && <span className="order-detail-progress-pulse" />}
|
||||
{pending && <span className="order-detail-progress-idle" />}
|
||||
</div>
|
||||
<span className="order-detail-progress-label">{step.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isReship && (
|
||||
<section className="order-detail-card order-detail-reship-info">
|
||||
<div className="order-detail-kv">
|
||||
<span>补发原因</span>
|
||||
<span>{order.remark || '商品破损'}</span>
|
||||
</div>
|
||||
<div className="order-detail-kv">
|
||||
<span>关联原订单</span>
|
||||
{order.originOrderId ? (
|
||||
<Link to={`/orders/${order.originOrderId}`} className="order-detail-link">
|
||||
查看原订单
|
||||
</Link>
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{item && (
|
||||
<section className="order-detail-card">
|
||||
<div className="order-detail-product">
|
||||
<div className="order-detail-product-thumb">
|
||||
<AppImage src={productImage} alt={item.productName} wrapperClassName="app-image--fill" />
|
||||
</div>
|
||||
<div className="order-detail-product-info">
|
||||
<div>
|
||||
<h3 className="order-detail-product-name">{item.productName}</h3>
|
||||
<p className="order-detail-product-spec">规格:{item.productSpec}</p>
|
||||
</div>
|
||||
<div className="order-detail-product-meta">
|
||||
<span className="order-detail-product-price">
|
||||
<small>¥</small>
|
||||
{formatMoney(isReship ? 0 : Number(item.unitPrice)).replace('.00', '')}
|
||||
</span>
|
||||
<span className="order-detail-product-qty">x{item.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Number(order.benefitAmount) > 0 && !isReship && (
|
||||
<div className="order-detail-benefit-panel">
|
||||
<div className="order-detail-benefit-panel-left">
|
||||
<div className="order-detail-benefit-icon">
|
||||
<span className="material-symbols-outlined filled">confirmation_number</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="order-detail-benefit-amount">
|
||||
<span>¥{Math.round(Number(order.benefitAmount))}</span>
|
||||
<span>好客权益</span>
|
||||
</div>
|
||||
<p className="order-detail-benefit-sub">全城100+餐厅通用</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="order-detail-benefit-use" onClick={() => navigate('/redeem')}>
|
||||
去使用
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="order-detail-card">
|
||||
<div className="order-detail-card-head">
|
||||
<h3>
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
收货信息
|
||||
</h3>
|
||||
{canEditAddress && id && (
|
||||
<button
|
||||
type="button"
|
||||
className="order-detail-edit-btn"
|
||||
onClick={() => navigate(buildOrderAddressSelectUrl(id))}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
修改
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-detail-kv-list">
|
||||
<div className="order-detail-kv">
|
||||
<span>收货人</span>
|
||||
<span>
|
||||
{order.receiverName} {maskPhone(order.receiverPhone)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="order-detail-kv order-detail-kv--address">
|
||||
<span>收货地址</span>
|
||||
<span>{fullReceiverAddress(order)}</span>
|
||||
</div>
|
||||
<div className="order-detail-kv">
|
||||
<span>配送方式</span>
|
||||
<span>
|
||||
{deliveryProviderLabel(order.delivery?.provider, order.delivery?.logisticsCompany)}
|
||||
</span>
|
||||
</div>
|
||||
{order.delivery?.trackingNo && (
|
||||
<div className="order-detail-kv">
|
||||
<span>运单号</span>
|
||||
<span>{order.delivery.trackingNo}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.delivery?.manualQueryUrl && (
|
||||
<div className="order-detail-kv">
|
||||
<span>物流查询</span>
|
||||
<a
|
||||
href={order.delivery.manualQueryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="order-detail-link"
|
||||
>
|
||||
查看物流
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{trackNodes.length > 0 && (
|
||||
<section className="order-detail-card">
|
||||
<h3 className="order-detail-card-title">
|
||||
<span className="material-symbols-outlined">timeline</span>
|
||||
物流动态
|
||||
</h3>
|
||||
<div className="order-detail-kv-list order-detail-kv-list--bordered">
|
||||
{trackNodes.map((node, index) => (
|
||||
<div key={`${node.trackInfo}-${index}`} className="order-detail-kv">
|
||||
<span>{node.createdAt ? formatDateTime(node.createdAt) : '—'}</span>
|
||||
<span>{node.trackInfo || '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isReship && (
|
||||
<section className="order-detail-card">
|
||||
<h3 className="order-detail-card-title">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
订单信息
|
||||
</h3>
|
||||
<div className="order-detail-kv-list order-detail-kv-list--bordered">
|
||||
<div className="order-detail-kv">
|
||||
<span>订单编号</span>
|
||||
<span className="order-detail-kv-inline">
|
||||
<span className="order-detail-order-no">{order.orderNo}</span>
|
||||
<button type="button" className="order-detail-copy-btn" onClick={copyOrderNo}>
|
||||
{copyHint || '复制'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="order-detail-kv">
|
||||
<span>创建时间</span>
|
||||
<span>{formatDateTime(order.createdAt)}</span>
|
||||
</div>
|
||||
<div className="order-detail-kv">
|
||||
<span>支付方式</span>
|
||||
<span>{order.payment?.status === 'SUCCESS' ? '微信支付' : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="order-detail-summary">
|
||||
<div className="order-detail-kv order-detail-kv--muted">
|
||||
<span>商品总额</span>
|
||||
<span>¥{formatMoney(productTotal)}</span>
|
||||
</div>
|
||||
<div className="order-detail-kv order-detail-kv--muted">
|
||||
<span>运费</span>
|
||||
<span>¥{formatMoney(freightTotal)}</span>
|
||||
</div>
|
||||
<div className="order-detail-kv order-detail-kv--total">
|
||||
<span>实付金额</span>
|
||||
<span className="order-detail-pay-total">¥{formatMoney(Number(order.payAmount))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="order-detail-actionbar">
|
||||
<button type="button" className="order-detail-action-outline" onClick={() => setShowCs(true)}>
|
||||
<span className="material-symbols-outlined">headset_mic</span>
|
||||
联系客服
|
||||
</button>
|
||||
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
|
||||
<button
|
||||
type="button"
|
||||
className="order-detail-action-outline"
|
||||
onClick={() => navigate(`/after-sale?orderId=${order.id}&type=REFUND`)}
|
||||
>
|
||||
申请售后
|
||||
</button>
|
||||
)}
|
||||
{canInvoice && (
|
||||
<button
|
||||
type="button"
|
||||
className="order-detail-action-outline"
|
||||
onClick={() => navigate(`/invoices/apply?orderId=${order.id}`)}
|
||||
>
|
||||
申请发票
|
||||
</button>
|
||||
)}
|
||||
{canPay && (
|
||||
<button
|
||||
type="button"
|
||||
className="order-detail-action-primary"
|
||||
onClick={() => navigate(`/pay?orderId=${order.id}`)}
|
||||
>
|
||||
<span className="material-symbols-outlined">payments</span>
|
||||
去付款 ¥{formatMoney(Number(order.payAmount))}
|
||||
</button>
|
||||
)}
|
||||
{canConfirmReceive && (
|
||||
<button
|
||||
type="button"
|
||||
className="order-detail-action-primary"
|
||||
disabled={confirming}
|
||||
onClick={confirmReceive}
|
||||
>
|
||||
<span className="material-symbols-outlined">inventory</span>
|
||||
{confirming ? '提交中...' : '确认收货'}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{showCs && (
|
||||
<ContactCustomerSheet
|
||||
orderId={order.id}
|
||||
orderNo={order.orderNo}
|
||||
onClose={() => setShowCs(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import OrderStatusTabs from '@dukang/shared-ui/OrderStatusTabs';
|
||||
import { request } from '../lib/api';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending_pay', label: '待付款' },
|
||||
{ key: 'paid', label: '已付款' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
function isReshipOrder(order: Record<string, unknown>) {
|
||||
return String(order.orderType) === 'RESHIPMENT';
|
||||
}
|
||||
|
||||
function orderStatusLabel(order: Record<string, unknown>) {
|
||||
if (isReshipOrder(order)) return '补发中';
|
||||
return STATUS_LABEL[String(order.status)] || String(order.status);
|
||||
}
|
||||
|
||||
export default function OrderListPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab = params.get('tab') || 'all';
|
||||
usePageView('order_list_view', { tab });
|
||||
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] });
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
request<{ list: Array<Record<string, unknown>> }>('USER_H5', `/trade/orders?tab=${tab}`).then(setData);
|
||||
}, [tab]);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="我的订单" onBack={() => navigate('/mine')} />
|
||||
<OrderStatusTabs tabs={TABS} active={tab} onChange={(key) => setParams({ tab: key })} />
|
||||
{data.list.length === 0 && <div className="empty">暂无订单</div>}
|
||||
{data.list.map((o) => {
|
||||
const items = (o.items as Array<Record<string, unknown>>) || [];
|
||||
const item = items[0];
|
||||
const isReship = isReshipOrder(o);
|
||||
const isProxy = String(o.orderType) === 'PROXY' || o.isProxyOrder === true;
|
||||
const isPendingPay = String(o.status) === 'PENDING_PAY';
|
||||
return (
|
||||
<div key={String(o.id)} className="card">
|
||||
<div className="card-row" style={{ marginBottom: 8 }}>
|
||||
<span className="label-md text-muted">
|
||||
订单号: {String(o.orderNo)}
|
||||
{isReship && (
|
||||
<span className="tag-reship" style={{ marginLeft: 8 }}>
|
||||
补发单
|
||||
</span>
|
||||
)}
|
||||
{isProxy && (
|
||||
<span className="tag-reship" style={{ marginLeft: 8 }}>
|
||||
代下单
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="status-tag">{orderStatusLabel(o)}</span>
|
||||
</div>
|
||||
{isProxy && o.proxyPartnerName ? (
|
||||
<div className="label-md text-muted" style={{ marginBottom: 8 }}>
|
||||
由合伙人 {String(o.proxyPartnerName)} 代下
|
||||
</div>
|
||||
) : null}
|
||||
{item && (
|
||||
<div className="card-row">
|
||||
<AppImage
|
||||
src={String(item.productImage)}
|
||||
alt={String(item.productName || '')}
|
||||
wrapperClassName="order-list-thumb"
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="headline-md">{String(item.productName)}</div>
|
||||
<div className="text-muted body-md">{String(item.productSpec)} x{Number(item.quantity)}</div>
|
||||
</div>
|
||||
<div className="amount-lg">¥{Number(o.payAmount)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ textAlign: 'right', marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{isPendingPay && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-pill"
|
||||
onClick={() => navigate(`/pay?orderId=${o.id}`)}
|
||||
>
|
||||
去付款
|
||||
</button>
|
||||
)}
|
||||
<Link to={`/orders/${o.id}`} className="btn btn-outline btn-pill">
|
||||
查看详情
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import type { WechatPayOrderResult } from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import PhoneVerifySheet from '../components/PhoneVerifySheet';
|
||||
import { request } from '../lib/api';
|
||||
import { buildOrderConfirmUrl } from '../lib/navigation';
|
||||
import {
|
||||
authorizeWechatForPay,
|
||||
fetchClientConfig,
|
||||
fetchUserProfile,
|
||||
isWechatAuthRequiredError,
|
||||
needsWechatAuthForPay,
|
||||
saveWechatLoginResult,
|
||||
} from '../lib/pay-wechat';
|
||||
import { applyWechatLoginResult, handleWechatAuthCallback } from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
import { track } from '../lib/analytics';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitOrderPaid(orderId: string, maxAttempts = 15) {
|
||||
for (let i = 0; i < maxAttempts; i += 1) {
|
||||
const order = await request<{ payStatus?: string }>('USER_H5', `/trade/orders/${orderId}`);
|
||||
if (order.payStatus === 'PAID') return true;
|
||||
await sleep(2000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function PayPage() {
|
||||
const [params] = useSearchParams();
|
||||
const orderId = params.get('orderId') || '';
|
||||
usePageView('pay_page_view', { orderId });
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [mockMode, setMockMode] = useState(true);
|
||||
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [showBindPhone, setShowBindPhone] = useState(false);
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||
const [orderNo, setOrderNo] = useState('');
|
||||
const [deliveryType, setDeliveryType] = useState('');
|
||||
|
||||
const refreshPayReadiness = useCallback(async () => {
|
||||
try {
|
||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||
setMockMode(config.mockPay);
|
||||
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
|
||||
return profile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWechatLoginResult = useCallback(
|
||||
async (result: Parameters<typeof applyWechatLoginResult>[0]) => {
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setShowBindPhone(true);
|
||||
setMsg('');
|
||||
return;
|
||||
}
|
||||
if (saveWechatLoginResult(result)) {
|
||||
setMsg('');
|
||||
await refreshPayReadiness();
|
||||
}
|
||||
},
|
||||
[refreshPayReadiness],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refreshPayReadiness();
|
||||
}, [refreshPayReadiness]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) {
|
||||
setOrderNo('');
|
||||
return;
|
||||
}
|
||||
request<{ orderNo?: string; deliveryType?: string }>('USER_H5', `/trade/orders/${orderId}`)
|
||||
.then((order) => {
|
||||
setOrderNo(order.orderNo || '');
|
||||
setDeliveryType(order.deliveryType || '');
|
||||
})
|
||||
.catch(() => {
|
||||
setOrderNo('');
|
||||
setDeliveryType('');
|
||||
});
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv()) return;
|
||||
handleWechatAuthCallback()
|
||||
.then((result) => {
|
||||
if (result) void handleWechatLoginResult(result);
|
||||
})
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '微信授权失败'));
|
||||
}, [handleWechatLoginResult]);
|
||||
|
||||
function goBackConfirm() {
|
||||
navigate(
|
||||
buildOrderConfirmUrl({
|
||||
productId: params.get('productId'),
|
||||
qty: params.get('qty'),
|
||||
addressId: params.get('addressId'),
|
||||
cross: params.get('cross'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function wechatAuthorize() {
|
||||
setAuthLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
if (!isWechatEnv()) {
|
||||
setMsg('请在微信内打开以授权微信支付');
|
||||
return;
|
||||
}
|
||||
const result = await authorizeWechatForPay();
|
||||
if (result) await handleWechatLoginResult(result);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '微信授权失败');
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (needsWechatAuth) {
|
||||
setMsg('请先完成微信授权后再支付');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await request<WechatPayOrderResult>('USER_H5', `/trade/orders/${orderId}/pay`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (result.mode === 'jsapi' && result.prepay) {
|
||||
setMockMode(false);
|
||||
const { weixinSdk } = await import('../lib/weixin');
|
||||
await weixinSdk.pay(result.prepay);
|
||||
const paid = await waitOrderPaid(orderId);
|
||||
if (!paid) {
|
||||
alert('支付结果确认中,请稍后在订单列表查看');
|
||||
}
|
||||
navigate(
|
||||
deliveryType === 'ON_SITE_PICKUP' ? '/orders?tab=completed' : '/orders?tab=paid',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(deliveryType === 'ON_SITE_PICKUP' ? '/orders?tab=completed' : '/orders?tab=paid');
|
||||
} catch (e) {
|
||||
if (isWechatAuthRequiredError(e)) {
|
||||
setNeedsWechatAuth(true);
|
||||
setMsg('微信支付需要先完成微信授权');
|
||||
return;
|
||||
}
|
||||
setMsg(e instanceof Error ? e.message : '支付失败');
|
||||
track('pay_fail', {
|
||||
orderId,
|
||||
failReason: e instanceof Error ? e.message : '支付失败',
|
||||
pagePath: '/pay',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab pay-page">
|
||||
<SubPageHeader title="微信支付" onBack={goBackConfirm} />
|
||||
<div className="pay-body sub-page-body">
|
||||
<div className="pay-icon">
|
||||
<span className="material-symbols-outlined">account_balance_wallet</span>
|
||||
</div>
|
||||
<p className="headline-lg text-primary">
|
||||
{needsWechatAuth ? '授权微信后可支付' : mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
|
||||
</p>
|
||||
<p className="text-muted body-md" style={{ marginTop: 8 }}>
|
||||
{needsWechatAuth
|
||||
? '使用微信支付前,需先授权微信账号以完成付款'
|
||||
: mockMode
|
||||
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
|
||||
: '请在微信内完成支付,支付成功后自动跳转'}
|
||||
</p>
|
||||
|
||||
{needsWechatAuth && (
|
||||
<div className="pay-wechat-auth-card">
|
||||
<p className="pay-wechat-auth-title">尚未授权微信</p>
|
||||
<p className="pay-wechat-auth-desc">授权后可安全调起微信支付,不会重复扣款</p>
|
||||
<button
|
||||
type="button"
|
||||
className="login-wechat-btn pay-wechat-auth-btn"
|
||||
disabled={authLoading}
|
||||
onClick={wechatAuthorize}
|
||||
>
|
||||
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
||||
<span>{authLoading ? '授权中...' : '微信一键授权'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <p className="pay-wechat-auth-msg">{msg}</p>}
|
||||
{orderNo && <p className="label-md text-muted" style={{ marginTop: 24 }}>订单号 {orderNo}</p>}
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={loading || !orderId || needsWechatAuth}
|
||||
onClick={pay}
|
||||
>
|
||||
{loading ? '支付中...' : needsWechatAuth ? '请先授权微信' : '确认支付'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PhoneVerifySheet
|
||||
open={showBindPhone}
|
||||
mode="wechat_bind_phone"
|
||||
wxSessionKey={wxSessionKey ?? undefined}
|
||||
onClose={() => {
|
||||
setShowBindPhone(false);
|
||||
setWxSessionKey(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
void refreshPayReadiness();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { ProductDetailContentDto } from '@dukang/shared-types';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { request } from '../lib/api';
|
||||
import { track } from '../lib/analytics';
|
||||
import { handleShareButtonClick } from '../lib/wechat-share';
|
||||
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
||||
import type { ProductImageSource } from '../lib/product-images';
|
||||
|
||||
type Product = ProductImageSource & {
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
benefitAmount?: number;
|
||||
detailContent?: ProductDetailContentDto | null;
|
||||
};
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
track('product_detail_view', { refType: 'PRODUCT', refId: id, productId: id });
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setHeaderSolid(window.scrollY > 100);
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
if (!product) return <div className="empty">加载中...</div>;
|
||||
|
||||
const benefit = Number(product.benefitAmount ?? product.price);
|
||||
const carouselImages = getProductCarouselImages(product);
|
||||
const detailImages = getProductDetailImages(product);
|
||||
const detail = product.detailContent ?? {};
|
||||
const features = detail.features ?? [];
|
||||
|
||||
return (
|
||||
<div className="product-detail-page">
|
||||
<header className={`product-detail-header${headerSolid ? ' solid' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="product-detail-header-btn"
|
||||
aria-label="返回"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className={`app-page-title product-detail-header-title${headerSolid ? ' visible' : ''}`}>
|
||||
{product.name}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="product-detail-header-btn"
|
||||
aria-label="分享"
|
||||
onClick={() => handleShareButtonClick(setToast)}
|
||||
>
|
||||
<span className="material-symbols-outlined">share</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<AppToast message={toast} />
|
||||
|
||||
<main className="product-detail-main">
|
||||
<section className="product-detail-hero">
|
||||
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
|
||||
</section>
|
||||
|
||||
<section className="product-detail-info">
|
||||
<div className="product-detail-price">
|
||||
<span className="product-detail-price-symbol">¥</span>
|
||||
<span className="product-detail-price-value">{product.price}</span>
|
||||
</div>
|
||||
<h2 className="product-detail-name">{product.name}</h2>
|
||||
{product.subtitle && (
|
||||
<p className="product-detail-subtitle">{product.subtitle}</p>
|
||||
)}
|
||||
|
||||
<div className="product-detail-promo">
|
||||
<div className="product-detail-promo-glow" aria-hidden />
|
||||
<div className="product-detail-promo-head">
|
||||
<div className="product-detail-promo-icon">
|
||||
<span className="material-symbols-outlined fill-icon">confirmation_number</span>
|
||||
</div>
|
||||
<h3 className="product-detail-promo-title">
|
||||
买杜康美酒 · 享全城好客礼遇
|
||||
<span className="product-detail-promo-amount">¥{benefit}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<p className="product-detail-promo-desc">
|
||||
购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
|
||||
</p>
|
||||
<span className="material-symbols-outlined product-detail-promo-deco" aria-hidden>
|
||||
restaurant
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="product-detail-content">
|
||||
<div className="product-detail-section-head">
|
||||
<span className="product-detail-section-bar" />
|
||||
<h3>商品详情</h3>
|
||||
</div>
|
||||
|
||||
{detailImages.map((src, index) => (
|
||||
<AppImage key={`${src}-${index}`} src={src} alt="" wrapperClassName="product-detail-banner" />
|
||||
))}
|
||||
|
||||
{(detail.storyTitle || detail.storyText || features.length > 0) && (
|
||||
<div className="product-detail-copy">
|
||||
{(detail.storyTitle || detail.storyText) && (
|
||||
<div className="product-detail-story">
|
||||
{detail.storyTitle && <h4>{detail.storyTitle}</h4>}
|
||||
{detail.storyText && <p>{detail.storyText}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{features.length > 0 && (
|
||||
<div className="product-detail-features">
|
||||
{features.map((f) => (
|
||||
<div key={`${f.title}-${f.icon}`} className="product-detail-feature">
|
||||
<span className="material-symbols-outlined">{f.icon}</span>
|
||||
<div className="product-detail-feature-title">{f.title}</div>
|
||||
<div className="product-detail-feature-desc">{f.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav className="product-detail-bar">
|
||||
<Link to="/" className="product-detail-bar-home">
|
||||
<span className="material-symbols-outlined">home</span>
|
||||
<span>首页</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="product-detail-buy-btn"
|
||||
onClick={() => {
|
||||
track('product_click', { refType: 'PRODUCT', refId: id, productId: id });
|
||||
navigate(`/order/confirm?productId=${id}&qty=2`);
|
||||
}}
|
||||
>
|
||||
立即购买
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
export default function RedeemCodePage() {
|
||||
usePageView('redeem_code_view');
|
||||
const navigate = useNavigate();
|
||||
const token = sessionStorage.getItem('redeemToken') || '';
|
||||
const amount = sessionStorage.getItem('redeemAmount') || '0';
|
||||
const [secondsLeft, setSecondsLeft] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||
|
||||
useEffect(() => {
|
||||
const expireAt = sessionStorage.getItem('redeemExpireAt');
|
||||
if (!expireAt) return;
|
||||
const tick = () => {
|
||||
const left = Math.max(0, Math.floor((new Date(expireAt).getTime() - Date.now()) / 1000));
|
||||
setSecondsLeft(left);
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab" style={{ textAlign: 'center' }}>
|
||||
<PageHeader title="核销码展示" onBack={() => navigate(-1)} />
|
||||
<p className="text-variant body-md page-actions" style={{ marginBottom: 0, paddingBottom: 0 }}>请向门店出示以下核销码</p>
|
||||
<div className="card" style={{ padding: 32 }}>
|
||||
<div className="label-md text-muted">核销金额</div>
|
||||
<div className="amount-xl" style={{ margin: '8px 0 24px' }}>¥{amount}</div>
|
||||
<div className="code-box">{token}</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 16 }}>
|
||||
{secondsLeft > 0 ? `${secondsLeft} 秒后过期` : '已过期,请重新生成'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/benefit')}>
|
||||
返回权益页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
const POLL_INTERVAL_MS = 2500;
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
maxRedeemAmount: number;
|
||||
activeCouponCount: number;
|
||||
};
|
||||
|
||||
type RedeemTokenStatus =
|
||||
| { status: 'PENDING'; expireInSeconds: number; amount: number }
|
||||
| {
|
||||
status: 'CONSUMED';
|
||||
record: {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
| { status: 'EXPIRED' };
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function RedeemPage() {
|
||||
usePageView('benefit_redeem_start');
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const couponId = searchParams.get('couponId') ?? undefined;
|
||||
const presetAmount = searchParams.get('amount');
|
||||
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [couponBalance, setCouponBalance] = useState<number | null>(null);
|
||||
const [amountInput, setAmountInput] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [token, setToken] = useState('');
|
||||
const [confirmAmount, setConfirmAmount] = useState(0);
|
||||
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const presetApplied = useRef(false);
|
||||
const successHandled = useRef(false);
|
||||
|
||||
const redeemableMax = couponId ? (couponBalance ?? 0) : (summary?.totalBalance ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
|
||||
return () => stopTimer();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!couponId) {
|
||||
setCouponBalance(null);
|
||||
return;
|
||||
}
|
||||
request<Array<Record<string, unknown>>>('USER_H5', '/benefit/coupons')
|
||||
.then((list) => {
|
||||
const found = list.find((c) => String(c.id) === couponId);
|
||||
if (found) {
|
||||
setCouponBalance(Number(found.balance));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [couponId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (presetApplied.current) return;
|
||||
if (presetAmount && Number(presetAmount) > 0) {
|
||||
setAmountInput(formatMoney(Number(presetAmount)));
|
||||
presetApplied.current = true;
|
||||
}
|
||||
}, [presetAmount]);
|
||||
|
||||
function stopTimer() {
|
||||
if (timerRef.current != null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
stopTimer();
|
||||
setModalOpen(false);
|
||||
setToken('');
|
||||
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
|
||||
successHandled.current = false;
|
||||
}, []);
|
||||
|
||||
const handleRedeemExpired = useCallback(() => {
|
||||
if (successHandled.current) return;
|
||||
successHandled.current = true;
|
||||
closeModal();
|
||||
sessionStorage.removeItem('redeemToken');
|
||||
sessionStorage.removeItem('redeemAmount');
|
||||
setMsg('核销码已失效,请重新生成');
|
||||
}, [closeModal]);
|
||||
|
||||
const handleRedeemSuccess = useCallback(
|
||||
(record: NonNullable<Extract<RedeemTokenStatus, { status: 'CONSUMED' }>['record']>) => {
|
||||
if (successHandled.current) return;
|
||||
successHandled.current = true;
|
||||
stopTimer();
|
||||
setModalOpen(false);
|
||||
setToken('');
|
||||
sessionStorage.setItem('lastRedeemRecordId', record.id);
|
||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(record));
|
||||
sessionStorage.removeItem('redeemToken');
|
||||
sessionStorage.removeItem('redeemAmount');
|
||||
navigate('/redeem/success', {
|
||||
replace: true,
|
||||
state: { record },
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setTimerSec((prev) => {
|
||||
if (prev <= 1) {
|
||||
stopTimer();
|
||||
window.setTimeout(() => handleRedeemExpired(), 0);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || !token) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const status = await request<RedeemTokenStatus>('USER_H5', `/redeem/tokens/${token}/status`);
|
||||
if (cancelled || successHandled.current) return;
|
||||
if (status.status === 'CONSUMED' && status.record) {
|
||||
handleRedeemSuccess(status.record);
|
||||
} else if (status.status === 'EXPIRED') {
|
||||
handleRedeemExpired();
|
||||
} else if (status.status === 'PENDING' && status.expireInSeconds > 0) {
|
||||
setTimerSec((prev) => Math.min(prev, status.expireInSeconds));
|
||||
}
|
||||
} catch {
|
||||
/* 轮询失败忽略,下次重试 */
|
||||
}
|
||||
}
|
||||
|
||||
void pollStatus();
|
||||
const pollId = window.setInterval(() => void pollStatus(), POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(pollId);
|
||||
};
|
||||
}, [modalOpen, token, handleRedeemSuccess, handleRedeemExpired]);
|
||||
|
||||
function parseAmount() {
|
||||
const value = Number(amountInput);
|
||||
return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
|
||||
}
|
||||
|
||||
function fillMaxAmount() {
|
||||
if (redeemableMax <= 0) return;
|
||||
setAmountInput(formatMoney(redeemableMax));
|
||||
setMsg('');
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const amount = parseAmount();
|
||||
if (!summary) return;
|
||||
const maxAllowed = couponId ? (couponBalance ?? 0) : summary.totalBalance;
|
||||
|
||||
if (amount <= 0) {
|
||||
setMsg('请输入核销金额');
|
||||
return;
|
||||
}
|
||||
if (amount > maxAllowed) {
|
||||
setMsg(couponId ? '核销金额不能超过该权益可用余额' : '核销金额不能超过可用余额');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
successHandled.current = false;
|
||||
try {
|
||||
const body: { amount: number; couponId?: string } = { amount };
|
||||
if (couponId) body.couponId = couponId;
|
||||
|
||||
const data = await request<{ token: string; amount: number }>('USER_H5', '/redeem/tokens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setToken(data.token);
|
||||
setConfirmAmount(data.amount);
|
||||
setModalOpen(true);
|
||||
startTimer();
|
||||
sessionStorage.setItem('redeemToken', data.token);
|
||||
sessionStorage.setItem('redeemAmount', String(data.amount));
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '生成失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const qrUrl = token
|
||||
? `https://api.qrserver.com/v1/create-qr-code/?size=192x192&data=${encodeURIComponent(token)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="redeem-page">
|
||||
<header className="redeem-header">
|
||||
<button type="button" className="redeem-header-btn" aria-label="返回" onClick={() => navigate(-1)}>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">好客权益核销</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="redeem-header-btn"
|
||||
aria-label="帮助"
|
||||
onClick={() => window.alert('核销金额不超过可用余额,核销码 3 分钟内有效。')}
|
||||
>
|
||||
<span className="material-symbols-outlined">help_outline</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="redeem-main">
|
||||
<section className="redeem-balance-card">
|
||||
<div className="redeem-balance-pattern" aria-hidden />
|
||||
<div className="redeem-balance-inner">
|
||||
<span className="redeem-balance-label">
|
||||
{couponId ? '当前权益可用余额' : '当前好客权益可用余额'}
|
||||
</span>
|
||||
<div className="redeem-balance-amount">
|
||||
<span className="redeem-balance-symbol">¥</span>
|
||||
<span className="redeem-balance-value">
|
||||
{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="redeem-form-card">
|
||||
<label className="redeem-form-label" htmlFor="redeem-amount">
|
||||
核销金额 (¥)
|
||||
</label>
|
||||
<div className="redeem-amount-row">
|
||||
<span className="redeem-amount-symbol">¥</span>
|
||||
<input
|
||||
id="redeem-amount"
|
||||
className="redeem-amount-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={amountInput}
|
||||
onChange={(e) => {
|
||||
setAmountInput(e.target.value);
|
||||
setMsg('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="redeem-amount-foot">
|
||||
<span>最高可核销 ¥{formatMoney(redeemableMax)}</span>
|
||||
<button type="button" className="redeem-fill-max" onClick={fillMaxAmount}>
|
||||
全部核销
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{msg && <p className="redeem-msg">{msg}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="redeem-submit-btn"
|
||||
disabled={loading || redeemableMax <= 0}
|
||||
onClick={submit}
|
||||
>
|
||||
<span className="material-symbols-outlined">qr_code_2</span>
|
||||
{loading ? '生成中...' : '生成核销码'}
|
||||
</button>
|
||||
|
||||
<div className="redeem-security">
|
||||
<span className="material-symbols-outlined">shield</span>
|
||||
<p>安全提示:请在核销前核实金额,核销码将在 3 分钟后自动失效。</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className={`redeem-modal${modalOpen ? ' open' : ''}`} aria-hidden={!modalOpen}>
|
||||
<div className="redeem-modal-backdrop" onClick={closeModal} aria-hidden />
|
||||
<div className={`redeem-modal-panel${modalOpen ? ' open' : ''}`}>
|
||||
<div className="redeem-modal-head">
|
||||
<h3>正在核销</h3>
|
||||
<p>请向收银员出示此码</p>
|
||||
</div>
|
||||
<div className="redeem-modal-body">
|
||||
<div className="redeem-qr-wrap">
|
||||
<div className="redeem-qr-box">
|
||||
{qrUrl ? (
|
||||
<img className="redeem-qr-img" src={qrUrl} alt="核销二维码" />
|
||||
) : (
|
||||
<div className="redeem-qr-placeholder" />
|
||||
)}
|
||||
<div className="redeem-qr-scanline" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' active' : ''}`}>
|
||||
<span className="redeem-timer-value">{formatTimer(timerSec)}</span>
|
||||
<span className="redeem-timer-label">失效倒计时</span>
|
||||
</div>
|
||||
<div className="redeem-modal-amount">
|
||||
<p>待核销金额</p>
|
||||
<p className="redeem-modal-amount-value">¥ {formatMoney(confirmAmount)}</p>
|
||||
</div>
|
||||
{token && (
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>核销码编号(供追查)</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontSize: 12,
|
||||
wordBreak: 'break-all',
|
||||
padding: '0 16px',
|
||||
}}
|
||||
>
|
||||
{token}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="redeem-modal-cancel" onClick={closeModal}>
|
||||
取消核销
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { usePageView } from '../lib/usePageView';
|
||||
|
||||
type RedeemRecord = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function StarRating({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (score: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="redeem-rating-row">
|
||||
<span className="redeem-rating-label">{label}</span>
|
||||
<div className="redeem-star-row" role="group" aria-label={label}>
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<button
|
||||
key={score}
|
||||
type="button"
|
||||
className={`redeem-star-btn${score <= value ? ' active' : ''}`}
|
||||
aria-label={`${score} 星`}
|
||||
onClick={() => onChange(score)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RedeemSuccessPage() {
|
||||
usePageView('redeem_success_view');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [serviceScore, setServiceScore] = useState(5);
|
||||
const [envScore, setEnvScore] = useState(5);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const record = useMemo<RedeemRecord | null>(() => {
|
||||
const fromState = (location.state as { record?: RedeemRecord })?.record;
|
||||
if (fromState) return fromState;
|
||||
try {
|
||||
const cached = sessionStorage.getItem('lastRedeemResult');
|
||||
return cached ? (JSON.parse(cached) as RedeemRecord) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
const amount = Number(record?.amount ?? 0);
|
||||
const storeName = record?.storeName || '门店';
|
||||
const redeemNo = record?.redeemNo || '—';
|
||||
const redeemedAt = record?.createdAt
|
||||
? new Date(record.createdAt).toLocaleString('zh-CN')
|
||||
: new Date().toLocaleString('zh-CN');
|
||||
|
||||
async function submit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const redeemRecordId = record?.id || sessionStorage.getItem('lastRedeemRecordId');
|
||||
if (redeemRecordId) {
|
||||
await request('USER_H5', '/redeem/ratings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
redeemRecordId,
|
||||
serviceScore,
|
||||
envScore,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 评价失败不阻塞返回 */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
sessionStorage.removeItem('lastRedeemRecordId');
|
||||
sessionStorage.removeItem('lastRedeemResult');
|
||||
navigate('/benefit', { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab redeem-success-page">
|
||||
<PageHeader title="核销成功" onBack={() => navigate('/benefit', { replace: true })} />
|
||||
<div className="redeem-success-hero">
|
||||
<div className="success-icon">✓</div>
|
||||
<h2 className="headline-lg text-primary">核销成功</h2>
|
||||
<p className="redeem-success-amount">¥ {formatMoney(amount)}</p>
|
||||
<p className="text-variant body-md">已在 {storeName} 完成核销</p>
|
||||
</div>
|
||||
|
||||
<div className="card redeem-success-details">
|
||||
<div className="redeem-success-detail-row">
|
||||
<span>核销门店</span>
|
||||
<span>{storeName}</span>
|
||||
</div>
|
||||
<div className="redeem-success-detail-row">
|
||||
<span>核销时间</span>
|
||||
<span>{redeemedAt}</span>
|
||||
</div>
|
||||
<div className="redeem-success-detail-row">
|
||||
<span>核销单号</span>
|
||||
<span className="redeem-success-mono">{redeemNo}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card redeem-success-rating">
|
||||
<p className="redeem-success-rating-title">为门店服务评分</p>
|
||||
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} />
|
||||
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} />
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={loading} onClick={submit}>
|
||||
{loading ? '提交中...' : '完成'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { request } from '../lib/api';
|
||||
import { track } from '../lib/analytics';
|
||||
import { handleShareButtonClick } from '../lib/wechat-share';
|
||||
import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images';
|
||||
|
||||
type StoreMedia = { url: string; mediaType?: string; sortOrder?: number };
|
||||
|
||||
type StoreDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
cityName?: string;
|
||||
city?: string;
|
||||
district: string;
|
||||
address: string;
|
||||
intro?: string | null;
|
||||
coverUrl?: string | null;
|
||||
status: string;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
avgPrice?: number | null;
|
||||
category?: { name: string } | null;
|
||||
media?: StoreMedia[];
|
||||
};
|
||||
|
||||
const SERVICES = [
|
||||
{ icon: 'wifi', label: 'WiFi' },
|
||||
{ icon: 'local_parking', label: '免费停车' },
|
||||
{ icon: 'meeting_room', label: '独立包间' },
|
||||
{ icon: 'table_restaurant', label: '宴会大厅' },
|
||||
] as const;
|
||||
|
||||
const MOCK_DISTANCES = ['800m', '1.2km', '2.4km', '3.5km'];
|
||||
|
||||
const DEFAULT_INTRO =
|
||||
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
|
||||
|
||||
function formatHours(store: StoreDetail) {
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
return parts.length ? parts.join(',') : '09:30-22:00';
|
||||
}
|
||||
|
||||
function fullAddress(store: StoreDetail) {
|
||||
const city = store.cityName || store.city || '';
|
||||
return `${store.province}${city}${store.district}${store.address}`;
|
||||
}
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [store, setStore] = useState<StoreDetail | null>(null);
|
||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
request<StoreDetail>('USER_H5', `/stores/${id}`).then(setStore);
|
||||
track('store_detail_view', { refType: 'STORE', refId: id, storeId: id });
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
request<Array<{ balance: number; status: string }>>('USER_H5', '/benefit/coupons')
|
||||
.then((list) => {
|
||||
const balance = list.reduce((sum, c) => {
|
||||
if (c.status === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||
return sum;
|
||||
}, 0);
|
||||
setBenefitBalance(balance);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setHeaderSolid(window.scrollY > 80);
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const galleryImages = useMemo(() => {
|
||||
if (!store) return [];
|
||||
return getStoreGalleryImages(store.coverUrl, store.media);
|
||||
}, [store]);
|
||||
|
||||
const distance = MOCK_DISTANCES[Number(id || 0) % MOCK_DISTANCES.length];
|
||||
const isOpen = store?.status === 'OPEN';
|
||||
|
||||
if (!store) {
|
||||
return <div className="empty store-detail-page">加载中...</div>;
|
||||
}
|
||||
|
||||
function callStore() {
|
||||
if (store?.phone) window.location.href = `tel:${store.phone}`;
|
||||
}
|
||||
|
||||
function openMap() {
|
||||
window.alert('preV1:导航功能即将开放');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="store-detail-page">
|
||||
<header className={`store-detail-header${headerSolid ? ' solid' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="store-detail-header-btn"
|
||||
aria-label="返回"
|
||||
onClick={() => navigate('/stores')}
|
||||
>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className={`app-page-title store-detail-header-title${headerSolid ? ' visible' : ''}`}>门店详情</h1>
|
||||
<button type="button" className="store-detail-header-btn" aria-label="分享" onClick={() => handleShareButtonClick(setToast)}>
|
||||
<span className="material-symbols-outlined">share</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<AppToast message={toast} />
|
||||
|
||||
<main className="store-detail-main">
|
||||
<section className="store-detail-hero">
|
||||
<ProductCarousel images={galleryImages} alt={store.name} variant="store" />
|
||||
</section>
|
||||
|
||||
<section className="store-detail-info-wrap">
|
||||
<div className="store-detail-info-card">
|
||||
<div className="store-detail-info-head">
|
||||
<div>
|
||||
<h2 className="store-detail-name">{store.name}</h2>
|
||||
<p className="store-detail-hours">营业时间:{formatHours(store)}</p>
|
||||
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
|
||||
<p className="store-detail-hours">人均约 ¥{Number(store.avgPrice).toFixed(0)}</p>
|
||||
) : null}
|
||||
{store.category?.name && (
|
||||
<span className="store-detail-category">{store.category.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`store-detail-status${isOpen ? '' : ' closed'}`}>
|
||||
{isOpen && <span className="store-detail-status-dot" />}
|
||||
<span>{isOpen ? '营业中' : '休息中'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="store-detail-tip">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
<p>温馨提示:为保证服务品质,如用餐规模超过2桌,请提前电话联系门店确认可用性。</p>
|
||||
</section>
|
||||
|
||||
<section className="store-detail-location">
|
||||
<div className="store-detail-location-card">
|
||||
<div className="store-detail-map">
|
||||
<AppImage src={STITCH_STORE_MAP} alt="" wrapperClassName="app-image--fill" />
|
||||
<div className="store-detail-map-gradient" aria-hidden />
|
||||
</div>
|
||||
<div className="store-detail-location-body">
|
||||
<div className="store-detail-location-text">
|
||||
<p>{fullAddress(store)}</p>
|
||||
<p className="store-detail-distance">
|
||||
<span className="material-symbols-outlined">near_me</span>
|
||||
距离您 {distance}
|
||||
</p>
|
||||
</div>
|
||||
<div className="store-detail-location-actions">
|
||||
<button type="button" className="store-detail-action-btn" aria-label="电话" onClick={callStore}>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="store-detail-action-btn store-detail-action-btn--primary"
|
||||
aria-label="导航"
|
||||
onClick={openMap}
|
||||
>
|
||||
<span className="material-symbols-outlined">directions</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="store-detail-services">
|
||||
<h3>设施服务</h3>
|
||||
<div className="store-detail-service-grid">
|
||||
{SERVICES.map((s) => (
|
||||
<div key={s.label} className="store-detail-service-item">
|
||||
<span className="material-symbols-outlined">{s.icon}</span>
|
||||
<span>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="store-detail-intro">
|
||||
<h3>门店介绍</h3>
|
||||
<div className="store-detail-intro-card">
|
||||
<p>{store.intro || DEFAULT_INTRO}</p>
|
||||
<div className="store-detail-intro-foot">
|
||||
<div className="store-detail-intro-avatars" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<span className="store-detail-intro-stat">已有 1.2w 人到店体验</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="store-detail-footer">
|
||||
<div className="store-detail-footer-inner">
|
||||
<div className="store-detail-balance">
|
||||
<span className="store-detail-balance-label">可用额度</span>
|
||||
<span className="store-detail-balance-value">
|
||||
¥{benefitBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="store-detail-redeem-btn" onClick={() => navigate('/redeem')}>
|
||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||
去核销
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { track } from '../lib/analytics';
|
||||
import TabMainHeader from '../components/TabMainHeader';
|
||||
import RegionPicker from '../components/RegionPicker';
|
||||
import {
|
||||
DEFAULT_REGION,
|
||||
FALLBACK_CITY_REGION,
|
||||
formatRegionCity,
|
||||
REGION_ALL,
|
||||
toCityLevelRegion,
|
||||
type RegionSelection,
|
||||
} from '../lib/region-data';
|
||||
import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location';
|
||||
|
||||
type OpenCity = {
|
||||
code: string;
|
||||
name: string;
|
||||
province: string;
|
||||
};
|
||||
|
||||
type StoreItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
province?: string;
|
||||
cityName?: string;
|
||||
district: string;
|
||||
address: string;
|
||||
coverUrl?: string | null;
|
||||
status: string;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
avgPrice?: number | null;
|
||||
category?: { name: string } | null;
|
||||
};
|
||||
|
||||
function resolveCityCode(region: RegionSelection, cities: OpenCity[]): string | undefined {
|
||||
if (region.province === REGION_ALL || region.city === REGION_ALL) return undefined;
|
||||
return cities.find((c) => c.province === region.province && c.name === region.city)?.code;
|
||||
}
|
||||
|
||||
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
|
||||
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||||
|
||||
function storeCover(store: StoreItem, index: number) {
|
||||
if (store.coverUrl) return String(store.coverUrl);
|
||||
const imgs = ['/images/1.png', '/images/2.png', '/images/3.png'];
|
||||
return imgs[index % imgs.length];
|
||||
}
|
||||
|
||||
function formatHours(store: StoreItem) {
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00';
|
||||
}
|
||||
|
||||
export default function StoreListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [cities, setCities] = useState<OpenCity[]>([]);
|
||||
const [stores, setStores] = useState<StoreItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [region, setRegion] = useState<RegionSelection>(() => toCityLevelRegion(DEFAULT_REGION));
|
||||
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<'auto' | 'manual'>('auto');
|
||||
const [usedFallback, setUsedFallback] = useState(false);
|
||||
const [geoReady, setGeoReady] = useState(false);
|
||||
|
||||
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
|
||||
|
||||
useEffect(() => {
|
||||
track('store_list_view', { pagePath: '/stores' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterMode !== 'auto' || geoReady) return;
|
||||
resolveUserCity().then((resolved) => {
|
||||
if (resolved) {
|
||||
setRegion(toCityLevelRegion(resolved.region));
|
||||
}
|
||||
setGeoReady(true);
|
||||
});
|
||||
}, [filterMode, geoReady]);
|
||||
|
||||
const fetchStores = useCallback(
|
||||
async (targetRegion: RegionSelection, allowFallback: boolean) => {
|
||||
const code = resolveCityCode(targetRegion, cities);
|
||||
const qs = code ? `?cityCode=${encodeURIComponent(code)}` : '';
|
||||
const data = await request<StoreItem[]>('USER_H5', `/stores${qs}`).catch(() => [] as StoreItem[]);
|
||||
|
||||
if (
|
||||
allowFallback &&
|
||||
filterMode === 'auto' &&
|
||||
data.length === 0 &&
|
||||
!usedFallback &&
|
||||
code &&
|
||||
code !== FALLBACK_CITY_CODE
|
||||
) {
|
||||
setUsedFallback(true);
|
||||
setRegion(toCityLevelRegion(FALLBACK_CITY_REGION));
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
[cities, filterMode, usedFallback],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterMode === 'auto' && !geoReady) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchStores(region, true)
|
||||
.then((data) => {
|
||||
if (!cancelled && data) setStores(data);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [region, cities, filterMode, geoReady, fetchStores, usedFallback]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = stores;
|
||||
if (categoryTab !== '全部') {
|
||||
list = list.filter((s) => s.category?.name === categoryTab);
|
||||
}
|
||||
if (!cityCode) {
|
||||
list = list.filter((s) => {
|
||||
const province = s.province ?? '河南省';
|
||||
const city = s.cityName ?? '郑州市';
|
||||
if (region.province !== REGION_ALL && province !== region.province) return false;
|
||||
if (region.city !== REGION_ALL && city !== region.city) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.address.toLowerCase().includes(q) ||
|
||||
s.district.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [stores, categoryTab, keyword, region, cityCode]);
|
||||
|
||||
const regionLabel = formatRegionCity(region.province, region.city);
|
||||
const emptyMessage =
|
||||
filterMode === 'manual' && filtered.length === 0
|
||||
? '未找到匹配门店'
|
||||
: '暂无门店';
|
||||
|
||||
return (
|
||||
<div className="page store-page">
|
||||
<header className="store-header">
|
||||
<TabMainHeader title="门店" />
|
||||
|
||||
<div className="store-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="store-location"
|
||||
onClick={() => setRegionPickerOpen(true)}
|
||||
>
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>{regionLabel}</span>
|
||||
<span className="material-symbols-outlined store-location-arrow">expand_more</span>
|
||||
</button>
|
||||
<div className="store-search">
|
||||
<span className="material-symbols-outlined store-search-icon">search</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索门店名称或地址"
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
setFilterMode('manual');
|
||||
setKeyword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-category-bar">
|
||||
<div className="store-category-tabs">
|
||||
{CATEGORY_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setFilterMode('manual');
|
||||
setCategoryTab(tab);
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="store-list">
|
||||
{loading && <div className="store-empty">加载门店中...</div>}
|
||||
{!loading && filtered.map((s, index) => (
|
||||
<article
|
||||
key={s.id}
|
||||
className="store-card"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/stores/${s.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
navigate(`/stores/${s.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="store-card-cover">
|
||||
<AppImage src={storeCover(s, index)} alt={s.name} wrapperClassName="app-image--fill" />
|
||||
</div>
|
||||
<div className="store-card-body">
|
||||
<div className="store-card-top">
|
||||
<div className="store-card-title-row">
|
||||
<h3 className="store-card-name">{s.name}</h3>
|
||||
<span className="store-card-distance">
|
||||
{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="store-card-meta">
|
||||
<span className="status-open">营业中</span>
|
||||
<span className="store-card-hours">{formatHours(s)}</span>
|
||||
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
|
||||
<span className="store-card-hours">人均¥{Number(s.avgPrice).toFixed(0)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="store-card-bottom">
|
||||
<p className="store-card-address">
|
||||
{s.district}
|
||||
{s.address}
|
||||
</p>
|
||||
<Link
|
||||
to="/redeem"
|
||||
className="store-redeem-btn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
去核销
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="store-empty">{emptyMessage}</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length > 0 && (
|
||||
<p className="store-list-end">没有更多门店了</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<RegionPicker
|
||||
open={regionPickerOpen}
|
||||
value={region}
|
||||
levels={2}
|
||||
onClose={() => setRegionPickerOpen(false)}
|
||||
onConfirm={(next) => {
|
||||
setFilterMode('manual');
|
||||
setRegion(toCityLevelRegion(next));
|
||||
setRegionPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user