Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a061f085f | |||
| b20e566a65 | |||
| 4d434c9c67 | |||
| 62e61a9e63 | |||
| a01217539c | |||
| 26334ed072 | |||
| fc2e5b65de |
@@ -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 [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||||
const [addressDetail, setAddressDetail] = useState('');
|
const [addressDetail, setAddressDetail] = useState('');
|
||||||
const [productId, setProductId] = useState<string>();
|
const [productId, setProductId] = useState<string>();
|
||||||
|
const [skuId, setSkuId] = useState<string>();
|
||||||
const [quantity, setQuantity] = useState(2);
|
const [quantity, setQuantity] = useState(2);
|
||||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||||
@@ -87,9 +88,37 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
const selectedSku =
|
||||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || !selectedProduct) return;
|
if (!open || !selectedProduct) return;
|
||||||
@@ -115,6 +144,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
|
skuId: skuId || undefined,
|
||||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -124,7 +154,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
.finally(() => setPreviewLoading(false));
|
.finally(() => setPreviewLoading(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => window.clearTimeout(timer);
|
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(), []);
|
useEffect(() => () => stopPoll(), []);
|
||||||
|
|
||||||
@@ -135,6 +165,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
setRegionCodes([]);
|
setRegionCodes([]);
|
||||||
setAddressDetail('');
|
setAddressDetail('');
|
||||||
setQuantity(2);
|
setQuantity(2);
|
||||||
|
setProductId(undefined);
|
||||||
|
setSkuId(undefined);
|
||||||
setPromoCodeId(undefined);
|
setPromoCodeId(undefined);
|
||||||
setDeliveryMode('ADDRESS');
|
setDeliveryMode('ADDRESS');
|
||||||
setAutoReceive(false);
|
setAutoReceive(false);
|
||||||
@@ -202,6 +234,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
productId: productId!,
|
productId: productId!,
|
||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
|
skuId: skuId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -317,7 +350,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
loading={loadingOptions}
|
loading={loadingOptions}
|
||||||
placeholder="选择商品"
|
placeholder="选择商品"
|
||||||
value={productId}
|
value={productId}
|
||||||
onChange={setProductId}
|
onChange={(id) => {
|
||||||
|
setProductId(id);
|
||||||
|
setSkuId(undefined);
|
||||||
|
}}
|
||||||
options={(options?.products ?? []).map((p) => ({
|
options={(options?.products ?? []).map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
||||||
@@ -325,7 +361,25 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
value={quantity}
|
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,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -16,9 +17,11 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
|
INVOICE_CATEGORY_LABELS,
|
||||||
INVOICE_KIND_LABELS,
|
INVOICE_KIND_LABELS,
|
||||||
INVOICE_STATUS_LABELS,
|
INVOICE_STATUS_LABELS,
|
||||||
INVOICE_TITLE_TYPE_LABELS,
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceCategory,
|
||||||
type InvoiceKind,
|
type InvoiceKind,
|
||||||
type InvoiceStatus,
|
type InvoiceStatus,
|
||||||
type InvoiceTitleType,
|
type InvoiceTitleType,
|
||||||
@@ -34,6 +37,7 @@ type Row = {
|
|||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
@@ -53,6 +57,7 @@ type CreateFormValues = {
|
|||||||
orderNo: string;
|
orderNo: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
taxNo?: string;
|
taxNo?: string;
|
||||||
addressPhone?: string;
|
addressPhone?: string;
|
||||||
@@ -63,12 +68,22 @@ type CreateFormValues = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
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>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/invoices',
|
'/admin/invoices',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -81,12 +96,29 @@ export default function InvoicesPage() {
|
|||||||
const [createForm] = Form.useForm<CreateFormValues>();
|
const [createForm] = Form.useForm<CreateFormValues>();
|
||||||
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||||
const titleType = Form.useWatch('titleType', 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) {
|
async function openDetail(id: string) {
|
||||||
setDetail(await request(`/admin/invoices/${id}`));
|
setDetail(await request(`/admin/invoices/${id}`));
|
||||||
setDrawerOpen(true);
|
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) {
|
async function issueWithFile(file: File) {
|
||||||
if (!detail) return false;
|
if (!detail) return false;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -128,6 +160,7 @@ export default function InvoicesPage() {
|
|||||||
orderNo: values.orderNo.trim(),
|
orderNo: values.orderNo.trim(),
|
||||||
titleType: values.titleType,
|
titleType: values.titleType,
|
||||||
invoiceKind: values.invoiceKind,
|
invoiceKind: values.invoiceKind,
|
||||||
|
invoiceCategory: values.invoiceCategory,
|
||||||
titleName: values.titleName.trim(),
|
titleName: values.titleName.trim(),
|
||||||
taxNo: values.taxNo?.trim() || undefined,
|
taxNo: values.taxNo?.trim() || undefined,
|
||||||
addressPhone: values.addressPhone?.trim() || undefined,
|
addressPhone: values.addressPhone?.trim() || undefined,
|
||||||
@@ -158,9 +191,17 @@ export default function InvoicesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '票种',
|
title: '票种',
|
||||||
width: 120,
|
width: 140,
|
||||||
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
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: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -205,6 +246,7 @@ export default function InvoicesPage() {
|
|||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
titleType: 'PERSONAL',
|
titleType: 'PERSONAL',
|
||||||
invoiceKind: 'NORMAL',
|
invoiceKind: 'NORMAL',
|
||||||
|
invoiceCategory: 'LIQUOR',
|
||||||
});
|
});
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
@@ -213,6 +255,7 @@ export default function InvoicesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Form
|
<Form
|
||||||
|
form={filterForm}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
onFinish={(v) => {
|
onFinish={(v) => {
|
||||||
@@ -231,6 +274,9 @@ export default function InvoicesPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="invoiceNo" label="申请单号">
|
||||||
|
<Input allowClear placeholder="发票申请单号" />
|
||||||
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
筛选
|
筛选
|
||||||
</Button>
|
</Button>
|
||||||
@@ -292,6 +338,11 @@ export default function InvoicesPage() {
|
|||||||
<Descriptions.Item label="发票类型">
|
<Descriptions.Item label="发票类型">
|
||||||
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||||
</Descriptions.Item>
|
</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.titleName}</Descriptions.Item>
|
||||||
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||||
@@ -350,6 +401,18 @@ export default function InvoicesPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
name="titleType"
|
name="titleType"
|
||||||
label="抬头类型"
|
label="抬头类型"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -162,6 +162,8 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [shipForm] = Form.useForm();
|
const [shipForm] = Form.useForm();
|
||||||
const [logisticsForm] = Form.useForm();
|
const [logisticsForm] = Form.useForm();
|
||||||
@@ -172,6 +174,7 @@ export default function OrdersPage() {
|
|||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const deepLinkOpenedRef = useRef(false);
|
||||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||||
@@ -201,6 +204,21 @@ export default function OrdersPage() {
|
|||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
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) {
|
async function openRedeemDetail(redeemId: string) {
|
||||||
setRedeemDetailLoading(true);
|
setRedeemDetailLoading(true);
|
||||||
setRedeemDrawerOpen(true);
|
setRedeemDrawerOpen(true);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import OssUpload from '../components/OssUpload';
|
|||||||
import MultiImageUpload from '../components/MultiImageUpload';
|
import MultiImageUpload from '../components/MultiImageUpload';
|
||||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||||
|
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
|
|
||||||
type ProductDetailContentDto = {
|
type ProductDetailContentDto = {
|
||||||
@@ -221,8 +222,13 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
|||||||
<Form.Item label="SKU">
|
<Form.Item label="SKU">
|
||||||
<Input disabled placeholder="保存后自动生成" />
|
<Input disabled placeholder="保存后自动生成" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Input />
|
name="barcode69"
|
||||||
|
label="69码"
|
||||||
|
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
|
||||||
|
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
|
||||||
|
>
|
||||||
|
<Input placeholder="默认规格 69 码" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
@@ -402,7 +408,7 @@ export default function ProductsPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
|
<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); } }} />
|
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 && (
|
extra={detail && (
|
||||||
<Button type="primary" onClick={async () => {
|
<Button type="primary" onClick={async () => {
|
||||||
const v = await editForm.validateFields();
|
const v = await editForm.validateFields();
|
||||||
@@ -420,8 +426,8 @@ export default function ProductsPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
|
<Descriptions.Item label="SKU">{String(detail.skuCode)}(系统生成)</Descriptions.Item>
|
||||||
<Descriptions.Item label="69码">{String(detail.barcode69)}</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.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<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>
|
</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 { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
@@ -26,8 +27,14 @@ function maskPhone(phone: string | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RedeemRecordsPage() {
|
export default function RedeemRecordsPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
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>(
|
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||||
'/admin/redeem-records',
|
'/admin/redeem-records',
|
||||||
() => {
|
() => {
|
||||||
@@ -43,6 +50,11 @@ export default function RedeemRecordsPage() {
|
|||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = 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) {
|
async function openDetail(id: string) {
|
||||||
setDetailLoading(true);
|
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> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '核销号',
|
title: '核销号',
|
||||||
|
|||||||
@@ -64,10 +64,13 @@ export default function StoreBillsPage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||||
const initialStoreId = searchParams.get('storeId') || '';
|
const initialStoreId = searchParams.get('storeId') || '';
|
||||||
|
const initialStatus =
|
||||||
|
searchParams.get('status')?.trim() ||
|
||||||
|
(initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '');
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({
|
const [filters, setFilters] = useState<Record<string, string>>({
|
||||||
kind: initialKind,
|
kind: initialKind,
|
||||||
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
status: initialStatus,
|
||||||
storeId: initialStoreId,
|
storeId: initialStoreId,
|
||||||
});
|
});
|
||||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
|
|||||||
@@ -20,20 +20,18 @@ import type {
|
|||||||
StoreInfoChangeFieldDiff,
|
StoreInfoChangeFieldDiff,
|
||||||
StoreInfoChangeRequestDto,
|
StoreInfoChangeRequestDto,
|
||||||
StoreInfoChangeStatus,
|
StoreInfoChangeStatus,
|
||||||
StorePackageAuditDetailDto,
|
|
||||||
StorePackageAuditSummaryDto,
|
StorePackageAuditSummaryDto,
|
||||||
StorePackageChangeRequestDto,
|
StorePackageChangeRequestDto,
|
||||||
StorePackageChangeStatus,
|
StorePackageChangeStatus,
|
||||||
StorePackageItemDto,
|
|
||||||
StorePackageViewDto,
|
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
STORE_INFO_CHANGE_STATUS_LABELS,
|
STORE_INFO_CHANGE_STATUS_LABELS,
|
||||||
normalizeStorePackageImageUrls,
|
STORE_INFO_CHANGEABLE_FIELD_LABELS,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
||||||
|
|
||||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||||
PENDING: '待审核',
|
PENDING: '待审核',
|
||||||
@@ -41,293 +39,78 @@ const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
|||||||
REJECTED: '已驳回',
|
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 {
|
function fmtFieldValue(field: string, v: unknown): string {
|
||||||
if (v == null || String(v).trim() === '') return '(空)';
|
if (v == null || String(v).trim() === '') return '(空)';
|
||||||
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
|
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
|
||||||
return String(v);
|
return String(v);
|
||||||
}
|
}
|
||||||
|
if (field === 'envPhotoUrls' && Array.isArray(v)) {
|
||||||
|
return `${v.length} 张`;
|
||||||
|
}
|
||||||
|
if (field === 'coverUrl') return '见对比图';
|
||||||
return String(v);
|
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({
|
function InfoChangeAuditPanel({
|
||||||
initialRequestId,
|
initialRequestId,
|
||||||
}: {
|
}: {
|
||||||
@@ -425,7 +208,7 @@ function InfoChangeAuditPanel({
|
|||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.changedFields?.length
|
row.changedFields?.length
|
||||||
? row.changedFields.map((f) => (
|
? 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,8 +325,11 @@ function InfoChangeAuditPanel({
|
|||||||
{detail.diffs.map((d) => (
|
{detail.diffs.map((d) => (
|
||||||
<Descriptions.Item
|
<Descriptions.Item
|
||||||
key={d.field}
|
key={d.field}
|
||||||
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
|
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
|
||||||
>
|
>
|
||||||
|
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
|
||||||
|
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
|
||||||
|
) : (
|
||||||
<span>
|
<span>
|
||||||
<Typography.Text delete type="secondary">
|
<Typography.Text delete type="secondary">
|
||||||
{fmtFieldValue(d.field, d.live)}
|
{fmtFieldValue(d.field, d.live)}
|
||||||
@@ -553,6 +339,7 @@ function InfoChangeAuditPanel({
|
|||||||
{fmtFieldValue(d.field, d.proposed)}
|
{fmtFieldValue(d.field, d.proposed)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
))}
|
))}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
@@ -599,8 +386,8 @@ export default function StorePackageAuditsPage() {
|
|||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
|
||||||
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
|
||||||
|
|
||||||
async function reload(nextPage = page, nextStatus = status) {
|
async function reload(nextPage = page, nextStatus = status) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -629,30 +416,23 @@ export default function StorePackageAuditsPage() {
|
|||||||
void reload(1, status);
|
void reload(1, status);
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
|
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
|
||||||
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
||||||
const infoRequestId = searchParams.get('infoRequestId');
|
const infoRequestId = searchParams.get('infoRequestId');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rid = searchParams.get('requestId');
|
const rid = searchParams.get('requestId');
|
||||||
if (rid) void openDetail(rid);
|
if (rid) {
|
||||||
|
setActiveRequestId(rid);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
function openDetail(id: string) {
|
||||||
|
setActiveRequestId(id);
|
||||||
|
setDrawerTitle('套餐变更详情');
|
||||||
setDetailOpen(true);
|
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) {
|
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> = [
|
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||||
{
|
{
|
||||||
@@ -702,7 +474,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="link" onClick={() => void openDetail(row.id)}>
|
<Button type="link" onClick={() => openDetail(row.id)}>
|
||||||
查看变更
|
查看变更
|
||||||
</Button>
|
</Button>
|
||||||
{row.status === 'PENDING' ? (
|
{row.status === 'PENDING' ? (
|
||||||
@@ -781,90 +553,23 @@ export default function StorePackageAuditsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
title={drawerTitle}
|
||||||
width={880}
|
width={880}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
extra={
|
destroyOnClose
|
||||||
detail?.status === 'PENDING' ? (
|
>
|
||||||
<Space>
|
{activeRequestId ? (
|
||||||
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
<StorePackageAuditPanel
|
||||||
<Button
|
requestId={activeRequestId}
|
||||||
danger
|
onAudited={() => {
|
||||||
onClick={() => {
|
setDetailOpen(false);
|
||||||
setActiveId(detail.id);
|
void reload(page, status);
|
||||||
setRejectReason('');
|
}}
|
||||||
setRejectOpen(true);
|
onDetailLoaded={(d) => {
|
||||||
|
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
驳回
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
@@ -892,3 +597,4 @@ export default function StorePackageAuditsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -46,6 +47,9 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
|||||||
import AdminStorePackagesSection, {
|
import AdminStorePackagesSection, {
|
||||||
type AdminStorePackagesHandle,
|
type AdminStorePackagesHandle,
|
||||||
} from '../components/AdminStorePackagesSection';
|
} from '../components/AdminStorePackagesSection';
|
||||||
|
import StorePackageAuditPanel, {
|
||||||
|
auditStorePackageRequest,
|
||||||
|
} from '../components/StorePackageAuditPanel';
|
||||||
|
|
||||||
const CREATE_STEPS = [
|
const CREATE_STEPS = [
|
||||||
{ title: '基本信息' },
|
{ title: '基本信息' },
|
||||||
@@ -242,6 +246,8 @@ export default function StoresPage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialCityId = searchParams.get('cityId') ?? '';
|
const initialCityId = searchParams.get('cityId') ?? '';
|
||||||
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
||||||
|
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
|
||||||
|
const initialStoreId = searchParams.get('storeId') ?? '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||||
@@ -249,6 +255,7 @@ export default function StoresPage() {
|
|||||||
const init: Record<string, string | boolean> = {};
|
const init: Record<string, string | boolean> = {};
|
||||||
if (initialCityId) init.cityId = initialCityId;
|
if (initialCityId) init.cityId = initialCityId;
|
||||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||||
|
if (initialAuditStatus) init.auditStatus = initialAuditStatus;
|
||||||
return init;
|
return init;
|
||||||
});
|
});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
||||||
@@ -270,7 +277,11 @@ export default function StoresPage() {
|
|||||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [detailTab, setDetailTab] = useState('basic');
|
||||||
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
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 [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [auditing, setAuditing] = useState(false);
|
const [auditing, setAuditing] = useState(false);
|
||||||
@@ -286,6 +297,7 @@ export default function StoresPage() {
|
|||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
||||||
|
const deepLinkStoreOpenedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
@@ -296,6 +308,12 @@ export default function StoresPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialAuditStatus) {
|
||||||
|
form.setFieldsValue({ auditStatus: initialAuditStatus });
|
||||||
|
}
|
||||||
|
}, [form, initialAuditStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cityId = searchParams.get('cityId') ?? '';
|
const cityId = searchParams.get('cityId') ?? '';
|
||||||
const partnerId = searchParams.get('partnerId') ?? '';
|
const partnerId = searchParams.get('partnerId') ?? '';
|
||||||
@@ -348,13 +366,18 @@ export default function StoresPage() {
|
|||||||
.map((n) => ({ value: n.id, label: n.name }));
|
.map((n) => ({ value: n.id, label: n.name }));
|
||||||
}, [categoryTree, editCategoryParentId]);
|
}, [categoryTree, editCategoryParentId]);
|
||||||
|
|
||||||
async function openStoreDetail(row: StoreRow) {
|
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
|
||||||
const [d, cats] = await Promise.all([
|
const [d, cats] = await Promise.all([
|
||||||
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
||||||
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
|
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
|
||||||
]);
|
]);
|
||||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
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'
|
const category = d.category && typeof d.category === 'object'
|
||||||
? (d.category as { id?: string; parentId?: string | null })
|
? (d.category as { id?: string; parentId?: string | null })
|
||||||
: null;
|
: null;
|
||||||
@@ -441,6 +464,19 @@ export default function StoresPage() {
|
|||||||
setDrawerOpen(true);
|
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() {
|
async function saveStoreDetail() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
setSaving(true);
|
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={() => void openStoreDetail(row)}>详情</Button>
|
||||||
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
||||||
{row.pendingPackageAuditId ? (
|
{row.pendingPackageAuditId ? (
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
onClick={() => void openStoreDetail(row, { tab: 'packages' })}
|
||||||
>
|
>
|
||||||
审核套餐
|
审核套餐
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
|
||||||
>
|
|
||||||
对比
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{row.pendingInfoChangeId ? (
|
{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 && (
|
extra={detail && (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||||
@@ -940,6 +967,41 @@ export default function StoresPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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 }}
|
<Select value={String(detail.status)} style={{ width: 120 }}
|
||||||
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
onChange={async (status) => {
|
onChange={async (status) => {
|
||||||
@@ -954,6 +1016,8 @@ export default function StoresPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
activeKey={detailTab}
|
||||||
|
onChange={setDetailTab}
|
||||||
destroyInactiveTabPane={false}
|
destroyInactiveTabPane={false}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@@ -1202,15 +1266,76 @@ export default function StoresPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'packages',
|
key: 'packages',
|
||||||
label: '套餐',
|
label: detail.pendingPackageAuditId ? (
|
||||||
|
<Badge dot offset={[4, 0]}>
|
||||||
|
套餐
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'套餐'
|
||||||
|
),
|
||||||
forceRender: true,
|
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>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</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
|
<Modal
|
||||||
title="新建门店"
|
title="新建门店"
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
@@ -249,15 +249,53 @@ export default function SystemSettingsPage() {
|
|||||||
|
|
||||||
const collapseItems = useMemo(() => {
|
const collapseItems = useMemo(() => {
|
||||||
if (!meta) return [];
|
if (!meta) return [];
|
||||||
return meta.groups.map((group) => ({
|
const SHARE_SUBGROUP_LABELS: Record<string, string> = {
|
||||||
key: group.key,
|
global: '全局',
|
||||||
label: group.label,
|
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,
|
forceRender: true,
|
||||||
children: (
|
children: (
|
||||||
<div style={{ maxWidth: 720 }}>
|
<div style={{ maxWidth: 720 }}>
|
||||||
{meta.fields
|
{(bySub.get(sk) ?? []).map((f) =>
|
||||||
.filter((f) => f.group === group.key)
|
|
||||||
.map((f) =>
|
|
||||||
renderField(
|
renderField(
|
||||||
f,
|
f,
|
||||||
meta.configuredSecrets,
|
meta.configuredSecrets,
|
||||||
@@ -268,7 +306,32 @@ export default function SystemSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
children = (
|
||||||
|
<div style={{ maxWidth: 720 }}>
|
||||||
|
{fields.map((f) =>
|
||||||
|
renderField(
|
||||||
|
f,
|
||||||
|
meta.configuredSecrets,
|
||||||
|
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||||
|
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||||
|
) : undefined,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: group.key,
|
||||||
|
label: group.label,
|
||||||
|
forceRender: true,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
});
|
||||||
}, [meta, mockSmsEnabled, loading]);
|
}, [meta, mockSmsEnabled, loading]);
|
||||||
|
|
||||||
async function onSave() {
|
async function onSave() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
@@ -21,8 +22,13 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import {
|
import {
|
||||||
WECOM_PUSH_CONDITION_GROUPS,
|
WECOM_PUSH_CONDITION_GROUPS,
|
||||||
WECOM_PUSH_CONDITION_LABELS,
|
WECOM_PUSH_CONDITION_LABELS,
|
||||||
|
WECOM_TEMPLATE_EVENT_KEYS,
|
||||||
|
WECOM_TEMPLATE_EVENT_LABELS,
|
||||||
|
WECOM_TEMPLATE_PLACEHOLDERS,
|
||||||
type WecomMessagePushDto,
|
type WecomMessagePushDto,
|
||||||
type WecomPushCondition,
|
type WecomPushCondition,
|
||||||
|
type WecomPushTemplateDto,
|
||||||
|
type WecomTemplateEventKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
@@ -78,7 +84,7 @@ function WecomPushConditionPicker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WecomMessagePushesPage() {
|
function PushRoutesTab() {
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [form] = Form.useForm<FormValues>();
|
const [form] = Form.useForm<FormValues>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -257,11 +263,9 @@ export default function WecomMessagePushesPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
配置群机器人 Webhook 多实例,按推送条件分发运营告警、技术支持工单、开发任务派发等消息。运行时不再读取
|
配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。
|
||||||
.env 中的 Webhook URL。
|
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
@@ -332,10 +336,10 @@ export default function WecomMessagePushesPage() {
|
|||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
<Input placeholder="如:运营告警" />
|
<Input placeholder="如:业务待办通知群" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
||||||
<OssUpload />
|
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="webhookUrl"
|
name="webhookUrl"
|
||||||
@@ -400,6 +404,208 @@ export default function WecomMessagePushesPage() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ type Props = {
|
|||||||
accept?: string;
|
accept?: string;
|
||||||
/** 计量单位文案,如「张」「个」 */
|
/** 计量单位文案,如「张」「个」 */
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
/**
|
||||||
|
* stack:缩略图 + 下方独立上传按钮(合同等)
|
||||||
|
* grid:九宫格,末尾「+」格上传,无独立大按钮(环境图)
|
||||||
|
*/
|
||||||
|
variant?: 'stack' | 'grid';
|
||||||
};
|
};
|
||||||
|
|
||||||
function isCancelError(msg: string): boolean {
|
function isCancelError(msg: string): boolean {
|
||||||
@@ -42,6 +47,7 @@ export default function MultiOssUploadField({
|
|||||||
label,
|
label,
|
||||||
accept = 'image/*',
|
accept = 'image/*',
|
||||||
unit = '张',
|
unit = '张',
|
||||||
|
variant = 'stack',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const pickingRef = useRef(false);
|
const pickingRef = useRef(false);
|
||||||
@@ -53,6 +59,7 @@ export default function MultiOssUploadField({
|
|||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
const remaining = Math.max(0, maxCount - urls.length);
|
const remaining = Math.max(0, maxCount - urls.length);
|
||||||
const inWechat = isWechatEnv();
|
const inWechat = isWechatEnv();
|
||||||
|
const isGrid = variant === 'grid';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
urlsRef.current = urls;
|
urlsRef.current = urls;
|
||||||
@@ -126,30 +133,18 @@ export default function MultiOssUploadField({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
if (inWechat) void pickWechat();
|
||||||
|
else inputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
function removeAt(index: number) {
|
function removeAt(index: number) {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
onChange?.(urls.filter((_, i) => i !== index));
|
onChange?.(urls.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const thumb = (url: string, index: number) => (
|
||||||
<div className="partner-oss-upload">
|
<div key={`${url}-${index}`} className={isGrid ? 'partner-upload-grid-thumb' : undefined} style={isGrid ? undefined : { position: 'relative', width: 88, height: 88 }}>
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept={accept}
|
|
||||||
multiple
|
|
||||||
className="partner-oss-upload-input"
|
|
||||||
disabled={disabled || uploading || remaining <= 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const list = Array.from(e.target.files ?? []);
|
|
||||||
if (list.length) void uploadFiles(list);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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) ? (
|
{isPdf(url) ? (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
@@ -157,8 +152,8 @@ export default function MultiOssUploadField({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
width: 88,
|
width: isGrid ? '100%' : 88,
|
||||||
height: 88,
|
height: isGrid ? '100%' : 88,
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -178,7 +173,13 @@ export default function MultiOssUploadField({
|
|||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
style={{
|
||||||
|
width: isGrid ? '100%' : 88,
|
||||||
|
height: isGrid ? '100%' : 88,
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: isGrid ? 12 : 8,
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!disabled ? (
|
{!disabled ? (
|
||||||
@@ -202,18 +203,52 @@ export default function MultiOssUploadField({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="partner-oss-upload">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple
|
||||||
|
className="partner-oss-upload-input"
|
||||||
|
disabled={disabled || uploading || remaining <= 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const list = Array.from(e.target.files ?? []);
|
||||||
|
if (list.length) void uploadFiles(list);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{urls.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||||
|
{urls.map((url, index) => thumb(url, index))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||||
disabled={disabled || uploading || remaining <= 0}
|
disabled={disabled || uploading || remaining <= 0}
|
||||||
onClick={() => {
|
onClick={openPicker}
|
||||||
if (inWechat) void pickWechat();
|
|
||||||
else inputRef.current?.click();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||||
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
||||||
@@ -226,6 +261,8 @@ export default function MultiOssUploadField({
|
|||||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="partner-form-error" role="alert">
|
<p className="partner-form-error" role="alert">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export default function ProxyOrderPage() {
|
|||||||
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
||||||
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
||||||
const [productId, setProductId] = useState(draft0.productId);
|
const [productId, setProductId] = useState(draft0.productId);
|
||||||
|
const [skuId, setSkuId] = useState('');
|
||||||
const [quantity, setQuantity] = useState(draft0.quantity);
|
const [quantity, setQuantity] = useState(draft0.quantity);
|
||||||
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
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 selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
const selectedSku =
|
||||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!selectedProduct) return;
|
if (!selectedProduct) return;
|
||||||
@@ -184,6 +213,7 @@ export default function ProxyOrderPage() {
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
|
skuId: skuId || undefined,
|
||||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -200,7 +230,7 @@ export default function ProxyOrderPage() {
|
|||||||
.finally(() => setPreviewLoading(false));
|
.finally(() => setPreviewLoading(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
|
}, [productId, skuId, quantity, deliveryMode, region?.city, region?.district]);
|
||||||
|
|
||||||
function validateForm(): string | null {
|
function validateForm(): string | null {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||||
@@ -359,6 +389,7 @@ export default function ProxyOrderPage() {
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
|
skuId: skuId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -524,8 +555,35 @@ export default function ProxyOrderPage() {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
<label className="partner-form-label">数量</label>
|
<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">
|
||||||
|
{selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'}
|
||||||
|
</label>
|
||||||
<div className="partner-input-wrap">
|
<div className="partner-input-wrap">
|
||||||
<input
|
<input
|
||||||
className="partner-input"
|
className="partner-input"
|
||||||
|
|||||||
@@ -1007,9 +1007,9 @@ export default function StoreCreatePage() {
|
|||||||
<MultiOssUploadField
|
<MultiOssUploadField
|
||||||
bizType="STORE_ENV"
|
bizType="STORE_ENV"
|
||||||
maxCount={20}
|
maxCount={20}
|
||||||
|
variant="grid"
|
||||||
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
|
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
|
||||||
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
|
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
|
||||||
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20)`}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export default function StoreDetailPage() {
|
|||||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [mediaSaving, setMediaSaving] = useState(false);
|
|
||||||
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||||
@@ -159,18 +158,91 @@ 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;
|
if (!id || saving || status === 'CLOSED') return;
|
||||||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||||
if (auditStatus === 'PENDING') {
|
if (auditStatus === 'PENDING') {
|
||||||
setActionError('门店审核中,暂不可修改资料');
|
setActionError('门店审核中,暂不可修改资料');
|
||||||
return;
|
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);
|
setSaving(true);
|
||||||
setActionError('');
|
setActionError('');
|
||||||
try {
|
try {
|
||||||
// v3.5.1 #5:基本信息变更走「提交变更」审核流,由总部审核通过后覆盖门店
|
// 入驻被驳回:直写 media/basic 并重提门店审核;已通过门店:统一走信息变更审核
|
||||||
await submitStoreInfoChangeRequest(id, {
|
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(),
|
name: form.name.trim(),
|
||||||
contactPhone: form.contactPhone.trim(),
|
contactPhone: form.contactPhone.trim(),
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
@@ -182,9 +254,37 @@ export default function StoreDetailPage() {
|
|||||||
longitude: Number(form.longitude),
|
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);
|
setPendingInfoChange(true);
|
||||||
toastSuccess('变更已提交,等待总部审核');
|
toastSuccess(
|
||||||
|
wantMedia
|
||||||
|
? '变更已提交(含门头照/环境图),总部审核通过后生效'
|
||||||
|
: '变更已提交,等待总部审核',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionError(e instanceof Error ? e.message : '提交失败');
|
setActionError(e instanceof Error ? e.message : '提交失败');
|
||||||
} finally {
|
} 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) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="partner-detail-page partner-home--flush-top">
|
<div className="partner-detail-page partner-home--flush-top">
|
||||||
@@ -445,27 +509,13 @@ export default function StoreDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{canMutate && !readOnly ? (
|
{canMutate && !readOnly ? (
|
||||||
<>
|
|
||||||
<MultiOssUploadField
|
<MultiOssUploadField
|
||||||
bizType="STORE_ENV"
|
bizType="STORE_ENV"
|
||||||
maxCount={20}
|
maxCount={20}
|
||||||
|
variant="grid"
|
||||||
value={uniqueEnvUrls(envPhotoUrls)}
|
value={uniqueEnvUrls(envPhotoUrls)}
|
||||||
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
|
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>
|
|
||||||
</>
|
|
||||||
) : envPhotos.length > 0 ? (
|
) : envPhotos.length > 0 ? (
|
||||||
<div className="partner-photo-grid">
|
<div className="partner-photo-grid">
|
||||||
{envPhotos.map((url, index) => (
|
{envPhotos.map((url, index) => (
|
||||||
@@ -495,7 +545,7 @@ export default function StoreDetailPage() {
|
|||||||
<footer className="partner-save-footer">
|
<footer className="partner-save-footer">
|
||||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||||
{canMutate && (
|
{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>
|
<span className="material-symbols-outlined">send</span>
|
||||||
{saving ? '提交中…' : '提交变更'}
|
{saving ? '提交中…' : '提交变更'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1622,10 +1622,19 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-upload-grid-thumb {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.partner-upload-grid .partner-upload-dashed {
|
.partner-upload-grid .partner-upload-dashed {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-upload-dashed--compact {
|
.partner-upload-dashed--compact {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { uploadRedeemPendingPhoto } from '../lib/upload';
|
import { uploadRedeemPendingPhoto } from '../lib/upload';
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
|
import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
|
||||||
@@ -18,25 +19,22 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
const [previewUrl, setPreviewUrl] = useState('');
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
const [photoResourceId, setPhotoResourceId] = useState('');
|
const [photoResourceId, setPhotoResourceId] = useState('');
|
||||||
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
||||||
const [msg, setMsg] = useState('');
|
|
||||||
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setMsg('');
|
|
||||||
try {
|
try {
|
||||||
const registered = await uploadRedeemPendingPhoto(file);
|
const registered = await uploadRedeemPendingPhoto(file);
|
||||||
setPhotoResourceId(registered.id);
|
setPhotoResourceId(registered.id);
|
||||||
setPreviewUrl(registered.url);
|
setPreviewUrl(registered.url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '上传失败');
|
toastError(e instanceof Error ? e.message : '上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickPhoto() {
|
async function pickPhoto() {
|
||||||
setMsg('');
|
|
||||||
if (isWechatEnv()) {
|
if (isWechatEnv()) {
|
||||||
try {
|
try {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -51,7 +49,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = e instanceof Error ? e.message : '选图失败';
|
const text = e instanceof Error ? e.message : '选图失败';
|
||||||
if (!/cancel/i.test(text)) {
|
if (!/cancel/i.test(text)) {
|
||||||
setMsg(`${text},可改从系统相册选择`);
|
toastError(`${text},可改从系统相册选择`);
|
||||||
setShowAlbumFallback(true);
|
setShowAlbumFallback(true);
|
||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
}
|
}
|
||||||
@@ -65,11 +63,10 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
|
|
||||||
async function submitPending() {
|
async function submitPending() {
|
||||||
if (!photoResourceId) {
|
if (!photoResourceId) {
|
||||||
setMsg('请先拍摄或上传核销码照片');
|
toastError('请先拍摄或上传核销码照片');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setMsg('');
|
|
||||||
try {
|
try {
|
||||||
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
|
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -81,7 +78,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
});
|
});
|
||||||
setResult(res);
|
setResult(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '提交失败');
|
toastError(e instanceof Error ? e.message : '提交失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -89,8 +86,8 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
|
|
||||||
function copyText(text: string) {
|
function copyText(text: string) {
|
||||||
void navigator.clipboard?.writeText(text).then(
|
void navigator.clipboard?.writeText(text).then(
|
||||||
() => setMsg('已复制'),
|
() => toastSuccess('已复制'),
|
||||||
() => setMsg('复制失败,请手动长按复制'),
|
() => toastError('复制失败,请手动长按复制'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +166,6 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{msg && <p className="shop-redeem-error" style={{ marginTop: 12 }}>{msg}</p>}
|
|
||||||
</section>
|
</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 { BrowserRouter } from 'react-router-dom';
|
||||||
import { getRouterBasename } from '@dukang/weixin-sdk';
|
import { getRouterBasename } from '@dukang/weixin-sdk';
|
||||||
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
||||||
|
import { ShopToastProvider } from './contexts/ShopToastContext';
|
||||||
import { installClientErrorReporting } from '@dukang/client-logging';
|
import { installClientErrorReporting } from '@dukang/client-logging';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { apiBase } from './lib/api';
|
import { apiBase } from './lib/api';
|
||||||
@@ -19,7 +20,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter basename={getRouterBasename()}>
|
<BrowserRouter basename={getRouterBasename()}>
|
||||||
<StoreSessionProvider>
|
<StoreSessionProvider>
|
||||||
|
<ShopToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ShopToastProvider>
|
||||||
</StoreSessionProvider>
|
</StoreSessionProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
|||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||||
|
|
||||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
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 {
|
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||||
|
|
||||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||||
@@ -476,7 +469,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<span style={{ fontSize: 18 }}>¥</span>
|
<span style={{ fontSize: 18 }}>¥</span>
|
||||||
|
|
||||||
{formatMoney(Number(dash?.todayAmount || 0))}
|
{formatMoney(toMoneyNumber(dash?.todayAmount))}
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -596,7 +589,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
|
<p className="shop-home-record-amount">¥{formatMoney(toMoneyNumber(r.amount))}</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
@@ -17,7 +18,6 @@ export default function PhoneRedeemPage() {
|
|||||||
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
const [storeClosed, setStoreClosed] = useState(false);
|
const [storeClosed, setStoreClosed] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||||
const [showOpenModal, setShowOpenModal] = useState(false);
|
const [showOpenModal, setShowOpenModal] = useState(false);
|
||||||
@@ -41,11 +41,10 @@ export default function PhoneRedeemPage() {
|
|||||||
async function prepareDirectRedeem() {
|
async function prepareDirectRedeem() {
|
||||||
const value = Number(amount);
|
const value = Number(amount);
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
setMsg('请输入有效核销金额');
|
toastError('请输入有效核销金额');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
|
||||||
try {
|
try {
|
||||||
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -54,10 +53,10 @@ export default function PhoneRedeemPage() {
|
|||||||
setPrepared(result);
|
setPrepared(result);
|
||||||
setConfirmCode('');
|
setConfirmCode('');
|
||||||
setConfirmCooldown(60);
|
setConfirmCooldown(60);
|
||||||
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
toastSuccess(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPrepared(null);
|
setPrepared(null);
|
||||||
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
toastError(e instanceof Error ? e.message : '发送验证码失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -65,7 +64,7 @@ export default function PhoneRedeemPage() {
|
|||||||
|
|
||||||
async function sendConfirmSms() {
|
async function sendConfirmSms() {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||||
setMsg('请输入正确的手机号');
|
toastError('请输入正确的手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (storeClosed) {
|
if (storeClosed) {
|
||||||
@@ -88,7 +87,7 @@ export default function PhoneRedeemPage() {
|
|||||||
await prepareDirectRedeem();
|
await prepareDirectRedeem();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setShowOpenModal(false);
|
setShowOpenModal(false);
|
||||||
setMsg(e instanceof Error ? e.message : '开启营业失败');
|
toastError(e instanceof Error ? e.message : '开启营业失败');
|
||||||
} finally {
|
} finally {
|
||||||
setOpening(false);
|
setOpening(false);
|
||||||
}
|
}
|
||||||
@@ -96,15 +95,14 @@ export default function PhoneRedeemPage() {
|
|||||||
|
|
||||||
async function confirmRedeem() {
|
async function confirmRedeem() {
|
||||||
if (!prepared) {
|
if (!prepared) {
|
||||||
setMsg('请先发送核销验证码');
|
toastError('请先发送核销验证码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirmCode.trim()) {
|
if (!confirmCode.trim()) {
|
||||||
setMsg('请输入确认验证码');
|
toastError('请输入确认验证码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
|
||||||
try {
|
try {
|
||||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -118,7 +116,7 @@ export default function PhoneRedeemPage() {
|
|||||||
state: { result, storeName, user: prepared.user },
|
state: { result, storeName, user: prepared.user },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
toastError(e instanceof Error ? e.message : '核销失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -225,8 +223,6 @@ export default function PhoneRedeemPage() {
|
|||||||
>
|
>
|
||||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
type RangeKey = 'today' | '7d' | '30d';
|
type RangeKey = 'today' | '7d' | '30d';
|
||||||
type StatusFilter = 'all' | 'pending' | 'paid';
|
type StatusFilter = 'all' | 'pending' | 'paid';
|
||||||
|
|
||||||
function formatMoney(n: number) {
|
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function inRange(dateStr: string, range: RangeKey) {
|
function inRange(dateStr: string, range: RangeKey) {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -85,8 +82,8 @@ export default function RecordsPage() {
|
|||||||
}, [records, range, statusFilter]);
|
}, [records, range, statusFilter]);
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
const totalAmount = filtered.reduce((s, r) => s + Number(r.amount || 0), 0);
|
const totalAmount = filtered.reduce((s, r) => s + toMoneyNumber(r.amount), 0);
|
||||||
const totalSettle = filtered.reduce((s, r) => s + Number(r.settleAmount || 0), 0);
|
const totalSettle = filtered.reduce((s, r) => s + toMoneyNumber(r.settleAmount), 0);
|
||||||
const rate = totalAmount > 0 ? Math.round((totalSettle / totalAmount) * 100) : 60;
|
const rate = totalAmount > 0 ? Math.round((totalSettle / totalAmount) * 100) : 60;
|
||||||
return { totalAmount, totalSettle, rate };
|
return { totalAmount, totalSettle, rate };
|
||||||
}, [filtered]);
|
}, [filtered]);
|
||||||
@@ -181,8 +178,8 @@ export default function RecordsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="shop-records-list">
|
<div className="shop-records-list">
|
||||||
{filtered.map((r) => {
|
{filtered.map((r) => {
|
||||||
const amount = Number(r.amount || 0);
|
const amount = toMoneyNumber(r.amount);
|
||||||
const settle = Number(r.settleAmount || 0);
|
const settle = toMoneyNumber(r.settleAmount);
|
||||||
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||||
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||||
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
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 WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||||
|
import { toastError } from '../lib/toast';
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
@@ -22,7 +23,6 @@ export default function RedeemConfirmPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
const [msg, setMsg] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
const [preview, setPreview] = useState<Preview | null>(null);
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
@@ -61,10 +61,9 @@ export default function RedeemConfirmPage() {
|
|||||||
body: JSON.stringify({ token }),
|
body: JSON.stringify({ token }),
|
||||||
});
|
});
|
||||||
setPreview(p);
|
setPreview(p);
|
||||||
setMsg('');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
toastError(e instanceof Error ? e.message : '无法预览核销码');
|
||||||
const report = await reportRedeemFailure(token, 'preview', e);
|
const report = await reportRedeemFailure(token, 'preview', e);
|
||||||
if (report?.thresholdReached) {
|
if (report?.thresholdReached) {
|
||||||
setFailCount(report.failCount);
|
setFailCount(report.failCount);
|
||||||
@@ -82,11 +81,10 @@ export default function RedeemConfirmPage() {
|
|||||||
|
|
||||||
async function doConfirm() {
|
async function doConfirm() {
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
setMsg('请先扫码获取核销码');
|
toastError('请先扫码获取核销码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
|
||||||
try {
|
try {
|
||||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/confirm', {
|
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -95,7 +93,7 @@ export default function RedeemConfirmPage() {
|
|||||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||||
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
toastError(e instanceof Error ? e.message : '核销失败');
|
||||||
const report = await reportRedeemFailure(token, 'confirm', e);
|
const report = await reportRedeemFailure(token, 'confirm', e);
|
||||||
if (report?.thresholdReached) {
|
if (report?.thresholdReached) {
|
||||||
setFailCount(report.failCount);
|
setFailCount(report.failCount);
|
||||||
@@ -126,13 +124,12 @@ export default function RedeemConfirmPage() {
|
|||||||
});
|
});
|
||||||
setStoreClosed(false);
|
setStoreClosed(false);
|
||||||
setShowOpenModal(false);
|
setShowOpenModal(false);
|
||||||
setMsg('');
|
|
||||||
// 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销
|
// 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销
|
||||||
await loadPreview();
|
await loadPreview();
|
||||||
await doConfirm();
|
await doConfirm();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setShowOpenModal(false);
|
setShowOpenModal(false);
|
||||||
setMsg(e instanceof Error ? e.message : '开启营业失败');
|
toastError(e instanceof Error ? e.message : '开启营业失败');
|
||||||
} finally {
|
} finally {
|
||||||
setOpening(false);
|
setOpening(false);
|
||||||
}
|
}
|
||||||
@@ -217,8 +214,6 @@ export default function RedeemConfirmPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
|
||||||
|
|
||||||
{!showWeakNet && (
|
{!showWeakNet && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
||||||
|
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||||
function formatAmount(n: number) {
|
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RedeemSuccessPage() {
|
export default function RedeemSuccessPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -24,7 +21,7 @@ export default function RedeemSuccessPage() {
|
|||||||
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
|
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
|
||||||
const user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user;
|
const user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user;
|
||||||
const userLabel = user?.nickname || user?.phone || '—';
|
const userLabel = user?.nickname || user?.phone || '—';
|
||||||
const amount = Number(result?.amount ?? 0);
|
const amount = toMoneyNumber(result?.amount);
|
||||||
const redeemNo = String(result?.redeemNo || '—');
|
const redeemNo = String(result?.redeemNo || '—');
|
||||||
const createdAt = result?.createdAt
|
const createdAt = result?.createdAt
|
||||||
? new Date(String(result.createdAt)).toLocaleString('zh-CN')
|
? new Date(String(result.createdAt)).toLocaleString('zh-CN')
|
||||||
@@ -52,7 +49,7 @@ export default function RedeemSuccessPage() {
|
|||||||
<span className="material-symbols-outlined">check_circle</span>
|
<span className="material-symbols-outlined">check_circle</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="shop-success-title">核销成功</h2>
|
<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-sub">已入账到余额</p>
|
||||||
<p className="shop-success-note">核销成功,账单通知已发送至老板手机</p>
|
<p className="shop-success-note">核销成功,账单通知已发送至老板手机</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,15 +8,12 @@ import {
|
|||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
|
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
|
||||||
|
|
||||||
type StatusFilter = 'all' | StoreWithdrawStatus;
|
type StatusFilter = 'all' | StoreWithdrawStatus;
|
||||||
|
|
||||||
function formatMoney(n: number) {
|
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WithdrawPage() {
|
export default function WithdrawPage() {
|
||||||
useStorePageView('store_withdraw_view');
|
useStorePageView('store_withdraw_view');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -113,13 +110,13 @@ export default function WithdrawPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="shop-records-summary-label">可提未出账余额</p>
|
<p className="shop-records-summary-label">可提未出账余额</p>
|
||||||
<p className="shop-records-summary-value">
|
<p className="shop-records-summary-value">
|
||||||
¥ {formatMoney(summary?.availableAmount ?? 0)}
|
¥ {formatMoney(toMoneyNumber(summary?.availableAmount))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-records-summary-label">今日剩余额度</p>
|
<p className="shop-records-summary-label">今日剩余额度</p>
|
||||||
<p className="shop-records-summary-value">
|
<p className="shop-records-summary-value">
|
||||||
¥ {formatMoney(summary?.remainingDailyLimit ?? 0)}
|
¥ {formatMoney(toMoneyNumber(summary?.remainingDailyLimit))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +206,7 @@ export default function WithdrawPage() {
|
|||||||
<div className="shop-record-amounts">
|
<div className="shop-record-amounts">
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-record-amount-label">提现金额</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-record-amount-label">明细笔数</p>
|
<p className="shop-record-amount-label">明细笔数</p>
|
||||||
|
|||||||
@@ -3190,3 +3190,26 @@ header:has(> .app-page-title:only-child),
|
|||||||
box-shadow: none !important;
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/mini-user",
|
"name": "@dukang/mini-user",
|
||||||
"version": "3.4.15",
|
"version": "3.5.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -54,3 +54,71 @@ body::-webkit-scrollbar,
|
|||||||
padding-bottom: 0 !important;
|
padding-bottom: 0 !important;
|
||||||
bottom: 0 !important;
|
bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.benefit-figure {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-figure-prefix,
|
||||||
|
.benefit-figure-value {
|
||||||
|
line-height: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-figure-icon {
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-figure--sm {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-figure--sm .benefit-figure-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-figure--md .benefit-figure-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-figure--lg .benefit-figure-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-figure--xl {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-figure--xl .benefit-figure-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mu-float-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 28%;
|
||||||
|
left: 10%;
|
||||||
|
right: 10%;
|
||||||
|
z-index: 10020;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.78);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 224 B After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
import { Image, Text, View } from '@tarojs/components';
|
||||||
|
import iconStoreBenefit from '../assets/icons/store-benefit.png';
|
||||||
|
|
||||||
|
type BenefitFigureSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
type BenefitFigureProps = {
|
||||||
|
value: string;
|
||||||
|
size?: BenefitFigureSize;
|
||||||
|
prefix?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 好客权益金额:门店核销图标 + 数字,替代人民币符号 */
|
||||||
|
export default function BenefitFigure({
|
||||||
|
value,
|
||||||
|
size = 'md',
|
||||||
|
prefix = '',
|
||||||
|
className = '',
|
||||||
|
}: BenefitFigureProps) {
|
||||||
|
return (
|
||||||
|
<View className={`benefit-figure benefit-figure--${size} ${className}`.trim()}>
|
||||||
|
{prefix ? <Text className="benefit-figure-prefix">{prefix}</Text> : null}
|
||||||
|
<Image className="benefit-figure-icon" src={iconStoreBenefit} mode="aspectFit" />
|
||||||
|
<Text className="benefit-figure-value">{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Text } from '@tarojs/components';
|
import { Text, View } from '@tarojs/components';
|
||||||
|
|
||||||
type CouponBadgeProps = {
|
type CouponBadgeProps = {
|
||||||
amount: number | string;
|
amount: number | string;
|
||||||
label?: string;
|
label?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge) */
|
/** 首页商品角标:纯文案「享{amount}好客权益」(无门店图标) */
|
||||||
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
|
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
|
||||||
const n = Number(amount);
|
const n = Number(amount);
|
||||||
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
|
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
|
||||||
return (
|
return (
|
||||||
<Text className="coupon-badge">
|
<View className="coupon-badge">
|
||||||
享 ¥{display} {label}
|
<Text>
|
||||||
|
享{display}
|
||||||
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CoverView, View } from '@tarojs/components';
|
||||||
|
import { registerFloatingToastListener } from '../lib/floating-toast';
|
||||||
|
|
||||||
|
const TOAST_MS = 2600;
|
||||||
|
|
||||||
|
export default function FloatingToastHost() {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return registerFloatingToastListener((message) => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
setText(message);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setText('');
|
||||||
|
timerRef.current = null;
|
||||||
|
}, TOAST_MS);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
const Box = process.env.TARO_ENV === 'weapp' ? CoverView : View;
|
||||||
|
return <Box className="mu-float-toast">{text}</Box>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PropsWithChildren, ReactNode } from 'react';
|
import type { PropsWithChildren, ReactNode } from 'react';
|
||||||
import { View } from '@tarojs/components';
|
import { View } from '@tarojs/components';
|
||||||
|
import FloatingToastHost from './FloatingToastHost';
|
||||||
import { pageShellCssVars, useNavBarMetrics } from '../lib/nav-bar';
|
import { pageShellCssVars, useNavBarMetrics } from '../lib/nav-bar';
|
||||||
|
|
||||||
type PageShellVariant = 'tab' | 'scroll' | 'sub' | 'plain';
|
type PageShellVariant = 'tab' | 'scroll' | 'sub' | 'plain';
|
||||||
@@ -35,6 +36,7 @@ export default function PageShell({
|
|||||||
return (
|
return (
|
||||||
<View className={classes} style={pageShellCssVars(metrics)}>
|
<View className={classes} style={pageShellCssVars(metrics)}>
|
||||||
{children as ReactNode}
|
{children as ReactNode}
|
||||||
|
<FloatingToastHost />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type ProductCarouselProps = {
|
|||||||
* 依赖 swiper 原生 auto-height:海报有多高,轮播就有多高,无裁切。
|
* 依赖 swiper 原生 auto-height:海报有多高,轮播就有多高,无裁切。
|
||||||
*/
|
*/
|
||||||
imageFit?: 'cover' | 'contain' | 'adaptive';
|
imageFit?: 'cover' | 'contain' | 'adaptive';
|
||||||
|
/** 预览相册(默认等于 images);门店详情可传入封面+环境图合并列表 */
|
||||||
|
previewUrls?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
|
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
|
||||||
@@ -23,6 +25,7 @@ export default function ProductCarousel({
|
|||||||
variant = 'detail',
|
variant = 'detail',
|
||||||
previewable = false,
|
previewable = false,
|
||||||
imageFit = 'cover',
|
imageFit = 'cover',
|
||||||
|
previewUrls,
|
||||||
}: ProductCarouselProps) {
|
}: ProductCarouselProps) {
|
||||||
const slides = images.length > 0 ? images : [''];
|
const slides = images.length > 0 ? images : [''];
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
@@ -33,10 +36,10 @@ export default function ProductCarousel({
|
|||||||
const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}${isAdaptive ? ` ${prefix}-wrap--adaptive` : ''}`;
|
const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}${isAdaptive ? ` ${prefix}-wrap--adaptive` : ''}`;
|
||||||
|
|
||||||
function previewAt(index: number) {
|
function previewAt(index: number) {
|
||||||
const urls = slides.filter(Boolean);
|
const album = (previewUrls?.length ? previewUrls : slides).filter(Boolean);
|
||||||
if (!urls.length) return;
|
if (!album.length) return;
|
||||||
const current = slides[index] || urls[0];
|
const current = slides[index] || album[0];
|
||||||
Taro.previewImage({ current, urls }).catch(() => undefined);
|
Taro.previewImage({ current, urls: album }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { forceReloadAfterAccountMerge } from './auth-nav';
|
|||||||
import { resetStoresSessionBootstrap } from './stores-session';
|
import { resetStoresSessionBootstrap } from './stores-session';
|
||||||
import { resetHomeCatalogBootstrap } from './home-catalog-session';
|
import { resetHomeCatalogBootstrap } from './home-catalog-session';
|
||||||
import { reportClientValidationError } from './client-error';
|
import { reportClientValidationError } from './client-error';
|
||||||
|
import { showFloatingToast } from './floating-toast';
|
||||||
|
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const origin =
|
const origin =
|
||||||
@@ -126,7 +127,13 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none') {
|
export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none') {
|
||||||
Taro.showToast({ title, icon, duration: 1800 });
|
const text = title.trim();
|
||||||
|
if (!text) return;
|
||||||
|
if (icon !== 'success' && showFloatingToast(text)) {
|
||||||
|
void Taro.hideToast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Taro.showToast({ title: text, icon, duration: 1800 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionPayload = {
|
export type SessionPayload = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type CheckoutContext = {
|
export type CheckoutContext = {
|
||||||
productId?: string;
|
productId?: string;
|
||||||
|
skuId?: string;
|
||||||
qty?: string;
|
qty?: string;
|
||||||
addressId?: string;
|
addressId?: string;
|
||||||
cross?: boolean;
|
cross?: boolean;
|
||||||
@@ -9,6 +10,7 @@ export type CheckoutContext = {
|
|||||||
export function buildQuery(ctx: CheckoutContext): string {
|
export function buildQuery(ctx: CheckoutContext): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (ctx.productId) parts.push(`productId=${encodeURIComponent(ctx.productId)}`);
|
if (ctx.productId) parts.push(`productId=${encodeURIComponent(ctx.productId)}`);
|
||||||
|
if (ctx.skuId) parts.push(`skuId=${encodeURIComponent(ctx.skuId)}`);
|
||||||
if (ctx.qty) parts.push(`qty=${encodeURIComponent(ctx.qty)}`);
|
if (ctx.qty) parts.push(`qty=${encodeURIComponent(ctx.qty)}`);
|
||||||
if (ctx.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
|
if (ctx.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
|
||||||
if (ctx.cross) parts.push('cross=1');
|
if (ctx.cross) parts.push('cross=1');
|
||||||
@@ -36,12 +38,14 @@ export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext
|
|||||||
export function buildPayUrl(params: {
|
export function buildPayUrl(params: {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
productId?: string;
|
productId?: string;
|
||||||
|
skuId?: string;
|
||||||
qty?: string;
|
qty?: string;
|
||||||
addressId?: string;
|
addressId?: string;
|
||||||
cross?: boolean;
|
cross?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
|
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
|
||||||
if (params.productId) parts.push(`productId=${encodeURIComponent(params.productId)}`);
|
if (params.productId) parts.push(`productId=${encodeURIComponent(params.productId)}`);
|
||||||
|
if (params.skuId) parts.push(`skuId=${encodeURIComponent(params.skuId)}`);
|
||||||
if (params.qty) parts.push(`qty=${encodeURIComponent(params.qty)}`);
|
if (params.qty) parts.push(`qty=${encodeURIComponent(params.qty)}`);
|
||||||
if (params.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
|
if (params.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
|
||||||
if (params.cross) parts.push('cross=1');
|
if (params.cross) parts.push('cross=1');
|
||||||
@@ -51,6 +55,7 @@ export function buildPayUrl(params: {
|
|||||||
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
|
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
|
||||||
return {
|
return {
|
||||||
productId: params.productId,
|
productId: params.productId,
|
||||||
|
skuId: params.skuId,
|
||||||
qty: params.qty,
|
qty: params.qty,
|
||||||
addressId: params.addressId,
|
addressId: params.addressId,
|
||||||
cross: params.cross === '1',
|
cross: params.cross === '1',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
|
|||||||
import { fetchClientConfig } from './pay-wechat';
|
import { fetchClientConfig } from './pay-wechat';
|
||||||
|
|
||||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
||||||
export const APP_VERSION = '3.4.15';
|
export const APP_VERSION = '3.5.4';
|
||||||
|
|
||||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
type FloatingToastListener = (message: string) => void;
|
||||||
|
|
||||||
|
const listeners = new Set<FloatingToastListener>();
|
||||||
|
|
||||||
|
export function registerFloatingToastListener(fn: FloatingToastListener) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已有页面宿主时返回 true,否则调用方应回退到原生 toast */
|
||||||
|
export function showFloatingToast(message: string): boolean {
|
||||||
|
const text = message.trim();
|
||||||
|
if (!text || listeners.size === 0) return false;
|
||||||
|
listeners.forEach((fn) => fn(text));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,7 +1,26 @@
|
|||||||
/** 金额展示(不依赖 Intl / toLocaleString,兼容微信小程序) */
|
/** 金额展示(不依赖 Intl / toLocaleString,兼容微信小程序) */
|
||||||
|
export function toMoneyNumber(amount: unknown): number {
|
||||||
|
if (typeof amount === 'number') return Number.isFinite(amount) ? amount : 0;
|
||||||
|
if (typeof amount === 'string' && amount.trim()) {
|
||||||
|
const n = Number(amount);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
if (amount && typeof amount === 'object') {
|
||||||
|
const o = amount as { toNumber?: () => number; toString?: () => string };
|
||||||
|
if (typeof o.toNumber === 'function') {
|
||||||
|
const n = Number(o.toNumber());
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
if (typeof o.toString === 'function' && o.toString !== Object.prototype.toString) {
|
||||||
|
const n = Number(o.toString());
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatMoney(amount: number | string): string {
|
export function formatMoney(amount: number | string): string {
|
||||||
const n = typeof amount === 'number' ? amount : Number(amount);
|
const n = toMoneyNumber(amount);
|
||||||
if (!Number.isFinite(n)) return '0.00';
|
|
||||||
const fixed = n.toFixed(2);
|
const fixed = n.toFixed(2);
|
||||||
const [intPart, dec] = fixed.split('.');
|
const [intPart, dec] = fixed.split('.');
|
||||||
const withComma = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
const withComma = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
|||||||
@@ -82,8 +82,10 @@ function sceneConfig(scene?: ShareScene): MiniShareSceneConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 组装页面分享:场景配置优先;空字段用 dynamic → 默认分享。
|
* 组装页面分享。
|
||||||
* orderDetail 标题支持 {productName}。
|
* - productDetail / storeDetail:title 与 imageUrl 固定用业务字段(不被 HQ 场景配置覆盖)
|
||||||
|
* - 其它场景:场景配置优先;空字段用 dynamic → 默认分享
|
||||||
|
* - orderDetail 标题支持 {productName}
|
||||||
*/
|
*/
|
||||||
export function buildSceneSharePayload(
|
export function buildSceneSharePayload(
|
||||||
scene: ShareScene,
|
scene: ShareScene,
|
||||||
@@ -98,7 +100,13 @@ export function buildSceneSharePayload(
|
|||||||
): PageSharePayload {
|
): PageSharePayload {
|
||||||
const def = getShareRuntimeSync().default;
|
const def = getShareRuntimeSync().default;
|
||||||
const sc = sceneConfig(scene);
|
const sc = sceneConfig(scene);
|
||||||
let title = (sc.title || '').trim();
|
const preferEntity = scene === 'productDetail' || scene === 'storeDetail';
|
||||||
|
|
||||||
|
let title: string;
|
||||||
|
if (preferEntity) {
|
||||||
|
title = (options?.dynamicTitle || '').trim() || def.title;
|
||||||
|
} else {
|
||||||
|
title = (sc.title || '').trim();
|
||||||
if (title && options?.titleVars) {
|
if (title && options?.titleVars) {
|
||||||
const vars = options.titleVars;
|
const vars = options.titleVars;
|
||||||
const missingRequired = Object.entries(vars).some(
|
const missingRequired = Object.entries(vars).some(
|
||||||
@@ -109,12 +117,16 @@ export function buildSceneSharePayload(
|
|||||||
if (!title) {
|
if (!title) {
|
||||||
title = (options?.dynamicTitle || '').trim() || def.title;
|
title = (options?.dynamicTitle || '').trim() || def.title;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc;
|
const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc;
|
||||||
const imgUrl =
|
const imgUrl = preferEntity
|
||||||
(sc.imageUrl || '').trim() ||
|
? (options?.dynamicImageUrl || '').trim() || def.imageUrl || getDefaultShareImageUrl()
|
||||||
|
: (sc.imageUrl || '').trim() ||
|
||||||
(options?.dynamicImageUrl || '').trim() ||
|
(options?.dynamicImageUrl || '').trim() ||
|
||||||
def.imageUrl ||
|
def.imageUrl ||
|
||||||
getDefaultShareImageUrl();
|
getDefaultShareImageUrl();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
desc,
|
desc,
|
||||||
|
|||||||
@@ -97,7 +97,13 @@ export function buildSceneSharePayload(
|
|||||||
): PageSharePayload {
|
): PageSharePayload {
|
||||||
const def = getShareRuntimeSync().default;
|
const def = getShareRuntimeSync().default;
|
||||||
const sc = sceneConfig(scene);
|
const sc = sceneConfig(scene);
|
||||||
let title = (sc.title || '').trim();
|
const preferEntity = scene === 'productDetail' || scene === 'storeDetail';
|
||||||
|
|
||||||
|
let title: string;
|
||||||
|
if (preferEntity) {
|
||||||
|
title = (options?.dynamicTitle || '').trim() || def.title;
|
||||||
|
} else {
|
||||||
|
title = (sc.title || '').trim();
|
||||||
if (title && options?.titleVars) {
|
if (title && options?.titleVars) {
|
||||||
const vars = options.titleVars;
|
const vars = options.titleVars;
|
||||||
const missingRequired = Object.entries(vars).some(
|
const missingRequired = Object.entries(vars).some(
|
||||||
@@ -108,12 +114,16 @@ export function buildSceneSharePayload(
|
|||||||
if (!title) {
|
if (!title) {
|
||||||
title = (options?.dynamicTitle || '').trim() || def.title;
|
title = (options?.dynamicTitle || '').trim() || def.title;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc;
|
const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc;
|
||||||
const imgUrl =
|
const imgUrl = preferEntity
|
||||||
(sc.imageUrl || '').trim() ||
|
? (options?.dynamicImageUrl || '').trim() || def.imageUrl || getDefaultShareImageUrl()
|
||||||
|
: (sc.imageUrl || '').trim() ||
|
||||||
(options?.dynamicImageUrl || '').trim() ||
|
(options?.dynamicImageUrl || '').trim() ||
|
||||||
def.imageUrl ||
|
def.imageUrl ||
|
||||||
getDefaultShareImageUrl();
|
getDefaultShareImageUrl();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
desc,
|
desc,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimel
|
|||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||||
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||||
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
|
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
|
||||||
@@ -150,10 +151,11 @@ export default function BenefitPage() {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="benefit-hero-label">当前好客权益余额</Text>
|
<Text className="benefit-hero-label">当前好客权益余额</Text>
|
||||||
<View className="benefit-hero-amount">
|
<View className="benefit-hero-amount">
|
||||||
<Text className="benefit-hero-symbol">¥</Text>
|
<BenefitFigure
|
||||||
<Text className="benefit-hero-value">
|
value={summary ? formatMoney(summary.totalBalance) : '--'}
|
||||||
{summary ? formatMoney(summary.totalBalance) : '--'}
|
size="xl"
|
||||||
</Text>
|
className="benefit-hero-value"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="benefit-hero-logo">
|
<View className="benefit-hero-logo">
|
||||||
@@ -193,16 +195,19 @@ export default function BenefitPage() {
|
|||||||
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
||||||
<View className="benefit-coupon-head">
|
<View className="benefit-coupon-head">
|
||||||
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
|
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
|
||||||
<Text className="benefit-coupon-balance">¥{formatMoney(c.balance)}</Text>
|
<BenefitFigure value={formatMoney(c.balance)} size="md" className="benefit-coupon-balance" />
|
||||||
</View>
|
</View>
|
||||||
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
|
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
|
||||||
<View className="benefit-progress">
|
<View className="benefit-progress">
|
||||||
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
|
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
|
||||||
</View>
|
</View>
|
||||||
<View className="benefit-coupon-footer">
|
<View className="benefit-coupon-footer">
|
||||||
<Text className="benefit-coupon-meta">
|
<View className="benefit-coupon-meta">
|
||||||
已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)}
|
<Text>已用</Text>
|
||||||
</Text>
|
<BenefitFigure value={formatMoney(c.usedAmount)} size="sm" />
|
||||||
|
<Text>/ 总额</Text>
|
||||||
|
<BenefitFigure value={formatMoney(c.totalAmount)} size="sm" />
|
||||||
|
</View>
|
||||||
<Text
|
<Text
|
||||||
className="benefit-coupon-btn"
|
className="benefit-coupon-btn"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -226,7 +231,12 @@ export default function BenefitPage() {
|
|||||||
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
||||||
<View className="benefit-coupon-head">
|
<View className="benefit-coupon-head">
|
||||||
<Text className="benefit-coupon-name">{r.storeName || '门店核销'}</Text>
|
<Text className="benefit-coupon-name">{r.storeName || '门店核销'}</Text>
|
||||||
<Text className="benefit-coupon-balance">-¥{formatMoney(Number(r.amount))}</Text>
|
<BenefitFigure
|
||||||
|
prefix="-"
|
||||||
|
value={formatMoney(Number(r.amount))}
|
||||||
|
size="md"
|
||||||
|
className="benefit-coupon-balance"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text className="benefit-coupon-no">NO. {r.redeemNo}</Text>
|
<Text className="benefit-coupon-no">NO. {r.redeemNo}</Text>
|
||||||
<View className="benefit-coupon-footer">
|
<View className="benefit-coupon-footer">
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import SubPageHeader from '../../components/SubPageHeader';
|
|||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { usePageView } from '../../lib/usePageView';
|
import { usePageView } from '../../lib/usePageView';
|
||||||
import {
|
import {
|
||||||
INVOICE_KIND_LABELS,
|
INVOICE_CATEGORY_LABELS,
|
||||||
INVOICE_TITLE_TYPE_LABELS,
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
type InvoiceKind,
|
type InvoiceCategory,
|
||||||
type UserInvoiceTitleDto,
|
type UserInvoiceTitleDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
@@ -42,7 +42,8 @@ export default function InvoiceApplyPage() {
|
|||||||
|
|
||||||
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
|
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState('');
|
const [selectedId, setSelectedId] = useState('');
|
||||||
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
|
const [invoiceCategory, setInvoiceCategory] = useState<InvoiceCategory>('LIQUOR');
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
const [emailOverride, setEmailOverride] = useState('');
|
const [emailOverride, setEmailOverride] = useState('');
|
||||||
const [phoneOverride, setPhoneOverride] = useState('');
|
const [phoneOverride, setPhoneOverride] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -103,19 +104,17 @@ export default function InvoiceApplyPage() {
|
|||||||
toast('请填写联系电话');
|
toast('请填写联系电话');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (invoiceKind === 'SPECIAL' && selected.titleType !== 'ENTERPRISE') {
|
|
||||||
toast('专用发票仅支持企业抬头');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await request(`/trade/orders/${orderId}/invoices`, {
|
await request(`/trade/orders/${orderId}/invoices`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
titleId: selectedId,
|
titleId: selectedId,
|
||||||
invoiceKind,
|
invoiceKind: 'NORMAL',
|
||||||
|
invoiceCategory,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
|
remark: remark.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast('发票申请已提交', 'success');
|
toast('发票申请已提交', 'success');
|
||||||
@@ -192,20 +191,26 @@ export default function InvoiceApplyPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canApply ? (
|
{canApply ? (
|
||||||
|
<>
|
||||||
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
|
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
|
||||||
<Text className="invoice-title-label">发票类型</Text>
|
<Text className="invoice-title-label">发票类型</Text>
|
||||||
|
<Text className="invoice-kind-static">增值税普通发票</Text>
|
||||||
|
</View>
|
||||||
|
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
|
||||||
|
<Text className="invoice-title-label">类型</Text>
|
||||||
<View className="invoice-title-type-row">
|
<View className="invoice-title-type-row">
|
||||||
{(['NORMAL', 'SPECIAL'] as InvoiceKind[]).map((k) => (
|
{(['LIQUOR', 'CATERING'] as InvoiceCategory[]).map((k) => (
|
||||||
<Text
|
<Text
|
||||||
key={k}
|
key={k}
|
||||||
className={`invoice-title-type-chip${invoiceKind === k ? ' active' : ''}`}
|
className={`invoice-title-type-chip${invoiceCategory === k ? ' active' : ''}`}
|
||||||
onClick={() => setInvoiceKind(k)}
|
onClick={() => setInvoiceCategory(k)}
|
||||||
>
|
>
|
||||||
{INVOICE_KIND_LABELS[k]}
|
{INVOICE_CATEGORY_LABELS[k]}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canApply ? (
|
{canApply ? (
|
||||||
@@ -258,7 +263,11 @@ export default function InvoiceApplyPage() {
|
|||||||
|
|
||||||
{canApply && selected && !selected.email ? (
|
{canApply && selected && !selected.email ? (
|
||||||
<View className="invoice-title-field" style={{ marginTop: 16 }}>
|
<View className="invoice-title-field" style={{ marginTop: 16 }}>
|
||||||
<Text className="invoice-title-label">接收邮箱</Text>
|
<Text className="invoice-title-label">
|
||||||
|
<Text className="invoice-title-star">*</Text>
|
||||||
|
接收邮箱
|
||||||
|
<Text className="invoice-title-label-hint">必填</Text>
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
placeholder="电子发票将发送至此邮箱"
|
placeholder="电子发票将发送至此邮箱"
|
||||||
@@ -279,6 +288,19 @@ export default function InvoiceApplyPage() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{canApply ? (
|
||||||
|
<View className="invoice-title-field" style={{ marginTop: 16 }}>
|
||||||
|
<Text className="invoice-title-label">备注</Text>
|
||||||
|
<Input
|
||||||
|
className="invoice-title-input"
|
||||||
|
maxlength={200}
|
||||||
|
placeholder="选填,将显示在发票申请备注中"
|
||||||
|
value={remark}
|
||||||
|
onInput={(e) => setRemark(e.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{canApply && titles.length > 0 ? (
|
{canApply && titles.length > 0 ? (
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ const ACTION_ICONS = {
|
|||||||
|
|
||||||
type EditDraft = UpsertInvoiceTitleRequest & { id?: string };
|
type EditDraft = UpsertInvoiceTitleRequest & { id?: string };
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
children,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Text className="invoice-title-label">
|
||||||
|
{required ? <Text className="invoice-title-star">*</Text> : null}
|
||||||
|
{children}
|
||||||
|
{hint ? <Text className="invoice-title-label-hint">{hint}</Text> : null}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TitleActionIcon({
|
function TitleActionIcon({
|
||||||
kind,
|
kind,
|
||||||
label,
|
label,
|
||||||
@@ -120,6 +138,15 @@ export default function InvoiceTitlesPage() {
|
|||||||
toast('企业抬头须填写税号');
|
toast('企业抬头须填写税号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const email = draft.email?.trim() || '';
|
||||||
|
if (!email) {
|
||||||
|
toast('请填写接收邮箱(必填)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
toast('邮箱格式不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload: UpsertInvoiceTitleRequest = {
|
const payload: UpsertInvoiceTitleRequest = {
|
||||||
@@ -152,6 +179,10 @@ export default function InvoiceTitlesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setDefault(t: UserInvoiceTitleDto) {
|
async function setDefault(t: UserInvoiceTitleDto) {
|
||||||
|
if (!t.email?.trim()) {
|
||||||
|
toast('请先补全接收邮箱后再设为默认');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await request(`/trade/invoice-titles/${t.id}`, {
|
await request(`/trade/invoice-titles/${t.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -241,7 +272,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View className="invoice-title-field">
|
<View className="invoice-title-field">
|
||||||
<Text className="invoice-title-label">抬头类型</Text>
|
<FieldLabel required>抬头类型</FieldLabel>
|
||||||
<View className="invoice-title-type-row">
|
<View className="invoice-title-type-row">
|
||||||
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
|
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
|
||||||
<Text
|
<Text
|
||||||
@@ -258,7 +289,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="invoice-title-field">
|
<View className="invoice-title-field">
|
||||||
<Text className="invoice-title-label">抬头名称</Text>
|
<FieldLabel required>抬头名称</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={128}
|
maxlength={128}
|
||||||
@@ -270,7 +301,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
|
|
||||||
{draft.titleType === 'ENTERPRISE' ? (
|
{draft.titleType === 'ENTERPRISE' ? (
|
||||||
<View className="invoice-title-field">
|
<View className="invoice-title-field">
|
||||||
<Text className="invoice-title-label">税号</Text>
|
<FieldLabel required>税号</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={32}
|
maxlength={32}
|
||||||
@@ -282,11 +313,13 @@ export default function InvoiceTitlesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View className="invoice-title-field">
|
<View className="invoice-title-field">
|
||||||
<Text className="invoice-title-label">接收邮箱</Text>
|
<FieldLabel required hint="必填,电子发票将发送至此">
|
||||||
|
接收邮箱
|
||||||
|
</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={128}
|
maxlength={128}
|
||||||
placeholder="电子发票将发送至此邮箱"
|
placeholder="请填写邮箱(必填)"
|
||||||
value={draft.email || ''}
|
value={draft.email || ''}
|
||||||
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
|
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
|
||||||
/>
|
/>
|
||||||
@@ -311,7 +344,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={256}
|
maxlength={256}
|
||||||
placeholder="专用发票需要,选填"
|
placeholder="选填"
|
||||||
value={draft.addressPhone || ''}
|
value={draft.addressPhone || ''}
|
||||||
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
|
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
|
||||||
/>
|
/>
|
||||||
@@ -321,7 +354,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={256}
|
maxlength={256}
|
||||||
placeholder="专用发票需要,选填"
|
placeholder="选填"
|
||||||
value={draft.bankAccount || ''}
|
value={draft.bankAccount || ''}
|
||||||
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
|
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import PageShell from '../../components/PageShell';
|
|||||||
import TabMainHeader from '../../components/TabMainHeader';
|
import TabMainHeader from '../../components/TabMainHeader';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||||
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||||||
import {
|
import {
|
||||||
@@ -447,8 +448,7 @@ export default function MinePage() {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="mine-asset-label">好客权益余额</Text>
|
<Text className="mine-asset-label">好客权益余额</Text>
|
||||||
<View className="mine-asset-amount">
|
<View className="mine-asset-amount">
|
||||||
<Text className="mine-asset-currency">¥</Text>
|
<BenefitFigure value={formatMoney(benefitBalance)} size="lg" className="mine-asset-value" />
|
||||||
<Text className="mine-asset-value">{formatMoney(benefitBalance)}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ensurePayReady } from '../../lib/pay-ready';
|
|||||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { getProductMainImage } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
|
|
||||||
type PreviewProduct = {
|
type PreviewProduct = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,12 +32,14 @@ type OrderPreview = {
|
|||||||
quantityOk?: boolean;
|
quantityOk?: boolean;
|
||||||
quantityMessage?: string | null;
|
quantityMessage?: string | null;
|
||||||
minQty?: number;
|
minQty?: number;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OrderConfirmPickupPage() {
|
export default function OrderConfirmPickupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const productId = router.params.productId ?? '';
|
const productId = router.params.productId ?? '';
|
||||||
const [quantity, setQuantity] = useState(Math.max(2, Number(router.params.qty || 2)));
|
const skuId = router.params.skuId ?? '';
|
||||||
|
const [quantity, setQuantity] = useState(Math.max(1, Number(router.params.qty || 2)));
|
||||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -49,7 +52,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
request<OrderPreview>('/trade/orders/preview', {
|
request<OrderPreview>('/trade/orders/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { productId, quantity, onSitePickup: true },
|
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -69,15 +72,16 @@ export default function OrderConfirmPickupPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [productId, quantity]);
|
}, [productId, skuId, quantity]);
|
||||||
|
|
||||||
|
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
const minQty = preview?.minQty ?? 2;
|
const minQty = preview?.minQty ?? 2;
|
||||||
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
||||||
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
||||||
|
|
||||||
function updateQuantity(next: number) {
|
function updateQuantity(next: number) {
|
||||||
if (next < minQty) {
|
if (next < minQty) {
|
||||||
const tip = `现场提货至少购买 ${minQty} 瓶`;
|
const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
|
||||||
toast(tip);
|
toast(tip);
|
||||||
setMsg(tip);
|
setMsg(tip);
|
||||||
if (next < 1) return;
|
if (next < 1) return;
|
||||||
@@ -90,12 +94,13 @@ export default function OrderConfirmPickupPage() {
|
|||||||
async function doSubmit() {
|
async function doSubmit() {
|
||||||
const order = await request<{ id: string }>('/trade/orders', {
|
const order = await request<{ id: string }>('/trade/orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { productId, quantity, onSitePickup: true },
|
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
|
||||||
});
|
});
|
||||||
Taro.redirectTo({
|
Taro.redirectTo({
|
||||||
url: buildPayUrl({
|
url: buildPayUrl({
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
productId,
|
productId,
|
||||||
|
skuId: skuId || undefined,
|
||||||
qty: String(quantity),
|
qty: String(quantity),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -231,7 +236,11 @@ export default function OrderConfirmPickupPage() {
|
|||||||
</View>
|
</View>
|
||||||
<View className="order-row">
|
<View className="order-row">
|
||||||
<Text className="order-row-label">好客权益</Text>
|
<Text className="order-row-label">好客权益</Text>
|
||||||
<Text className="order-row-value--price">¥{Number(preview.benefitAmount).toFixed(2)}</Text>
|
<BenefitFigure
|
||||||
|
value={Number(preview.benefitAmount).toFixed(2)}
|
||||||
|
size="sm"
|
||||||
|
className="order-row-value--price"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="order-row">
|
<View className="order-row">
|
||||||
<Text className="order-row-label">运费</Text>
|
<Text className="order-row-label">运费</Text>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { fetchUserProfile } from '../../lib/pay-wechat';
|
|||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
||||||
import { getProductMainImage } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
|
|
||||||
type Address = {
|
type Address = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,6 +54,9 @@ type OrderPreview = {
|
|||||||
minQty?: number;
|
minQty?: number;
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
|
skuId?: string;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
|
bottlesPerUnit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
|
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
|
||||||
@@ -65,6 +69,7 @@ export default function OrderConfirmPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const checkoutCtx = readCheckoutContext(router.params);
|
const checkoutCtx = readCheckoutContext(router.params);
|
||||||
const productId = checkoutCtx.productId ?? '';
|
const productId = checkoutCtx.productId ?? '';
|
||||||
|
const skuId = checkoutCtx.skuId ?? '';
|
||||||
const forceCross = checkoutCtx.cross === true;
|
const forceCross = checkoutCtx.cross === true;
|
||||||
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
|
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
|
||||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||||
@@ -95,11 +100,12 @@ export default function OrderConfirmPage() {
|
|||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
const body: { productId: string; quantity: number; addressId?: string } = {
|
const body: { productId: string; quantity: number; addressId?: string; skuId?: string } = {
|
||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
};
|
};
|
||||||
if (addressId) body.addressId = addressId;
|
if (addressId) body.addressId = addressId;
|
||||||
|
if (skuId) body.skuId = skuId;
|
||||||
|
|
||||||
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
|
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -138,7 +144,7 @@ export default function OrderConfirmPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [productId, quantity, addressId]);
|
}, [productId, skuId, quantity, addressId]);
|
||||||
|
|
||||||
const selectedAddress = useMemo(
|
const selectedAddress = useMemo(
|
||||||
() => addresses.find((a) => String(a.id) === addressId),
|
() => addresses.find((a) => String(a.id) === addressId),
|
||||||
@@ -156,6 +162,7 @@ export default function OrderConfirmPage() {
|
|||||||
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
|
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
|
||||||
const crossBlocked = isCross && !allowCross;
|
const crossBlocked = isCross && !allowCross;
|
||||||
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
|
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
|
||||||
|
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
const minQty =
|
const minQty =
|
||||||
preview?.minQty ??
|
preview?.minQty ??
|
||||||
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
|
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
|
||||||
@@ -170,8 +177,8 @@ export default function OrderConfirmPage() {
|
|||||||
function updateQuantity(next: number) {
|
function updateQuantity(next: number) {
|
||||||
if (next < minQty) {
|
if (next < minQty) {
|
||||||
const tip = isCross
|
const tip = isCross
|
||||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
? `跨城配送至少购买 ${minQty}${unitLabel}`
|
||||||
: `同城配送至少购买 ${minQty} 瓶`;
|
: `同城配送至少购买 ${minQty}${unitLabel}`;
|
||||||
toast(tip);
|
toast(tip);
|
||||||
setMsg(tip);
|
setMsg(tip);
|
||||||
if (next < 1) return;
|
if (next < 1) return;
|
||||||
@@ -194,6 +201,7 @@ export default function OrderConfirmPage() {
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
addressId,
|
addressId,
|
||||||
|
...(skuId ? { skuId } : {}),
|
||||||
...(clientLocation ? { clientLocation } : {}),
|
...(clientLocation ? { clientLocation } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -201,6 +209,7 @@ export default function OrderConfirmPage() {
|
|||||||
url: buildPayUrl({
|
url: buildPayUrl({
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
productId,
|
productId,
|
||||||
|
skuId: skuId || undefined,
|
||||||
qty: String(quantity),
|
qty: String(quantity),
|
||||||
addressId,
|
addressId,
|
||||||
cross: forceCross,
|
cross: forceCross,
|
||||||
@@ -222,15 +231,15 @@ export default function OrderConfirmPage() {
|
|||||||
}
|
}
|
||||||
if (!quantityOk) {
|
if (!quantityOk) {
|
||||||
const tip = isCross
|
const tip = isCross
|
||||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
? `跨城配送至少购买 ${minQty}${unitLabel}`
|
||||||
: `同城配送至少购买 ${minQty} 瓶`;
|
: `同城配送至少购买 ${minQty}${unitLabel}`;
|
||||||
setMsg(tip);
|
setMsg(tip);
|
||||||
toast(tip);
|
toast(tip);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
|
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${skuId ? `&skuId=${skuId}` : ''}${forceCross ? '&cross=1' : ''}`;
|
||||||
|
|
||||||
if (!phonePromptSkipped.current) {
|
if (!phonePromptSkipped.current) {
|
||||||
try {
|
try {
|
||||||
@@ -370,8 +379,8 @@ export default function OrderConfirmPage() {
|
|||||||
{!quantityOk ? (
|
{!quantityOk ? (
|
||||||
<Text className="order-qty-hint">
|
<Text className="order-qty-hint">
|
||||||
{isCross
|
{isCross
|
||||||
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
|
? `跨城配送至少购买 ${minQty}${unitLabel},请调整数量`
|
||||||
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
|
: `同城配送至少购买 ${minQty}${unitLabel},请调整数量`}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -384,7 +393,11 @@ export default function OrderConfirmPage() {
|
|||||||
</View>
|
</View>
|
||||||
<View className="order-row">
|
<View className="order-row">
|
||||||
<Text className="order-row-label">好客权益</Text>
|
<Text className="order-row-label">好客权益</Text>
|
||||||
<Text className="order-row-value--price">¥{preview.benefitAmount.toFixed(2)}</Text>
|
<BenefitFigure
|
||||||
|
value={preview.benefitAmount.toFixed(2)}
|
||||||
|
size="sm"
|
||||||
|
className="order-row-value--price"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="order-row">
|
<View className="order-row">
|
||||||
<Text className="order-row-label">运费</Text>
|
<Text className="order-row-label">运费</Text>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
import '../../styles/order.css';
|
import '../../styles/order.css';
|
||||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
@@ -19,13 +19,13 @@ import { applyWechatLoginResult } from '../../lib/wechat-auth';
|
|||||||
import { isWechatEnv } from '../../lib/weixin';
|
import { isWechatEnv } from '../../lib/weixin';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
import payLogo from '../../assets/logo2.png';
|
||||||
|
|
||||||
export default function PayPage() {
|
export default function PayPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const orderId = router.params.orderId ?? '';
|
const orderId = router.params.orderId ?? '';
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
const [mockMode, setMockMode] = useState(true);
|
|
||||||
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
|
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [orderNo, setOrderNo] = useState('');
|
const [orderNo, setOrderNo] = useState('');
|
||||||
@@ -39,7 +39,6 @@ export default function PayPage() {
|
|||||||
const refreshPayReadiness = useCallback(async () => {
|
const refreshPayReadiness = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||||
setMockMode(config.mockPay);
|
|
||||||
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
|
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
|
||||||
return profile;
|
return profile;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -174,7 +173,7 @@ export default function PayPage() {
|
|||||||
<View className="sub-page-body">
|
<View className="sub-page-body">
|
||||||
<View className="pay-status">
|
<View className="pay-status">
|
||||||
<View className="pay-status-icon">
|
<View className="pay-status-icon">
|
||||||
<Text>¥</Text>
|
<Image className="pay-status-brand" src={payLogo} mode="aspectFit" />
|
||||||
</View>
|
</View>
|
||||||
<Text className="pay-status-title">
|
<Text className="pay-status-title">
|
||||||
{needsWechatAuth ? '需完成微信授权' : '待支付'}
|
{needsWechatAuth ? '需完成微信授权' : '待支付'}
|
||||||
@@ -204,12 +203,6 @@ export default function PayPage() {
|
|||||||
<Text className="order-row-label">支付方式</Text>
|
<Text className="order-row-label">支付方式</Text>
|
||||||
<Text className="order-row-value">微信支付</Text>
|
<Text className="order-row-value">微信支付</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="order-row">
|
|
||||||
<Text className="order-row-label">说明</Text>
|
|
||||||
<Text className="order-row-value">
|
|
||||||
{mockMode ? 'Mock 模式由服务端直接标记已付款' : '将调起微信收银台'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
{msg ? (
|
{msg ? (
|
||||||
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
|
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import Taro, {
|
|||||||
useShareAppMessage,
|
useShareAppMessage,
|
||||||
useShareTimeline,
|
useShareTimeline,
|
||||||
} from '@tarojs/taro';
|
} from '@tarojs/taro';
|
||||||
import type { ProductDetailContentDto } from '@dukang/shared-types';
|
import type { ProductDetailContentDto, ProductSkuDto, ProductSpecAttrDto } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import PageNavBar from '../../components/PageNavBar';
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
import ProductCarousel from '../../components/ProductCarousel';
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
import { ensurePayReady } from '../../lib/pay-ready';
|
||||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||||
@@ -46,8 +47,39 @@ type Product = ProductImageSource & {
|
|||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
|
specEnabled?: boolean;
|
||||||
|
specAttrs?: ProductSpecAttrDto[];
|
||||||
|
skus?: ProductSkuDto[];
|
||||||
|
defaultSkuId?: string;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function findSku(
|
||||||
|
skus: ProductSkuDto[],
|
||||||
|
selected: Record<string, string>,
|
||||||
|
attrs: ProductSpecAttrDto[],
|
||||||
|
): ProductSkuDto | undefined {
|
||||||
|
const valueIds = attrs.map((a) => selected[a.id]).filter(Boolean);
|
||||||
|
if (valueIds.length !== attrs.length) return undefined;
|
||||||
|
const key = [...valueIds].sort().join('_');
|
||||||
|
return skus.find((s) => [...s.specValueIds].sort().join('_') === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValueAvailable(
|
||||||
|
skus: ProductSkuDto[],
|
||||||
|
attrs: ProductSpecAttrDto[],
|
||||||
|
selected: Record<string, string>,
|
||||||
|
attrId: string,
|
||||||
|
valueId: string,
|
||||||
|
): boolean {
|
||||||
|
const trial = { ...selected, [attrId]: valueId };
|
||||||
|
const partialIds = attrs.map((a) => trial[a.id]).filter(Boolean);
|
||||||
|
return skus.some((s) => {
|
||||||
|
if (s.status !== 'ON_SALE') return false;
|
||||||
|
return partialIds.every((id) => s.specValueIds.includes(id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductDetailPage() {
|
export default function ProductDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const productId = router.params.id ?? '';
|
const productId = router.params.id ?? '';
|
||||||
@@ -57,6 +89,7 @@ export default function ProductDetailPage() {
|
|||||||
);
|
);
|
||||||
const [product, setProduct] = useState<Product | null>(null);
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
usePageScroll(({ scrollTop }) => {
|
usePageScroll(({ scrollTop }) => {
|
||||||
setHeaderSolid(scrollTop > 100);
|
setHeaderSolid(scrollTop > 100);
|
||||||
@@ -71,7 +104,25 @@ export default function ProductDetailPage() {
|
|||||||
toast('商品不存在或暂未开放');
|
toast('商品不存在或暂未开放');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setProduct(normalizeFulfillmentFlags(p));
|
const normalized = normalizeFulfillmentFlags(p);
|
||||||
|
setProduct(normalized);
|
||||||
|
const attrs = p.specAttrs ?? [];
|
||||||
|
const skus = p.skus ?? [];
|
||||||
|
const def =
|
||||||
|
skus.find((s) => s.id === p.defaultSkuId) ||
|
||||||
|
skus.find((s) => s.isDefault && s.status === 'ON_SALE') ||
|
||||||
|
skus.find((s) => s.status === 'ON_SALE') ||
|
||||||
|
skus[0];
|
||||||
|
if (def && attrs.length) {
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
for (const attr of attrs) {
|
||||||
|
const hit = attr.values.find((v) => def.specValueIds.includes(v.id));
|
||||||
|
if (hit) next[attr.id] = hit.id;
|
||||||
|
}
|
||||||
|
setSelected(next);
|
||||||
|
} else {
|
||||||
|
setSelected({});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setProduct(null);
|
setProduct(null);
|
||||||
@@ -83,20 +134,53 @@ export default function ProductDetailPage() {
|
|||||||
loadProduct();
|
loadProduct();
|
||||||
}, [loadProduct]);
|
}, [loadProduct]);
|
||||||
|
|
||||||
// 登录后返回详情须带 token 重拉,否则白名单商品会一直空白
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
loadProduct();
|
loadProduct();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const attrs = product?.specAttrs ?? [];
|
||||||
|
const skus = product?.skus ?? [];
|
||||||
|
const specEnabled = !!(product?.specEnabled && attrs.length > 0);
|
||||||
|
const activeSku = useMemo(() => {
|
||||||
|
if (!product) return undefined;
|
||||||
|
if (!specEnabled) {
|
||||||
|
return (
|
||||||
|
skus.find((s) => s.id === product.defaultSkuId) ||
|
||||||
|
skus.find((s) => s.isDefault) ||
|
||||||
|
skus.find((s) => s.status === 'ON_SALE') ||
|
||||||
|
skus[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return findSku(skus, selected, attrs);
|
||||||
|
}, [product, specEnabled, skus, selected, attrs]);
|
||||||
|
|
||||||
|
const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0);
|
||||||
|
const displayBenefit = activeSku
|
||||||
|
? Number(activeSku.benefitAmount)
|
||||||
|
: Number(product?.benefitDisplay ?? product?.benefitAmount ?? product?.price ?? 0);
|
||||||
|
const fulfillment = activeSku
|
||||||
|
? {
|
||||||
|
allowOnlinePurchase: activeSku.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: activeSku.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: activeSku.allowOnSitePickup,
|
||||||
|
}
|
||||||
|
: product;
|
||||||
|
const carouselImages = (() => {
|
||||||
|
const base = getProductCarouselImages(product);
|
||||||
|
const skuImg = activeSku?.imageUrl?.trim();
|
||||||
|
if (!skuImg) return base;
|
||||||
|
return [skuImg, ...base.filter((url) => url !== skuImg)];
|
||||||
|
})();
|
||||||
|
|
||||||
const sharePayload = useMemo(
|
const sharePayload = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildSceneSharePayload('productDetail', {
|
buildSceneSharePayload('productDetail', {
|
||||||
path: `/pages/product-detail/index?id=${productId}`,
|
path: `/pages/product-detail/index?id=${productId}`,
|
||||||
dynamicTitle: product?.name,
|
dynamicTitle: product?.name,
|
||||||
dynamicDesc: product?.subtitle,
|
dynamicDesc: product?.subtitle,
|
||||||
dynamicImageUrl: product ? getProductMainImage(product) : undefined,
|
dynamicImageUrl: (activeSku?.imageUrl?.trim() || (product ? getProductMainImage(product) : undefined)),
|
||||||
}),
|
}),
|
||||||
[product, productId],
|
[product, productId, activeSku],
|
||||||
);
|
);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
@@ -117,9 +201,23 @@ export default function ProductDetailPage() {
|
|||||||
Taro.switchTab({ url: '/pages/home/index' });
|
Taro.switchTab({ url: '/pages/home/index' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureSkuSelected(): string | null {
|
||||||
|
if (!specEnabled) return activeSku?.id ?? product?.defaultSkuId ?? null;
|
||||||
|
if (!activeSku || activeSku.status !== 'ON_SALE') {
|
||||||
|
toast('请选择完整规格');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return activeSku.id;
|
||||||
|
}
|
||||||
|
|
||||||
async function goBuy() {
|
async function goBuy() {
|
||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=2`;
|
const skuId = ensureSkuSelected();
|
||||||
|
if (specEnabled && !skuId) return;
|
||||||
|
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2;
|
||||||
|
const qs = [`productId=${productId}`, `qty=${qty}`];
|
||||||
|
if (skuId) qs.push(`skuId=${skuId}`);
|
||||||
|
const returnPath = `/pages/order-confirm/index?${qs.join('&')}`;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
goLogin(returnPath);
|
goLogin(returnPath);
|
||||||
return;
|
return;
|
||||||
@@ -131,7 +229,12 @@ export default function ProductDetailPage() {
|
|||||||
|
|
||||||
async function goOnSitePickup() {
|
async function goOnSitePickup() {
|
||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
|
const skuId = ensureSkuSelected();
|
||||||
|
if (specEnabled && !skuId) return;
|
||||||
|
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1;
|
||||||
|
const qs = [`productId=${productId}`, `qty=${qty}`];
|
||||||
|
if (skuId) qs.push(`skuId=${skuId}`);
|
||||||
|
const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
goLogin(returnPath);
|
goLogin(returnPath);
|
||||||
return;
|
return;
|
||||||
@@ -150,10 +253,8 @@ export default function ProductDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowOnline = canBuyOnline(product);
|
const allowOnline = canBuyOnline(fulfillment ?? {});
|
||||||
const allowOnSite = canPickupOnSite(product);
|
const allowOnSite = canPickupOnSite(fulfillment ?? {});
|
||||||
const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price);
|
|
||||||
const carouselImages = getProductCarouselImages(product);
|
|
||||||
const detailImages = getProductDetailImages(product);
|
const detailImages = getProductDetailImages(product);
|
||||||
const detail = product.detailContent ?? {};
|
const detail = product.detailContent ?? {};
|
||||||
const features = detail.features ?? [];
|
const features = detail.features ?? [];
|
||||||
@@ -176,12 +277,45 @@ export default function ProductDetailPage() {
|
|||||||
<View className="product-detail-info">
|
<View className="product-detail-info">
|
||||||
<View className="product-detail-price">
|
<View className="product-detail-price">
|
||||||
<Text className="product-detail-price-symbol">¥</Text>
|
<Text className="product-detail-price-symbol">¥</Text>
|
||||||
<Text className="product-detail-price-value">{Number(product.price).toFixed(2)}</Text>
|
<Text className="product-detail-price-value">{displayPrice.toFixed(2)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="product-detail-name">{product.name}</Text>
|
<Text className="product-detail-name">{product.name}</Text>
|
||||||
{product.subtitle ? (
|
{product.subtitle ? (
|
||||||
<Text className="product-detail-subtitle">{product.subtitle}</Text>
|
<Text className="product-detail-subtitle">{product.subtitle}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeSku?.specText ? (
|
||||||
|
<Text className="product-detail-subtitle">{activeSku.specText}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{specEnabled ? (
|
||||||
|
<View className="product-detail-specs">
|
||||||
|
{attrs.map((attr) => (
|
||||||
|
<View key={attr.id} className="product-detail-spec-row">
|
||||||
|
<Text className="product-detail-spec-label">{attr.name}</Text>
|
||||||
|
<View className="product-detail-spec-chips">
|
||||||
|
{attr.values.map((val) => {
|
||||||
|
const active = selected[attr.id] === val.id;
|
||||||
|
const available = isValueAvailable(skus, attrs, selected, attr.id, val.id);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={val.id}
|
||||||
|
className={`product-detail-spec-chip${active ? ' is-active' : ''}${
|
||||||
|
available ? '' : ' is-disabled'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!available) return;
|
||||||
|
setSelected((prev) => ({ ...prev, [attr.id]: val.id }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="product-detail-spec-chip-text">{val.name}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View className="product-detail-promo">
|
<View className="product-detail-promo">
|
||||||
<View className="product-detail-promo-glow" />
|
<View className="product-detail-promo-glow" />
|
||||||
@@ -189,10 +323,10 @@ export default function ProductDetailPage() {
|
|||||||
<View className="product-detail-promo-icon">
|
<View className="product-detail-promo-icon">
|
||||||
<Text className="product-detail-promo-icon-text">惠</Text>
|
<Text className="product-detail-promo-icon-text">惠</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="product-detail-promo-title">
|
<View className="product-detail-promo-title">
|
||||||
买杜康美酒 · 享全城好客礼遇
|
<Text>买杜康美酒 · 享全城好客礼遇</Text>
|
||||||
<Text className="product-detail-promo-amount"> ¥{benefit}</Text>
|
<BenefitFigure value={String(displayBenefit)} size="sm" className="product-detail-promo-amount" />
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text className="product-detail-promo-desc">
|
<Text className="product-detail-promo-desc">
|
||||||
购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
|
购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import RedeemQrCode from '../../components/RedeemQrCode';
|
|||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { formatMoney } from '../../lib/money';
|
import { formatMoney } from '../../lib/money';
|
||||||
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 2500;
|
const POLL_INTERVAL_MS = 2500;
|
||||||
const LAST_REDEEM_RECORD_KEY = 'lastRedeemRecordId';
|
const LAST_REDEEM_RECORD_KEY = 'lastRedeemRecordId';
|
||||||
@@ -154,7 +155,7 @@ export default function RedeemCodePage() {
|
|||||||
<Text className="redeem-timer-label">失效倒计时</Text>
|
<Text className="redeem-timer-label">失效倒计时</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="u-muted">待核销金额</Text>
|
<Text className="u-muted">待核销金额</Text>
|
||||||
<Text className="redeem-code-amount">¥ {formatMoney(amount)}</Text>
|
<BenefitFigure value={formatMoney(amount)} size="lg" className="redeem-code-amount" />
|
||||||
{token ? (
|
{token ? (
|
||||||
<View className="redeem-code-token-wrap" onClick={onTokenTap}>
|
<View className="redeem-code-token-wrap" onClick={onTokenTap}>
|
||||||
<Text className="redeem-code-token-label">核销码编号(供追查)</Text>
|
<Text className="redeem-code-token-label">核销码编号(供追查)</Text>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import PageShell from '../../components/PageShell';
|
|||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { formatShanghaiDateTime } from '../../lib/datetime';
|
import { formatShanghaiDateTime } from '../../lib/datetime';
|
||||||
import { formatMoney } from '../../lib/money';
|
import { formatMoney, toMoneyNumber } from '../../lib/money';
|
||||||
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
|
|
||||||
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
|
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export default function RedeemSuccessPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const amount = Number(record?.amount ?? router.params.amount ?? 0);
|
const amount = toMoneyNumber(record?.amount ?? router.params.amount);
|
||||||
const storeName = record?.storeName || '门店';
|
const storeName = record?.storeName || '门店';
|
||||||
const redeemNo = record?.redeemNo || '—';
|
const redeemNo = record?.redeemNo || '—';
|
||||||
const redeemedAt = formatChinaDateTime(record?.createdAt);
|
const redeemedAt = formatChinaDateTime(record?.createdAt);
|
||||||
@@ -119,7 +120,7 @@ export default function RedeemSuccessPage() {
|
|||||||
<Text>✓</Text>
|
<Text>✓</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="redeem-success-title">核销成功</Text>
|
<Text className="redeem-success-title">核销成功</Text>
|
||||||
<Text className="redeem-success-amount">¥ {formatMoney(amount)}</Text>
|
<BenefitFigure value={formatMoney(amount)} size="lg" className="redeem-success-amount" />
|
||||||
<Text className="redeem-success-desc">已在 {storeName} 完成核销</Text>
|
<Text className="redeem-success-desc">已在 {storeName} 完成核销</Text>
|
||||||
|
|
||||||
<View className="redeem-success-details">
|
<View className="redeem-success-details">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import SubPageHeader from '../../components/SubPageHeader';
|
|||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||||
import { formatMoney } from '../../lib/money';
|
import { formatMoney } from '../../lib/money';
|
||||||
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
|
|
||||||
type BenefitSummary = {
|
type BenefitSummary = {
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
@@ -103,7 +104,7 @@ export default function RedeemPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (value > redeemableMax) {
|
if (value > redeemableMax) {
|
||||||
toast(couponId ? '核销金额不能超过该权益可用余额' : '超出可用余额');
|
toast(couponId ? '核销金额不能超过该权益可用余额' : '核销金额不能超过可用余额');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,9 +135,11 @@ export default function RedeemPage() {
|
|||||||
<Text className="redeem-hero-label">
|
<Text className="redeem-hero-label">
|
||||||
{couponId ? '当前权益可用余额' : '可用余额'}
|
{couponId ? '当前权益可用余额' : '可用余额'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="redeem-hero-amount">
|
<BenefitFigure
|
||||||
¥{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
|
value={redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
|
||||||
</Text>
|
size="xl"
|
||||||
|
className="redeem-hero-amount"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="redeem-input-wrap">
|
<View className="redeem-input-wrap">
|
||||||
<Input
|
<Input
|
||||||
@@ -153,9 +156,10 @@ export default function RedeemPage() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="redeem-amount-foot">
|
<View className="redeem-amount-foot">
|
||||||
<Text className="redeem-amount-hint">
|
<View className="redeem-amount-hint">
|
||||||
最高可核销 ¥{formatMoney(redeemableMax)}
|
<Text>最高可核销</Text>
|
||||||
</Text>
|
<BenefitFigure value={formatMoney(redeemableMax)} size="sm" />
|
||||||
|
</View>
|
||||||
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
|
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
|
||||||
全部核销
|
全部核销
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image, ScrollView } from '@tarojs/components';
|
||||||
import '../../styles/store-detail.css';
|
import '../../styles/store-detail.css';
|
||||||
import Taro, {
|
import Taro, {
|
||||||
useDidShow,
|
useDidShow,
|
||||||
@@ -16,6 +16,7 @@ import ShareNavButton from '../../components/ShareNavButton';
|
|||||||
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
import { toMoneyNumber } from '../../lib/money';
|
||||||
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
||||||
import { track } from '../../lib/analytics';
|
import { track } from '../../lib/analytics';
|
||||||
import { formatShanghaiDateTime } from '../../lib/datetime';
|
import { formatShanghaiDateTime } from '../../lib/datetime';
|
||||||
@@ -116,9 +117,8 @@ function formatPackagePriceYuan(price: string | number) {
|
|||||||
return n.toFixed(2).replace(/\.?0+$/, '');
|
return n.toFixed(2).replace(/\.?0+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRedeemAmountYuan(amount: number | string) {
|
function formatRedeemAmountYuan(amount: unknown) {
|
||||||
const n = typeof amount === 'number' ? amount : Number(amount);
|
const n = toMoneyNumber(amount);
|
||||||
if (!Number.isFinite(n)) return '0';
|
|
||||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||||||
return n.toFixed(2).replace(/\.?0+$/, '');
|
return n.toFixed(2).replace(/\.?0+$/, '');
|
||||||
}
|
}
|
||||||
@@ -323,6 +323,8 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
const envPhotos = envPhotoUrls(store);
|
const envPhotos = envPhotoUrls(store);
|
||||||
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
|
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
|
||||||
|
/** 预览相册:封面 + 环境图(去重),页面展示仍分开 */
|
||||||
|
const previewAlbum = uniqueUrls([store.coverUrl, ...envPhotos]);
|
||||||
const packages = store.packages ?? [];
|
const packages = store.packages ?? [];
|
||||||
|
|
||||||
const intro = store.intro?.trim() || '';
|
const intro = store.intro?.trim() || '';
|
||||||
@@ -337,10 +339,12 @@ export default function StoreDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previewEnv(index: number) {
|
function previewEnv(index: number) {
|
||||||
if (!envPhotos.length) return;
|
const current = envPhotos[index];
|
||||||
|
if (!current) return;
|
||||||
|
const urls = previewAlbum.length ? previewAlbum : envPhotos;
|
||||||
Taro.previewImage({
|
Taro.previewImage({
|
||||||
current: envPhotos[index],
|
current,
|
||||||
urls: envPhotos,
|
urls,
|
||||||
}).catch(() => toast('无法预览图片'));
|
}).catch(() => toast('无法预览图片'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +366,7 @@ export default function StoreDetailPage() {
|
|||||||
variant="store"
|
variant="store"
|
||||||
previewable
|
previewable
|
||||||
imageFit="contain"
|
imageFit="contain"
|
||||||
|
previewUrls={previewAlbum}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -439,17 +444,19 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{intro ? (
|
{benefitRule ? (
|
||||||
<View className="store-detail-section">
|
<View className="store-detail-section">
|
||||||
<Text className="store-detail-section-title">门店详情</Text>
|
<Text className="store-detail-section-title store-detail-section-title--rule">使用规则</Text>
|
||||||
<Text className="store-detail-intro">{intro}</Text>
|
<Text className="store-detail-intro">{benefitRule}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{benefitRule ? (
|
{intro ? (
|
||||||
<View className="store-detail-section">
|
<View className="store-detail-section">
|
||||||
<Text className="store-detail-section-title">好客权益券使用规则</Text>
|
<Text className="store-detail-section-title">门店详情</Text>
|
||||||
<Text className="store-detail-intro">{benefitRule}</Text>
|
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
|
||||||
|
<Text className="store-detail-intro">{intro}</Text>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
.benefit-hero-amount {
|
.benefit-hero-amount {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +109,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.benefit-hero-value {
|
.benefit-hero-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -216,6 +218,10 @@
|
|||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.benefit-coupon-meta .benefit-figure {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.benefit-coupon-no {
|
.benefit-coupon-no {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -244,6 +250,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.benefit-coupon-meta {
|
.benefit-coupon-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-subtle-gray);
|
color: var(--color-subtle-gray);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
background: var(--color-aged-amber);
|
background: var(--color-aged-amber);
|
||||||
color: var(--color-on-secondary-container);
|
color: var(--color-on-secondary-container);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
|
|||||||
@@ -153,6 +153,30 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invoice-title-star {
|
||||||
|
color: #c62828;
|
||||||
|
margin-right: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-title-label-hint {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c62828;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-kind-static {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-on-surface, #1a1a1a);
|
||||||
|
line-height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--color-surface, #fafafa);
|
||||||
|
border: 1px solid var(--color-outline, #ddd);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.invoice-title-input {
|
.invoice-title-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -215,7 +215,7 @@
|
|||||||
|
|
||||||
.mine-asset-amount {
|
.mine-asset-amount {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +226,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mine-asset-value {
|
.mine-asset-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -215,6 +215,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-row-value--price {
|
.order-row-value--price {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -486,16 +488,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pay-status-icon {
|
.pay-status-icon {
|
||||||
width: 72px;
|
width: 88px;
|
||||||
height: 72px;
|
height: 88px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(166, 29, 36, 0.08);
|
background: #f7f4ee;
|
||||||
color: var(--color-heritage-red);
|
|
||||||
font-size: 36px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0 auto 16px;
|
margin: 0 auto 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-status-brand {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pay-status-title {
|
.pay-status-title {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
|
|
||||||
.product-detail-promo {
|
.product-detail-promo {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin-top: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
|
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
|
||||||
@@ -175,6 +176,10 @@
|
|||||||
|
|
||||||
.product-detail-promo-title {
|
.product-detail-promo-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -184,6 +189,7 @@
|
|||||||
|
|
||||||
.product-detail-promo-amount {
|
.product-detail-promo-amount {
|
||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-detail-promo-desc {
|
.product-detail-promo-desc {
|
||||||
@@ -370,3 +376,53 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-specs {
|
||||||
|
margin: 16px 0 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-on-surface-variant, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip.is-active {
|
||||||
|
background: rgba(166, 29, 36, 0.08);
|
||||||
|
border-color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip.is-disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip.is-active .product-detail-spec-chip-text {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.redeem-hero-amount {
|
.redeem-hero-amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -92,6 +95,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.redeem-amount-hint {
|
.redeem-amount-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-subtle-gray);
|
color: var(--color-subtle-gray);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -279,7 +286,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.redeem-code-amount {
|
.redeem-code-amount {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -350,8 +359,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.redeem-success-amount {
|
.redeem-success-amount {
|
||||||
display: block;
|
display: flex;
|
||||||
text-align: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -211,6 +211,17 @@
|
|||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-detail-section-title--rule {
|
||||||
|
color: var(--color-heritage-red, #a61d24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-intro-scroll {
|
||||||
|
max-height: 168px;
|
||||||
|
height: 168px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.store-detail-env-grid {
|
.store-detail-env-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -19,9 +19,10 @@
|
|||||||
| 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) |
|
| 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) |
|
||||||
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
||||||
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
||||||
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
|
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(限测可见+Mock旁路;测试流水计入结算)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
|
||||||
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
|
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
|
||||||
| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) |
|
| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) |
|
||||||
|
| 3.5.3 | 08-20 | 门店照片/企微审核/分享;核销金额序列化与提现补建;测试流水计结算;核销浮动提示;权益金额门店图标 | [`v3.5.3`](./杜康好客-v3.5.3-开发文档.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
|
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
|
||||||
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
|
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
|
||||||
| 近期版本 | … · v3.4.15 · **v3.4.16 联系电话/套餐 UI/合伙人列表与入驻客服门槛/HQ 门店列表表格** |
|
| 近期版本 | … · v3.4.16 · **v3.5.3 门店照片/企微审核/分享 + 核销金额/提现补修 + 权益图标** |
|
||||||
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
|
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
|
||||||
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
|
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
|
||||||
|
|
||||||
@@ -57,14 +57,14 @@
|
|||||||
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
|
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
|
||||||
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | ✅ 生产 |
|
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | ✅ 生产 |
|
||||||
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
|
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
|
||||||
|
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | 🔶 开发中 |
|
||||||
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态
|
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
|
| 2026-08-20 | v3.5.3 增补:Decimal 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
|
||||||
|
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态 |
|
||||||
|
| 2026-08-07 | v3.4.14 增补统一测试白名单(限测可见 / Mock 旁路;测试流水计入结算) |
|
||||||
| 2026-08-07 | v3.4.15 增补:HQ 门店照片替换/删除、套餐多图上限 20 |
|
| 2026-08-07 | v3.4.15 增补:HQ 门店照片替换/删除、套餐多图上限 20 |
|
||||||
|
|
||||||
| 2026-08-05 | v3.4.13 |
|
| 2026-08-05 | v3.4.13 |
|
||||||
| 2026-08-04 | v3.4.11 / v3.4.12 |
|
| 2026-08-04 | v3.4.11 / v3.4.12 |
|
||||||
| 2026-07-11 | 首版对照表 |
|
| 2026-07-11 | 首版对照表 |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
| 门店详情·套餐 | 仅完整标题纵向列表;点击进入详情 |
|
| 门店详情·套餐 | 仅完整标题纵向列表;点击进入详情 |
|
||||||
| 套餐详情页 | 实底导航让出胶囊区;内容区顶/左右留白;有图在门店名下;无图直接菜品 |
|
| 套餐详情页 | 实底导航让出胶囊区;内容区顶/左右留白;有图在门店名下;无图直接菜品 |
|
||||||
| **系统设置·微信小程序** | Logo / 资质图 / 客服电话 / C 端 H5 / Mock 验证码 **可配置**,经 `client-config` 下发 |
|
| **系统设置·微信小程序** | Logo / 资质图 / 客服电话 / C 端 H5 / Mock 验证码 **可配置**,经 `client-config` 下发 |
|
||||||
| **测试白名单** | 全局手机号名单;账号/门店/订单/核销打标;不计四条结算;合并商品/门店可见性手机号;HQ 独立管理模块 |
|
| **测试白名单** | 全局手机号名单;账号/门店/订单/核销打标(计入结算);合并商品/门店可见性手机号;HQ 独立管理模块 |
|
||||||
|
|
||||||
## 页面路由
|
## 页面路由
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 源真相 | HQ「白名单管理」维护 `common_test_whitelist_phone`;名单内手机号 = 测试账号 |
|
| 源真相 | HQ「白名单管理」维护 `common_test_whitelist_phone`;名单内手机号 = 测试账号 |
|
||||||
| 可见性 | 商品/门店 `visibilityWhitelistEnabled` 开启后,C 端仅当观众手机号 ∈ 全局名单可见/可购(不再用分实体 `*_visibility_phone`) |
|
| 可见性 | 商品/门店 `visibilityWhitelistEnabled` 开启后,C 端仅当观众手机号 ∈ 全局名单可见/可购(不再用分实体 `*_visibility_phone`) |
|
||||||
| 打标 | `User` / `StoreAccount` / `PartnerAccount` / `Store` / `Order` / `RedeemRecord` 的 `isTest` |
|
| 打标 | `User` / `StoreAccount` / `PartnerAccount` / `Store` / `Order` / `RedeemRecord` 的 `isTest`(HQ 列表可过滤;**计入结算**) |
|
||||||
| 不计账 | 测试核销不产生 `StorePayout`;酒厂/物流/合伙人账单排除 `isTest` 流水 |
|
| 结算 | 测试核销同样产生 `StorePayout`;酒厂/物流/合伙人账单**包含** `isTest` 流水 |
|
||||||
| 验证旁路 | 页顶 Checkbox ↔ `MOCK_SMS` / `MOCK_WECHAT` / `MOCK_PAY`(勾选 = 不做真实验证) |
|
| 验证旁路 | 页顶 Checkbox ↔ `MOCK_SMS` / `MOCK_WECHAT` / `MOCK_PAY`(勾选 = 不做真实验证) |
|
||||||
|
|
||||||
### API
|
### API
|
||||||
@@ -80,7 +80,7 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared
|
|||||||
- [ ] 页顶三 Checkbox 控制短信/微信/支付跳过真实验证,与 `MOCK_*` 同源立即生效
|
- [ ] 页顶三 Checkbox 控制短信/微信/支付跳过真实验证,与 `MOCK_*` 同源立即生效
|
||||||
- [ ] 业务列表「过滤测试账号」勾选后不含测试数据
|
- [ ] 业务列表「过滤测试账号」勾选后不含测试数据
|
||||||
- [ ] 旧可见性手机号已导入;限测商品/门店仅全局名单可见
|
- [ ] 旧可见性手机号已导入;限测商品/门店仅全局名单可见
|
||||||
- [ ] 测试流水不进四条账单与门店打款
|
- [ ] 测试流水进入四条账单与门店打款(与正式流水相同)
|
||||||
|
|
||||||
## HQ 开发计划
|
## HQ 开发计划
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# 杜康好客 · v3.5.3 版本更新
|
||||||
|
|
||||||
|
> **2026-08-20** · admin-web / mini-user / h5-partner / h5-shop / API
|
||||||
|
> 目标:门店封面/环境图体验、企微门店审核通知、总部抽屉内审套餐、小程序分享用业务标题主图、系统设置分享配置按场景折叠;**补修核销金额为 0 / 无法提现、测试流水计结算、核销浮动提示、好客权益金额图标**;**企微业务通知可编辑模板 + 处理快链**。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
| # | 项 | 交付 |
|
||||||
|
|---|----|------|
|
||||||
|
| 1 | 合伙人改封面/环境图 | **现状已具备**(`PUT /partner/stores/:id/media`);本版不改接口 |
|
||||||
|
| 1a | 小程序店内环境预览 | 页面内竖排展示;点击调用 `previewImage`,相册内可左右滑 |
|
||||||
|
| 1b | 录入/编辑页环境图上传 UI | 去掉独立「批量上传 / 重新上传照片」大按钮;九宫格「+」选图;编辑页「提交变更」同时提交门头照/环境图 |
|
||||||
|
| 2 | 客服改企微 | **暂不处理** |
|
||||||
|
| 3 | 门店提交审核企微通知 | `wecom_message_push` 条件 `store.audit_pending`;群名「门店审核通知群」 |
|
||||||
|
| 4 | 套餐审核同步 + 通知 | 门店详情抽屉内对比并通过/驳回;`store.package_audit_pending` 同群通知 |
|
||||||
|
| 5 | 商品/门店微信分享 | title/主图固定用业务字段(不被 HQ 场景配置覆盖) |
|
||||||
|
| 6 | 系统设置分享分组 | 「小程序分享」内按场景子组 Collapse,可展开收起 |
|
||||||
|
| 7 | 核销金额序列化 / 门店提现 | Prisma Decimal → JSON 数字;结算比例兜底;提现摘要补建缺失 `store_payout` |
|
||||||
|
| 8 | 测试流水计结算 | **取消**「测试流水不计结算」;核销一律建 payout;账单/佣金不再因 `isTest` 跳过 |
|
||||||
|
| 9 | 核销校验浮动提示 | 门店 H5 / 小程序:超可用余额等提示改为页面中上部浮动气泡 |
|
||||||
|
| 10 | 好客权益金额图标 | 小程序权益金额前去掉 ¥,改为门店核销语义图标(商品售价/实付仍用 ¥) |
|
||||||
|
| 11 | 企微业务通知扩展 | 订单支付/核销成功/信息变更/提现/发票;`wecom_push_template` 可编辑;处理快链 |
|
||||||
|
|
||||||
|
**配置进库**:`wecom_message_push` upsert「门店审核通知群 / 业务待办通知群 / 成交播报群」(无 webhook 时占位 URL + `enabled=false`)。**新表** `wecom_push_template`(发版不可 skip-db)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 封面 / 环境图
|
||||||
|
|
||||||
|
### 合伙人端(已有)
|
||||||
|
|
||||||
|
- 创建:`StoreCreatePage` 门头照 + 环境照(≥3)
|
||||||
|
- 修改:`StoreDetailPage` → `PUT /partner/stores/:id/media`
|
||||||
|
- 限制:`CLOSED` / `auditStatus=PENDING` 不可改;`REJECTED` 改照片会重提审核
|
||||||
|
|
||||||
|
### 小程序门店详情
|
||||||
|
|
||||||
|
- 顶部:仅 `coverUrl`(不混环境图)
|
||||||
|
- 「店内环境」:竖排长图列表;点击 `Taro.previewImage`,预览相册为**封面 + 环境图**(可左右滑);点封面预览同相册
|
||||||
|
|
||||||
|
### 录入/编辑页环境图 UI
|
||||||
|
|
||||||
|
- `MultiOssUploadField` 支持九宫格模式:缩略图 + 末尾「+」,去掉全宽虚线大按钮
|
||||||
|
- **已营业门店**:门头照/环境图变更并入「信息变更」审核(`coverUrl` / `envPhotoUrls`);总部「审核通知 → 信息变更」通过后才覆盖线上图;`PUT .../media` 对 APPROVED 门店已禁用
|
||||||
|
- **入驻驳回**:仍走 `PUT .../media` / `basic` 重提门店审核
|
||||||
|
- 签约合同本条不改
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 / 4. 企微「门店审核通知群」
|
||||||
|
|
||||||
|
| 条件 key | 触发 |
|
||||||
|
|----------|------|
|
||||||
|
| `store.audit_pending` | 新建门店进 PENDING;驳回后基本信息/照片重提 |
|
||||||
|
| `store.package_audit_pending` | 合伙人/门店提交套餐变更 PENDING |
|
||||||
|
|
||||||
|
- HQ `/wecom/pushes` 可改 webhook、启停、条件
|
||||||
|
- API 启动按名称 upsert 默认行;可选 env `WECOM_STORE_AUDIT_WEBHOOK_URL`
|
||||||
|
|
||||||
|
### 总部抽屉内审套餐
|
||||||
|
|
||||||
|
- 共享组件 `StorePackageAuditPanel`(对比 + 通过/驳回)
|
||||||
|
- `StoresPage` 详情抽屉「套餐」Tab 内嵌;列表「审核套餐」打开抽屉并切 Tab
|
||||||
|
- 原「审核通知」页保留
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 小程序分享
|
||||||
|
|
||||||
|
| 场景 | title | imageUrl |
|
||||||
|
|------|-------|----------|
|
||||||
|
| 商品详情 | `product.name` | `mainImageUrl` → `carouselUrls[0]` |
|
||||||
|
| 门店详情 | `store.name` | `coverUrl` → 首张环境图 |
|
||||||
|
|
||||||
|
`buildSceneSharePayload`:上述两场景 title/image 优先业务字段;其它场景仍场景配置优先。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 系统设置分享子组
|
||||||
|
|
||||||
|
`SystemConfigFieldMeta.subgroup`;`wechat_mini_share` 内层 Collapse:全局 / 首页 / 门店列表 / 门店详情 / 权益 / 我的 / 商品详情 / 订单详情。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 核销金额序列化与门店提现补修
|
||||||
|
|
||||||
|
**现象**:线上偶发核销后金额显示为 `0.00`、门店「结算提现」可用余额为 0。
|
||||||
|
|
||||||
|
| 根因 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| Prisma `Decimal` 经 `serializeBigInt` 未转成数字,前端 `Number` → `NaN` → 显示 0 | `convertForJson`:`Decimal` → `number`,`bigint` → `string`,`Date` → ISO |
|
||||||
|
| `settlementRate` 非法/为空导致结算额为 0 | `packages/domain`:`DEFAULT_STORE_SETTLEMENT_RATE = 0.6`、`resolveSettlementRate()`;核销建 payout 使用兜底比例 |
|
||||||
|
| 历史核销无 `store_payout`(含曾跳过测试流水) | `settlement.service`:提现摘要 `backfillMissingStorePayouts(storeId)`(最多补 200 条孤儿核销) |
|
||||||
|
| 前端弱网/对象形态金额 | `h5-shop` / `mini-user` `formatMoney` / `toMoneyNumber` 统一解析 |
|
||||||
|
|
||||||
|
**发版后**:门店打开一次「结算提现」即可触发补建;无需手工 SQL。
|
||||||
|
|
||||||
|
| 位置 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `common/decorators/current-user.decorator.ts` | 响应序列化 |
|
||||||
|
| `packages/domain` | `resolveSettlementRate` / `validateRedeemAmount` 拒 `NaN` |
|
||||||
|
| `redeem.service` / `settlement.service` | 兜底比例 + 补建 payout |
|
||||||
|
| `apps/h5-shop/src/lib/money.ts`、`apps/mini-user/src/lib/money.ts` | 金额展示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 测试流水计入结算(规则变更)
|
||||||
|
|
||||||
|
相对 v3.4.14「测试打标」保留,**结算口径对齐正式流水**:
|
||||||
|
|
||||||
|
- 核销:一律 `createStorePayout`(记录仍可带 `isTest` 供 HQ `excludeTest` 列表过滤)
|
||||||
|
- 提现补建:不再跳过测试门店 / `isTest` 核销
|
||||||
|
- 合伙人账单、酒厂 T+3、物流账单:不再因 `isTest` 排除或零佣金
|
||||||
|
|
||||||
|
**仍保留**:HQ 列表「过滤测试账号」;测试账号 JWT 状态旁路;待支付 30 分钟取消仍只处理非测试单(非结算链路)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 核销校验浮动提示
|
||||||
|
|
||||||
|
「核销金额不能超过可用余额」等校验/接口错误,改为页面**中上部**固定定位气泡(约 2.5~2.8s 消失),不再贴在表单底部(易滚出屏外)。
|
||||||
|
|
||||||
|
| 端 | 实现 |
|
||||||
|
|----|------|
|
||||||
|
| 门店 H5 | `ShopToastProvider` + `toastError` / `toastSuccess`;手机号核销、扫码确认、弱网兜底 |
|
||||||
|
| 小程序 | `FloatingToastHost`(挂 `PageShell`);`toast(icon≠success)` 走浮动层;成功仍用原生 `showToast` |
|
||||||
|
|
||||||
|
「门店休息中」等需持续可见的说明仍保留内联 banner。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 好客权益金额图标(mini-user)
|
||||||
|
|
||||||
|
权益额度前去掉人民币符号,改用红色圆角方块 + 白色店铺剪影图标,语义为「可到店核销」。
|
||||||
|
|
||||||
|
- 组件:`BenefitFigure`(`assets/icons/store-benefit.png`)
|
||||||
|
- 覆盖:首页角标、商品详情礼遇额、下单「好客权益」行、我的资产、权益页余额/券面、核销页/码/成功页金额
|
||||||
|
- **不改**:商品售价、订单实付、套餐价、人均价等现金口径(仍用 ¥)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 企微业务通知 + 可编辑模板 + 处理快链
|
||||||
|
|
||||||
|
### 事件条件 key
|
||||||
|
|
||||||
|
| key | 触发 | 测试单 |
|
||||||
|
|-----|------|--------|
|
||||||
|
| `order.paid` | `afterOrderPaid` | 不推 |
|
||||||
|
| `redeem.success` | `executeRedeem` 成功后 | 不推 |
|
||||||
|
| `store.audit_pending` | 入驻/重提 PENDING | 推 |
|
||||||
|
| `store.package_audit_pending` | 套餐变更 PENDING | 推 |
|
||||||
|
| `store.info_change_pending` | 信息变更 PENDING | 推 |
|
||||||
|
| `store.withdraw_pending` | 手动提现 PENDING_REVIEW | 推 |
|
||||||
|
| `invoice.pending` | 发票申请 PENDING | 不推 |
|
||||||
|
|
||||||
|
默认推送行:`业务待办通知群`、`成交播报群`(占位 webhook + 禁用,直至 HQ 配置)。
|
||||||
|
|
||||||
|
### 模板表 `wecom_push_template`
|
||||||
|
|
||||||
|
- 按 `eventKey` 全站一份;启动 `ensureTemplates` **仅插入缺行**,不覆盖 HQ 已改
|
||||||
|
- HQ「企微机器人 → 消息推送 → 通知模板」:编辑 / 恢复默认 / 示例测试推送
|
||||||
|
- API:`GET/PUT /admin/wecom-push-templates/:eventKey`,`POST .../reset`,`POST .../test`
|
||||||
|
- 渲染:`WecomMessagePushService.dispatchEvent` → `{{var}}` 插值 → 条件路由
|
||||||
|
|
||||||
|
### 处理快链
|
||||||
|
|
||||||
|
环境变量 `HQ_ADMIN_PUBLIC_URL`(staging `https://admin-test.dukanghaoke.com`,生产 `https://admin.dukanghaoke.com`)。
|
||||||
|
未配置时按 `WECOM_ALERT_ENV_LABEL` 回退(prod→生产域名,staging→测试域名,local→`http://localhost:5175`),**禁止**只发相对路径(企微会解析成 `http://orders/...`)。
|
||||||
|
|
||||||
|
| 事件 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| 订单 | `/orders?orderNo=` |
|
||||||
|
| 核销 | `/redeem-records?redeemNo=` |
|
||||||
|
| 入驻 | `/stores?auditStatus=PENDING&storeId=` |
|
||||||
|
| 套餐 | `/store-package-audits?requestId=` |
|
||||||
|
| 信息变更 | `/store-package-audits?tab=info&infoRequestId=` |
|
||||||
|
| 提现 | `/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=` |
|
||||||
|
| 发票 | `/invoices?status=PENDING&invoiceNo=` |
|
||||||
|
|
||||||
|
提现待审改走 `store.withdraw_pending`,不再误用 `AlertService` `category: finance` → `alert.system`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 合伙人可改门头/环境图(PENDING 除外)
|
||||||
|
- [ ] 录入门店环境图无独立「批量上传」大按钮,九宫格「+」可多选
|
||||||
|
- [ ] 小程序门店详情「店内环境」竖排;点开预览后可左右滑看多图
|
||||||
|
- [ ] 关自动通过后,新建/重提门店推企微;提交套餐同样推
|
||||||
|
- [ ] 门店详情抽屉可审套餐,不必跳页
|
||||||
|
- [ ] 商品/门店分享卡片为该实体名称 + 主图
|
||||||
|
- [ ] 系统设置「小程序分享」按场景可展开/收起
|
||||||
|
- [ ] 核销接口返回的 `amount` / `settleAmount` 为数字;门店记录/成功页不再显示 `0.00`(真实有额时)
|
||||||
|
- [ ] 历史无 payout 的核销:打开「结算提现」后可用余额出现;测试核销同样计结算
|
||||||
|
- [ ] 门店 H5 / 小程序超额核销提示为中上部浮动气泡
|
||||||
|
- [ ] 小程序好客权益金额前为店铺图标,商品价仍为 ¥
|
||||||
|
- [ ] HQ 可编辑企微通知模板;勾选条件 + 配置 webhook 后订单/核销/提现/发票/审核有推送
|
||||||
|
- [ ] 企微消息「去处理」可打开 HQ 对应列表并尽量定位到该单(需登录)
|
||||||
|
- [ ] 测试订单/核销不推成交播报;发票申请测试单不推
|
||||||
|
- [ ] 发版含 `wecom_push_template` 表与 `HQ_ADMIN_PUBLIC_URL`
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 杜康好客 · v3.5.4 商品规格(SPU + SKU)
|
||||||
|
|
||||||
|
> **2026-08-21** · mini-user / admin-web / h5-partner / API / Prisma
|
||||||
|
> 目标:商品详情页内可选销售规格;价格、权益、69 码、履约、上下架按 SKU;箱装按瓶当量起购与物流。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
| # | 项 | 交付 |
|
||||||
|
|---|----|------|
|
||||||
|
| 1 | 数据模型 | `common_product_item` 升级为 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` |
|
||||||
|
| 2 | 存量迁移 | 每商品 1 个默认瓶装 SKU;SQL:`prisma/migrate-product-sku-v354.sql`;脚本:`prisma/backfill-product-skus.ts` |
|
||||||
|
| 3 | Catalog | 列表附加 `specEnabled`/`saleUnit`;详情附加 `specAttrs`/`skus`/`defaultSkuId`;旧字段拍平保留 |
|
||||||
|
| 4 | Trade | preview/create 可选 `skuId`;无 SKU 回落 SPU;有可售 SKU 用默认/唯一 |
|
||||||
|
| 5 | Admin | `PUT /admin/products/:id/specs`、`PUT .../skus`;商品抽屉加宽;规格弹窗填写;每规格主图 |
|
||||||
|
| 6 | mini-user | 详情规格 chips;选规格切主图;确认页带 `skuId`;箱装数量文案 |
|
||||||
|
| 7 | 代下单 | HQ / 合伙人可选规格 SKU |
|
||||||
|
| 8 | 权益文案 | 「好客权益券」→「好客权益」;首页角标「享{amount}好客权益」(去掉门店 icon) |
|
||||||
|
| 9 | SKU 码 | 系统生成 `DK` + 6 位数字,后台不可填;每规格独立 69 码 |
|
||||||
|
| 10 | 规格主图 | `common_product_sku.image_url` 可选;空则 C 端回落商品封面 |
|
||||||
|
|
||||||
|
**不做**:库存、把现有酒祖 10/15/20 合并为一个 SPU。
|
||||||
|
|
||||||
|
## 兼容规则(线上)
|
||||||
|
|
||||||
|
- 不改 `/api/v1` 前缀;**只增字段,不删旧字段语义**。
|
||||||
|
- 旧客户端不传 `skuId`:
|
||||||
|
- 有 ≥1 个可售 SKU → 用默认(或第一个)可售 SKU
|
||||||
|
- **没有任何可售 SKU(未回填)→ 回落 SPU 字段,照常下单**(禁止再报「商品暂无可售规格」)
|
||||||
|
- 传入 `skuId`(新客户端选规格)→ 按该 SKU 校验。
|
||||||
|
- 旧 admin `PUT /admin/products/:id` 不传规格时:仅同步**唯一**默认 SKU(多规格时不同步,防误改);不同步覆盖已生成的 DK 码。
|
||||||
|
- SPU 上 `sku_code` / `barcode_69` 去掉唯一约束,唯一下沉到 `common_product_sku`;SPU 列保留为默认 SKU 冗余。
|
||||||
|
- 订单 `sku_id` 可空;无规格快照走商品 69 码 / 价格。
|
||||||
|
|
||||||
|
## 发版一并执行
|
||||||
|
|
||||||
|
按顺序在**目标环境库**执行(先测试后生产)。本机 `npx ts-node …` 只改本地 `.env` 库,不会自动打线上。
|
||||||
|
|
||||||
|
1. **规格表** `server/dukang-api/prisma/migrate-product-sku-v354.sql`
|
||||||
|
建 spec/sku 表,订单加 `sku_id` / `sale_unit` / `bottles_per_unit`。
|
||||||
|
2. **回填默认 SKU**
|
||||||
|
`cd server/dukang-api && npx ts-node prisma/backfill-product-skus.ts`
|
||||||
|
尚无 SKU 的商品各建 1 条默认瓶装 SKU(码用 DK)。
|
||||||
|
3. **SKU 主图列** `server/dukang-api/prisma/migrate-sku-image.sql`
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `common_product_sku`
|
||||||
|
ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`;
|
||||||
|
```
|
||||||
|
4. **SKU 码改 DK**(生产须在服务器用 `.env.production` 的 `DATABASE_URL`)
|
||||||
|
`npx dotenv -e .env.production -- npx ts-node prisma/rewrite-sku-codes-dk.ts`
|
||||||
|
已是 `DK`+数字的不动;`JZ-10` / `QX-001` 等会改。**不改 69 码、订单、价格。**
|
||||||
|
5. **发票品类列**(同批次)`server/dukang-api/prisma/migrate-invoice-category.sql`
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `user_invoice`
|
||||||
|
ADD COLUMN `invoice_category` VARCHAR(16) NOT NULL DEFAULT 'LIQUOR' AFTER `invoice_kind`;
|
||||||
|
```
|
||||||
|
6. 发布 API + mini-user + admin-web。
|
||||||
|
**此时不要给商品加第二规格**,等新小程序上线后再配「单瓶 / 整箱」。
|
||||||
|
|
||||||
|
## 起购与物流
|
||||||
|
|
||||||
|
- `bottleQty = quantity × bottlesPerUnit`
|
||||||
|
- 城市起购阈值仍是瓶;箱装 SKU(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6。
|
||||||
|
- 大单拦截、承运商 `goodsNum` 使用瓶当量。
|
||||||
|
|
||||||
|
## 关键表摘要
|
||||||
|
|
||||||
|
- `common_product_spec_attr` / `common_product_spec_value`
|
||||||
|
- `common_product_sku`(`sale_unit`=`BOTTLE|BOX`,`bottles_per_unit`,`image_url` 可选)
|
||||||
|
- `common_product_sku_spec`
|
||||||
|
- `user_order.sku_id` / `sale_unit` / `bottles_per_unit`
|
||||||
|
- `user_invoice.invoice_category`:`LIQUOR` 酒水类 / `CATERING` 餐饮类;票种 C 端固定增值税普通发票
|
||||||
|
|
||||||
|
## SKU 码与 69 码
|
||||||
|
|
||||||
|
- SKU 码:服务端生成 `DK000001` 起,后台只展示。
|
||||||
|
- 每个规格必须填写**互不相同**的 69 码(全局不可与其它商品 SKU 冲突)。
|
||||||
|
- 总部「规格与 SKU」点「填写」弹窗编辑 69 码 / 价 / 履约 / 主图。
|
||||||
|
|
||||||
|
## 权益与小程序 UI(同批次)
|
||||||
|
|
||||||
|
| 位置 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| 门店详情等 | 「好客权益券」→「好客权益」 |
|
||||||
|
| 首页商品角标 `CouponBadge` | 纯文案 **「享{amount}好客权益」**,无门店 icon |
|
||||||
|
| 商品详情 | 规格区与「好客权益」说明之间加大间距 |
|
||||||
|
| 待支付 / 收银台 | 顶部 logo 使用 `public/images/logo2.png`(杜康印章) |
|
||||||
|
| 门店详情 | 「使用规则」四字红色,放在「门店详情」**上面**;门店详情正文限高,超出上下滚动 |
|
||||||
|
| 发票申请 | 票种写死增值税普通发票;类型选酒水类/餐饮类;申请备注;抬头邮箱必填打 `*` |
|
||||||
|
|
||||||
|
其它页 `BenefitFigure`(我的/权益/核销等)仍保留门店核销图标,仅首页角标去 icon。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 未配规格:旧小程序/H5/代下单路径与现网一致(无 SKU 也能下单)
|
||||||
|
- [ ] 多规格:详情选规格后价格/权益/履约/主图变化
|
||||||
|
- [ ] 整箱 SKU:数量 1 过起购;订单快照 `bottles_per_unit=6`
|
||||||
|
- [ ] `GET /catalog/products/:id` 仍含 `id/name/price/spec/skuCode/...`
|
||||||
|
- [ ] 小程序无「好客权益券」字样;首页角标为「享{金额}好客权益」且无门店 icon
|
||||||
|
- [ ] 后台 SKU 码为 DK 开头且不可手填;每规格独立 69 码与主图
|
||||||
|
- [ ] 门店详情「使用规则」红色且在门店详情上方;门店详情超长可滚动
|
||||||
|
- [ ] 待支付页顶部为 logo2 印章
|
||||||
@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
calcBenefitAmount,
|
calcBenefitAmount,
|
||||||
calcRedeemSettleAmount,
|
calcRedeemSettleAmount,
|
||||||
|
resolveSettlementRate,
|
||||||
calcLogisticsFeeByBottles,
|
calcLogisticsFeeByBottles,
|
||||||
calcOrderBoxCount,
|
calcOrderBoxCount,
|
||||||
shouldHoldAutoCourierDispatch,
|
shouldHoldAutoCourierDispatch,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
|
toBottleQuantity,
|
||||||
|
toMinSaleQuantity,
|
||||||
validateBusinessHours,
|
validateBusinessHours,
|
||||||
formatBusinessHours,
|
formatBusinessHours,
|
||||||
validateRedeemAmount,
|
validateRedeemAmount,
|
||||||
@@ -46,6 +49,30 @@ describe('validateMinPurchase', () => {
|
|||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('box SKU: 1 box (6 bottles) satisfies local and cross mins', () => {
|
||||||
|
const opts = { bottlesPerUnit: 6, saleUnit: 'BOX' as const };
|
||||||
|
expect(validateMinPurchase('LOCAL', 1, 2, 6, opts).ok).toBe(true);
|
||||||
|
expect(validateMinPurchase('CROSS_CITY', 1, 2, 6, opts).ok).toBe(true);
|
||||||
|
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6, opts).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('box SKU message uses 箱', () => {
|
||||||
|
const r = validateMinPurchase('LOCAL', 0, 2, 6, { bottlesPerUnit: 6, saleUnit: 'BOX' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.message).toContain('箱');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toBottleQuantity / toMinSaleQuantity', () => {
|
||||||
|
it('converts sale qty to bottles', () => {
|
||||||
|
expect(toBottleQuantity(2, 1)).toBe(2);
|
||||||
|
expect(toBottleQuantity(1, 6)).toBe(6);
|
||||||
|
expect(toMinSaleQuantity(2, 1)).toBe(2);
|
||||||
|
expect(toMinSaleQuantity(2, 6)).toBe(1);
|
||||||
|
expect(toMinSaleQuantity(6, 6)).toBe(1);
|
||||||
|
expect(toMinSaleQuantity(7, 6)).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateBusinessHours', () => {
|
describe('validateBusinessHours', () => {
|
||||||
@@ -92,6 +119,7 @@ describe('validateRedeemAmount', () => {
|
|||||||
expect(validateRedeemAmount(100, 100).ok).toBe(true);
|
expect(validateRedeemAmount(100, 100).ok).toBe(true);
|
||||||
expect(validateRedeemAmount(50, 60).ok).toBe(false);
|
expect(validateRedeemAmount(50, 60).ok).toBe(false);
|
||||||
expect(validateRedeemAmount(100, 0).ok).toBe(false);
|
expect(validateRedeemAmount(100, 0).ok).toBe(false);
|
||||||
|
expect(validateRedeemAmount(100, Number.NaN).ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows direct redeem up to total balance without a document cap', () => {
|
it('allows direct redeem up to total balance without a document cap', () => {
|
||||||
@@ -114,6 +142,15 @@ describe('calcRedeemSettleAmount', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveSettlementRate', () => {
|
||||||
|
it('falls back to 0.6 when missing or invalid', () => {
|
||||||
|
expect(resolveSettlementRate(0.6)).toBe(0.6);
|
||||||
|
expect(resolveSettlementRate(0)).toBe(0.6);
|
||||||
|
expect(resolveSettlementRate(Number.NaN)).toBe(0.6);
|
||||||
|
expect(resolveSettlementRate(undefined)).toBe(0.6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('calcLogisticsFeeByBottles', () => {
|
describe('calcLogisticsFeeByBottles', () => {
|
||||||
const xfx = {
|
const xfx = {
|
||||||
baseBottles: 2,
|
baseBottles: 2,
|
||||||
|
|||||||
@@ -7,40 +7,85 @@ export function calcBenefitAmount(product: ProductPricing): number {
|
|||||||
return product.benefitAmount ?? product.price;
|
return product.benefitAmount ?? product.price;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProductSaleUnit = 'BOTTLE' | 'BOX';
|
||||||
|
|
||||||
|
/** 销售数量 → 瓶当量 */
|
||||||
|
export function toBottleQuantity(quantity: number, bottlesPerUnit = 1): number {
|
||||||
|
const qty = Math.floor(Number(quantity) || 0);
|
||||||
|
const per = Math.floor(Number(bottlesPerUnit) || 0);
|
||||||
|
if (qty <= 0 || per <= 0) return 0;
|
||||||
|
return qty * per;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 城市起购瓶数 → 最少销售单位数量(向上取整)
|
||||||
|
* 例:同城 2 瓶、整箱 6 瓶/箱 → minSaleQty = 1
|
||||||
|
*/
|
||||||
|
export function toMinSaleQuantity(minBottleQty: number, bottlesPerUnit = 1): number {
|
||||||
|
const minBottles = Math.floor(Number(minBottleQty) || 0);
|
||||||
|
const per = Math.floor(Number(bottlesPerUnit) || 0);
|
||||||
|
if (minBottles <= 0) return 1;
|
||||||
|
if (per <= 0) return minBottles;
|
||||||
|
return Math.max(1, Math.ceil(minBottles / per));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saleUnitLabel(saleUnit: ProductSaleUnit | string | null | undefined): string {
|
||||||
|
return saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
|
}
|
||||||
|
|
||||||
export function validateMinPurchase(
|
export function validateMinPurchase(
|
||||||
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||||||
quantity: number,
|
quantity: number,
|
||||||
localMinQty: number,
|
localMinQty: number,
|
||||||
crossMinQty: number,
|
crossMinQty: number,
|
||||||
|
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
|
||||||
): { ok: boolean; message?: string } {
|
): { ok: boolean; message?: string } {
|
||||||
|
const bottlesPerUnit = options?.bottlesPerUnit ?? 1;
|
||||||
|
const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE');
|
||||||
|
const bottleQty = toBottleQuantity(quantity, bottlesPerUnit);
|
||||||
|
const unit = saleUnitLabel(saleUnit);
|
||||||
|
|
||||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||||
const min = localMinQty > 0 ? localMinQty : 2;
|
const minBottles = localMinQty > 0 ? localMinQty : 2;
|
||||||
if (quantity < min) {
|
if (bottleQty < minBottles) {
|
||||||
return { ok: false, message: `现场提货至少购买 ${min} 瓶` };
|
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||||||
|
return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
||||||
if (quantity < min) {
|
if (bottleQty < minBottles) {
|
||||||
|
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message:
|
message:
|
||||||
deliveryType === 'LOCAL'
|
deliveryType === 'LOCAL'
|
||||||
? `同城配送至少购买 ${min} 瓶`
|
? `同城配送至少购买 ${minSale}${unit}`
|
||||||
: `跨城配送至少购买 ${min} 瓶(1箱)`,
|
: `跨城配送至少购买 ${minSale}${unit}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_STORE_SETTLEMENT_RATE = 0.6;
|
||||||
|
|
||||||
|
export function resolveSettlementRate(raw: unknown): number {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_STORE_SETTLEMENT_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateRedeemAmount(
|
export function validateRedeemAmount(
|
||||||
balance: number,
|
balance: number,
|
||||||
amount: number,
|
amount: number,
|
||||||
documentAmount?: number | null,
|
documentAmount?: number | null,
|
||||||
): { ok: boolean; message?: string } {
|
): { ok: boolean; message?: string } {
|
||||||
if (amount <= 0) return { ok: false, message: '核销金额必须大于 0' };
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
if (amount > balance) return { ok: false, message: '核销金额不能超过可用余额' };
|
return { ok: false, message: '核销金额必须大于 0' };
|
||||||
if (documentAmount != null && amount > documentAmount) {
|
}
|
||||||
|
if (!Number.isFinite(balance) || amount > balance) {
|
||||||
|
return { ok: false, message: '核销金额不能超过可用余额' };
|
||||||
|
}
|
||||||
|
if (documentAmount != null && Number.isFinite(documentAmount) && amount > documentAmount) {
|
||||||
return { ok: false, message: '核销金额不能超过该核销单可用金额' };
|
return { ok: false, message: '核销金额不能超过该核销单可用金额' };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -8,16 +8,50 @@ export interface CityDto {
|
|||||||
maxPartnerCommissionRate?: number;
|
maxPartnerCommissionRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProductSaleUnit = 'BOTTLE' | 'BOX';
|
||||||
|
|
||||||
|
export interface ProductSpecValueDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSpecAttrDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
values: ProductSpecValueDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSkuDto {
|
||||||
|
id: string;
|
||||||
|
/** 规格值 id 列表(与 specAttrs 轴顺序对应) */
|
||||||
|
specValueIds: string[];
|
||||||
|
specText: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount: number;
|
||||||
|
status: string;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
saleUnit: ProductSaleUnit;
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
/** 规格主图;无则详情页回落商品封面/轮播 */
|
||||||
|
imageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProductDto {
|
export interface ProductDto {
|
||||||
id: string;
|
id: string;
|
||||||
skuCode: string;
|
skuCode: string;
|
||||||
name: string;
|
name: string;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
|
/** 拍平价:可售 SKU 最低价 / 默认 SKU 价 */
|
||||||
price: number;
|
price: number;
|
||||||
benefitAmount: number;
|
benefitAmount: number;
|
||||||
benefitDisplay?: number;
|
benefitDisplay?: number;
|
||||||
aromaType: string;
|
aromaType: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** 规格文案(默认/展示 SKU) */
|
||||||
|
spec?: string;
|
||||||
/** 封面图(common_resource COVER / cover_resource_id) */
|
/** 封面图(common_resource COVER / cover_resource_id) */
|
||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
|
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
|
||||||
@@ -25,12 +59,21 @@ export interface ProductDto {
|
|||||||
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
||||||
detailImageUrls?: string[];
|
detailImageUrls?: string[];
|
||||||
detailContent?: ProductDetailContentDto | null;
|
detailContent?: ProductDetailContentDto | null;
|
||||||
/** 是否允许现场取货下单 */
|
/** 是否允许现场取货下单(拍平自展示 SKU) */
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
/** 是否允许配送到址(同城线上购买) */
|
/** 是否允许配送到址(同城线上购买) */
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
/** 是否允许跨城配送 */
|
/** 是否允许跨城配送 */
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
|
/** v3.5.4:是否配置了销售规格(多 SKU) */
|
||||||
|
specEnabled?: boolean;
|
||||||
|
/** 列表轻量:展示 SKU 销售单位 */
|
||||||
|
saleUnit?: ProductSaleUnit;
|
||||||
|
/** 详情:规格轴 */
|
||||||
|
specAttrs?: ProductSpecAttrDto[];
|
||||||
|
/** 详情:可售 SKU(含 OFF_SALE 供前端灰置时可带 status) */
|
||||||
|
skus?: ProductSkuDto[];
|
||||||
|
defaultSkuId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductDetailFeatureDto {
|
export interface ProductDetailFeatureDto {
|
||||||
@@ -68,3 +111,38 @@ export interface ProductDetailTemplateDto {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin 保存规格轴 */
|
||||||
|
export interface AdminProductSpecValueInput {
|
||||||
|
/** 已有值 id;新建可省略 */
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProductSpecAttrInput {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
values: AdminProductSpecValueInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProductSkuInput {
|
||||||
|
id?: string;
|
||||||
|
/** 规格值 id;无规格时为空数组 */
|
||||||
|
specValueIds?: string[];
|
||||||
|
/** 忽略;服务端自动生成 DK 码 */
|
||||||
|
skuCode?: string;
|
||||||
|
barcode69: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount?: number;
|
||||||
|
status?: string;
|
||||||
|
allowOnSitePickup?: boolean;
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
|
saleUnit?: ProductSaleUnit;
|
||||||
|
bottlesPerUnit?: number;
|
||||||
|
isDefault?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE';
|
export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE';
|
||||||
export type InvoiceKind = 'NORMAL' | 'SPECIAL';
|
export type InvoiceKind = 'NORMAL' | 'SPECIAL';
|
||||||
|
/** C 端开票品类:酒水类 / 餐饮类(票种固定增值税普通发票) */
|
||||||
|
export type InvoiceCategory = 'LIQUOR' | 'CATERING';
|
||||||
export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED';
|
export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED';
|
||||||
|
|
||||||
export const INVOICE_TITLE_TYPE_LABELS: Record<InvoiceTitleType, string> = {
|
export const INVOICE_TITLE_TYPE_LABELS: Record<InvoiceTitleType, string> = {
|
||||||
@@ -12,6 +14,11 @@ export const INVOICE_KIND_LABELS: Record<InvoiceKind, string> = {
|
|||||||
SPECIAL: '增值税专用发票',
|
SPECIAL: '增值税专用发票',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const INVOICE_CATEGORY_LABELS: Record<InvoiceCategory, string> = {
|
||||||
|
LIQUOR: '酒水类',
|
||||||
|
CATERING: '餐饮类',
|
||||||
|
};
|
||||||
|
|
||||||
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
||||||
PENDING: '待开票',
|
PENDING: '待开票',
|
||||||
ISSUED: '已开票',
|
ISSUED: '已开票',
|
||||||
@@ -21,6 +28,7 @@ export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
|||||||
export interface CreateInvoiceRequest {
|
export interface CreateInvoiceRequest {
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
taxNo?: string;
|
taxNo?: string;
|
||||||
addressPhone?: string;
|
addressPhone?: string;
|
||||||
@@ -37,6 +45,7 @@ export interface InvoiceDto {
|
|||||||
userId: string;
|
userId: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
taxNo?: string | null;
|
taxNo?: string | null;
|
||||||
addressPhone?: string | null;
|
addressPhone?: string | null;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const STORE_INFO_CHANGE_STATUS_LABELS: Record<StoreInfoChangeStatus, stri
|
|||||||
REJECTED: '已驳回',
|
REJECTED: '已驳回',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 门店基础信息可变字段白名单(提交变更 / 总部审核覆盖时使用,均为 Store 标量列) */
|
/** 门店基础信息可变字段白名单(提交变更 / 总部审核覆盖时使用) */
|
||||||
export const STORE_INFO_CHANGEABLE_FIELDS = [
|
export const STORE_INFO_CHANGEABLE_FIELDS = [
|
||||||
'name',
|
'name',
|
||||||
'contactPhone',
|
'contactPhone',
|
||||||
@@ -24,10 +24,42 @@ export const STORE_INFO_CHANGEABLE_FIELDS = [
|
|||||||
'openTime2',
|
'openTime2',
|
||||||
'closeTime2',
|
'closeTime2',
|
||||||
'avgPrice',
|
'avgPrice',
|
||||||
|
/** 门头照 URL(审核通过后写入 cover + common_resource) */
|
||||||
|
'coverUrl',
|
||||||
|
/** 环境照 URL 数组(审核通过后替换 ENV common_resource) */
|
||||||
|
'envPhotoUrls',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type StoreInfoChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
|
export type StoreInfoChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
|
||||||
|
|
||||||
|
/** 可变字段中文名(企微推送 / HQ 审核列表共用) */
|
||||||
|
export const STORE_INFO_CHANGEABLE_FIELD_LABELS: Record<StoreInfoChangeableField, string> = {
|
||||||
|
name: '门店名称',
|
||||||
|
contactPhone: '联系电话',
|
||||||
|
address: '详细地址',
|
||||||
|
intro: '门店简介',
|
||||||
|
benefitUsageRule: '权益券使用规则',
|
||||||
|
latitude: '纬度',
|
||||||
|
longitude: '经度',
|
||||||
|
openTime: '营业开始',
|
||||||
|
closeTime: '营业结束',
|
||||||
|
openTime2: '第二段开始',
|
||||||
|
closeTime2: '第二段结束',
|
||||||
|
avgPrice: '人均费用',
|
||||||
|
coverUrl: '门头照',
|
||||||
|
envPhotoUrls: '环境照片',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 将字段 key 列表格式化为中文,如「门店名称、详细地址」 */
|
||||||
|
export function formatStoreInfoChangeFieldLabels(
|
||||||
|
fields: readonly string[],
|
||||||
|
separator = '、',
|
||||||
|
): string {
|
||||||
|
return fields
|
||||||
|
.map((f) => STORE_INFO_CHANGEABLE_FIELD_LABELS[f as StoreInfoChangeableField] ?? f)
|
||||||
|
.join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
/** 单条记录的字段详情(用于审核页 diff 对比) */
|
/** 单条记录的字段详情(用于审核页 diff 对比) */
|
||||||
export interface StoreInfoChangeFieldDiff {
|
export interface StoreInfoChangeFieldDiff {
|
||||||
field: StoreInfoChangeableField;
|
field: StoreInfoChangeableField;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface SystemConfigFieldMeta {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
|
/** 组内子分组(如小程序分享按场景折叠) */
|
||||||
|
subgroup?: string;
|
||||||
type: SystemConfigFieldType;
|
type: SystemConfigFieldType;
|
||||||
secret?: boolean;
|
secret?: boolean;
|
||||||
requiresRestart: boolean;
|
requiresRestart: boolean;
|
||||||
|
|||||||
@@ -114,6 +114,22 @@ export type PartnerProxyOrderProductOption = {
|
|||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
|
specEnabled?: boolean;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
|
defaultSkuId?: string;
|
||||||
|
skus?: Array<{
|
||||||
|
id: string;
|
||||||
|
specText: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount: number;
|
||||||
|
status: string;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
saleUnit: 'BOTTLE' | 'BOX';
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartnerProxyOrderPromoOption = {
|
export type PartnerProxyOrderPromoOption = {
|
||||||
@@ -145,6 +161,7 @@ export type PartnerProxyOrderPreviewRequest = {
|
|||||||
storeId?: string;
|
storeId?: string;
|
||||||
receiverCity?: string;
|
receiverCity?: string;
|
||||||
receiverDistrict?: string;
|
receiverDistrict?: string;
|
||||||
|
skuId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartnerProxyOrderPreviewResult = {
|
export type PartnerProxyOrderPreviewResult = {
|
||||||
@@ -153,6 +170,11 @@ export type PartnerProxyOrderPreviewResult = {
|
|||||||
benefitAmount: number;
|
benefitAmount: number;
|
||||||
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
|
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
skuId?: string;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
|
bottlesPerUnit?: number;
|
||||||
|
bottleQuantity?: number;
|
||||||
|
minQuantity?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartnerProxyOrderCreateRequest = {
|
export type PartnerProxyOrderCreateRequest = {
|
||||||
@@ -169,6 +191,7 @@ export type PartnerProxyOrderCreateRequest = {
|
|||||||
productId: string;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
promoCodeId?: string;
|
promoCodeId?: string;
|
||||||
|
skuId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 合伙人代下单列表项(与 OrderDto 兼容,附带收货信息) */
|
/** 合伙人代下单列表项(与 OrderDto 兼容,附带收货信息) */
|
||||||
@@ -203,4 +226,5 @@ export type HqProxyOrderCreateRequest = {
|
|||||||
productId: string;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
promoCodeId?: string;
|
promoCodeId?: string;
|
||||||
|
skuId?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** 企微群机器人 Webhook · 推送条件(v3.4.11) */
|
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知) */
|
||||||
export const WECOM_PUSH_CONDITIONS = [
|
export const WECOM_PUSH_CONDITIONS = [
|
||||||
'alert.ops',
|
'alert.ops',
|
||||||
'support_ticket.created',
|
'support_ticket.created',
|
||||||
@@ -7,6 +7,13 @@ export const WECOM_PUSH_CONDITIONS = [
|
|||||||
'alert.system',
|
'alert.system',
|
||||||
'alert.settlement',
|
'alert.settlement',
|
||||||
'dev_plan.task_dispatch',
|
'dev_plan.task_dispatch',
|
||||||
|
'store.audit_pending',
|
||||||
|
'store.package_audit_pending',
|
||||||
|
'store.info_change_pending',
|
||||||
|
'store.withdraw_pending',
|
||||||
|
'order.paid',
|
||||||
|
'redeem.success',
|
||||||
|
'invoice.pending',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
|
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
|
||||||
@@ -19,6 +26,13 @@ export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
|
|||||||
'alert.system': '系统监控(5xx、回调、定时任务)',
|
'alert.system': '系统监控(5xx、回调、定时任务)',
|
||||||
'alert.settlement': '结算任务告警',
|
'alert.settlement': '结算任务告警',
|
||||||
'dev_plan.task_dispatch': '开发任务评审派发',
|
'dev_plan.task_dispatch': '开发任务评审派发',
|
||||||
|
'store.audit_pending': '门店提交/重提待审',
|
||||||
|
'store.package_audit_pending': '套餐变更待审',
|
||||||
|
'store.info_change_pending': '门店信息变更待审',
|
||||||
|
'store.withdraw_pending': '门店手动提现待审',
|
||||||
|
'order.paid': '订单支付成功',
|
||||||
|
'redeem.success': '门店核销成功',
|
||||||
|
'invoice.pending': '发票申请待开票',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WECOM_PUSH_CONDITION_GROUPS: Array<{
|
export const WECOM_PUSH_CONDITION_GROUPS: Array<{
|
||||||
@@ -33,9 +47,25 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pay_redeem',
|
key: 'pay_redeem',
|
||||||
label: '支付与核销',
|
label: '支付与核销异常',
|
||||||
conditions: ['alert.pay', 'alert.redeem'],
|
conditions: ['alert.pay', 'alert.redeem'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'deal_broadcast',
|
||||||
|
label: '成交播报',
|
||||||
|
conditions: ['order.paid', 'redeem.success'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'biz_todo',
|
||||||
|
label: '业务待办',
|
||||||
|
conditions: [
|
||||||
|
'store.audit_pending',
|
||||||
|
'store.package_audit_pending',
|
||||||
|
'store.info_change_pending',
|
||||||
|
'store.withdraw_pending',
|
||||||
|
'invoice.pending',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'system',
|
key: 'system',
|
||||||
label: '系统与结算',
|
label: '系统与结算',
|
||||||
@@ -64,6 +94,120 @@ export const WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS: WecomPushCondition[] =
|
|||||||
'support_ticket.created',
|
'support_ticket.created',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 默认「门店审核通知群」推送条件 */
|
||||||
|
export const WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS: WecomPushCondition[] = [
|
||||||
|
'store.audit_pending',
|
||||||
|
'store.package_audit_pending',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 默认「业务待办通知群」 */
|
||||||
|
export const WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS: WecomPushCondition[] = [
|
||||||
|
'store.audit_pending',
|
||||||
|
'store.package_audit_pending',
|
||||||
|
'store.info_change_pending',
|
||||||
|
'store.withdraw_pending',
|
||||||
|
'invoice.pending',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 默认「成交播报群」 */
|
||||||
|
export const WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS: WecomPushCondition[] = [
|
||||||
|
'order.paid',
|
||||||
|
'redeem.success',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WECOM_STORE_AUDIT_PUSH_NAME = '门店审核通知群';
|
||||||
|
export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群';
|
||||||
|
export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群';
|
||||||
|
|
||||||
|
/** 占位 webhook(未配置真实 key 时禁用,避免误推) */
|
||||||
|
export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK =
|
||||||
|
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=PENDING';
|
||||||
|
|
||||||
|
/** 有 HQ 可编辑模板的业务事件(含入驻/套餐,不含 alert.*) */
|
||||||
|
export const WECOM_TEMPLATE_EVENT_KEYS = [
|
||||||
|
'order.paid',
|
||||||
|
'redeem.success',
|
||||||
|
'store.audit_pending',
|
||||||
|
'store.package_audit_pending',
|
||||||
|
'store.info_change_pending',
|
||||||
|
'store.withdraw_pending',
|
||||||
|
'invoice.pending',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number];
|
||||||
|
|
||||||
|
export const WECOM_TEMPLATE_EVENT_LABELS: Record<WecomTemplateEventKey, string> = {
|
||||||
|
'order.paid': '订单支付成功',
|
||||||
|
'redeem.success': '门店核销成功',
|
||||||
|
'store.audit_pending': '门店提交/重提待审',
|
||||||
|
'store.package_audit_pending': '套餐变更待审',
|
||||||
|
'store.info_change_pending': '门店信息变更待审',
|
||||||
|
'store.withdraw_pending': '门店手动提现待审',
|
||||||
|
'invoice.pending': '发票申请待开票',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 各事件可用占位符说明(HQ 模板编辑) */
|
||||||
|
export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]> = {
|
||||||
|
'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'],
|
||||||
|
'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'],
|
||||||
|
'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'],
|
||||||
|
'store.package_audit_pending': [
|
||||||
|
'storeName',
|
||||||
|
'cityName',
|
||||||
|
'submitter',
|
||||||
|
'packageCount',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
'requestId',
|
||||||
|
],
|
||||||
|
'store.info_change_pending': [
|
||||||
|
'storeName',
|
||||||
|
'cityName',
|
||||||
|
'submitter',
|
||||||
|
'changedFields',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
'requestId',
|
||||||
|
],
|
||||||
|
'store.withdraw_pending': [
|
||||||
|
'storeName',
|
||||||
|
'withdrawNo',
|
||||||
|
'amount',
|
||||||
|
'payoutCount',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
'storeId',
|
||||||
|
],
|
||||||
|
'invoice.pending': [
|
||||||
|
'invoiceNo',
|
||||||
|
'orderNo',
|
||||||
|
'payAmount',
|
||||||
|
'titleName',
|
||||||
|
'phoneMasked',
|
||||||
|
'time',
|
||||||
|
'handleUrl',
|
||||||
|
'handleLabel',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WecomPushTemplateDto = {
|
||||||
|
id: string;
|
||||||
|
eventKey: WecomTemplateEventKey;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
handleLabel: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateWecomPushTemplateRequest = {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
handleLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function parseWecomPushConditions(
|
export function parseWecomPushConditions(
|
||||||
raw?: string | string[] | null,
|
raw?: string | string[] | null,
|
||||||
): WecomPushCondition[] {
|
): WecomPushCondition[] {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 921 KiB |
@@ -69,6 +69,12 @@ WECOM_AIBOT_ENABLED=false
|
|||||||
# WECOM_ALERT_ENABLED=false
|
# WECOM_ALERT_ENABLED=false
|
||||||
# WECOM_ALERT_WEBHOOK_URL=
|
# WECOM_ALERT_WEBHOOK_URL=
|
||||||
WECOM_ALERT_ENV_LABEL=local
|
WECOM_ALERT_ENV_LABEL=local
|
||||||
|
# HQ 企微业务通知「去处理」快链根地址(无尾斜杠;未配时按 WECOM_ALERT_ENV_LABEL 回退生产/测试/本机)
|
||||||
|
# HQ_ADMIN_PUBLIC_URL=http://localhost:5175
|
||||||
|
# 生产请设:https://admin.dukanghaoke.com ;测试:https://admin-test.dukanghaoke.com
|
||||||
|
# WECOM_STORE_AUDIT_WEBHOOK_URL=
|
||||||
|
# WECOM_BIZ_TODO_WEBHOOK_URL=
|
||||||
|
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
|
||||||
|
|
||||||
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
|
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
|
||||||
# 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
|
# 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false
|
|||||||
# WECOM_ALERT_ENABLED=false
|
# WECOM_ALERT_ENABLED=false
|
||||||
# WECOM_ALERT_WEBHOOK_URL=
|
# WECOM_ALERT_WEBHOOK_URL=
|
||||||
WECOM_ALERT_ENV_LABEL=production
|
WECOM_ALERT_ENV_LABEL=production
|
||||||
|
# HQ 企微业务通知「去处理」快链根地址
|
||||||
|
HQ_ADMIN_PUBLIC_URL=https://admin.dukanghaoke.com
|
||||||
|
# WECOM_STORE_AUDIT_WEBHOOK_URL=
|
||||||
|
# WECOM_BIZ_TODO_WEBHOOK_URL=
|
||||||
|
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
|
||||||
|
|
||||||
OSS_ACCESS_KEY_ID=
|
OSS_ACCESS_KEY_ID=
|
||||||
OSS_ACCESS_KEY_SECRET=
|
OSS_ACCESS_KEY_SECRET=
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false
|
|||||||
# WECOM_ALERT_ENABLED=false
|
# WECOM_ALERT_ENABLED=false
|
||||||
# WECOM_ALERT_WEBHOOK_URL=
|
# WECOM_ALERT_WEBHOOK_URL=
|
||||||
WECOM_ALERT_ENV_LABEL=staging
|
WECOM_ALERT_ENV_LABEL=staging
|
||||||
|
# HQ 企微业务通知「去处理」快链根地址
|
||||||
|
HQ_ADMIN_PUBLIC_URL=https://admin-test.dukanghaoke.com
|
||||||
|
# WECOM_STORE_AUDIT_WEBHOOK_URL=
|
||||||
|
# WECOM_BIZ_TODO_WEBHOOK_URL=
|
||||||
|
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
|
||||||
|
|
||||||
OSS_ACCESS_KEY_ID=
|
OSS_ACCESS_KEY_ID=
|
||||||
OSS_ACCESS_KEY_SECRET=
|
OSS_ACCESS_KEY_SECRET=
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* v3.5.4:为尚无 SKU 的商品回填默认 SKU(与 migrate-product-sku-v354.sql 一致)
|
||||||
|
* 用法:cd server/dukang-api && npx ts-node prisma/backfill-product-skus.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const PREFIX = 'DK';
|
||||||
|
const PAD = 6;
|
||||||
|
const RE = /^DK(\d+)$/;
|
||||||
|
|
||||||
|
async function nextDkCode() {
|
||||||
|
const [items, skus] = await Promise.all([
|
||||||
|
prisma.commonProductItem.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
prisma.commonProductSku.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let maxSeq = 0;
|
||||||
|
for (const row of [...items, ...skus]) {
|
||||||
|
const m = RE.exec(row.skuCode);
|
||||||
|
if (!m) continue;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
||||||
|
}
|
||||||
|
return `${PREFIX}${String(maxSeq + 1).padStart(PAD, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const products = await prisma.commonProductItem.findMany({
|
||||||
|
include: { skus: { select: { id: true }, take: 1 } },
|
||||||
|
});
|
||||||
|
let created = 0;
|
||||||
|
for (const p of products) {
|
||||||
|
if (p.skus.length > 0) continue;
|
||||||
|
const skuCode = RE.test(p.skuCode) ? p.skuCode : await nextDkCode();
|
||||||
|
await prisma.commonProductSku.create({
|
||||||
|
data: {
|
||||||
|
productId: p.id,
|
||||||
|
skuCode,
|
||||||
|
barcode69: p.barcode69,
|
||||||
|
specKey: '',
|
||||||
|
specText: p.spec,
|
||||||
|
price: p.price,
|
||||||
|
benefitAmount: p.benefitAmount,
|
||||||
|
status: p.status,
|
||||||
|
allowOnSitePickup: p.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: p.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: p.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (p.skuCode !== skuCode) {
|
||||||
|
await prisma.commonProductItem.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { skuCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
created += 1;
|
||||||
|
console.log(`created default sku for product ${p.id} ${skuCode}`);
|
||||||
|
}
|
||||||
|
console.log(`done, created=${created}, scanned=${products.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 发票开票品类:酒水类 / 餐饮类(票种仍为增值税普通发票)
|
||||||
|
-- 存量申请默认酒水类,与 Prisma InvoiceCategory 默认值一致
|
||||||
|
|
||||||
|
ALTER TABLE `user_invoice`
|
||||||
|
ADD COLUMN `invoice_category` VARCHAR(16) NOT NULL DEFAULT 'LIQUOR' AFTER `invoice_kind`;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- v3.5.4 商品规格:SPU + SKU
|
||||||
|
-- 1) 新建规格/SKU 表 2) 订单快照列 3) SPU 去掉 sku/barcode 唯一 4) 存量默认 SKU 回填
|
||||||
|
|
||||||
|
-- 销售单位枚举(Prisma 用字符串枚举映射;MySQL 用 VARCHAR,此处仅建表)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS common_product_spec_attr (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(32) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_spec_attr_product (product_id, sort_order),
|
||||||
|
CONSTRAINT fk_spec_attr_product FOREIGN KEY (product_id) REFERENCES common_product_item (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS common_product_spec_value (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
attr_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_spec_value_attr (attr_id, sort_order),
|
||||||
|
CONSTRAINT fk_spec_value_attr FOREIGN KEY (attr_id) REFERENCES common_product_spec_attr (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS common_product_sku (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
sku_code VARCHAR(32) NOT NULL,
|
||||||
|
barcode_69 VARCHAR(32) NOT NULL,
|
||||||
|
spec_key VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
spec_text VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
price DECIMAL(10, 2) NOT NULL,
|
||||||
|
benefit_amount DECIMAL(10, 2) NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
allow_on_site_pickup TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
allow_online_purchase TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
allow_cross_city_delivery TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
sale_unit VARCHAR(16) NOT NULL DEFAULT 'BOTTLE',
|
||||||
|
bottles_per_unit INT NOT NULL DEFAULT 1,
|
||||||
|
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_product_sku_code (sku_code),
|
||||||
|
UNIQUE KEY uk_product_sku_barcode (barcode_69),
|
||||||
|
UNIQUE KEY uk_product_sku_spec_key (product_id, spec_key),
|
||||||
|
KEY idx_product_sku_status (product_id, status),
|
||||||
|
KEY idx_product_sku_default (product_id, is_default),
|
||||||
|
CONSTRAINT fk_product_sku_product FOREIGN KEY (product_id) REFERENCES common_product_item (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS common_product_sku_spec (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
sku_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
value_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_sku_spec (sku_id, value_id),
|
||||||
|
KEY idx_sku_spec_value (value_id),
|
||||||
|
CONSTRAINT fk_sku_spec_sku FOREIGN KEY (sku_id) REFERENCES common_product_sku (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_sku_spec_value FOREIGN KEY (value_id) REFERENCES common_product_spec_value (id) ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 订单快照列
|
||||||
|
ALTER TABLE user_order
|
||||||
|
ADD COLUMN sku_id BIGINT UNSIGNED NULL AFTER product_id,
|
||||||
|
ADD COLUMN sale_unit VARCHAR(16) NOT NULL DEFAULT 'BOTTLE' AFTER quantity,
|
||||||
|
ADD COLUMN bottles_per_unit INT NOT NULL DEFAULT 1 AFTER sale_unit;
|
||||||
|
|
||||||
|
ALTER TABLE user_order
|
||||||
|
ADD KEY idx_order_sku_id (sku_id);
|
||||||
|
|
||||||
|
-- SPU:去掉 sku_code / barcode_69 唯一(保留普通索引);若索引名不同可手工调整
|
||||||
|
ALTER TABLE common_product_item DROP INDEX sku_code;
|
||||||
|
ALTER TABLE common_product_item DROP INDEX barcode_69;
|
||||||
|
ALTER TABLE common_product_item ADD INDEX idx_product_item_sku_code (sku_code);
|
||||||
|
ALTER TABLE common_product_item ADD INDEX idx_product_item_barcode_69 (barcode_69);
|
||||||
|
|
||||||
|
-- 存量商品回填默认 SKU(无规格,瓶装)
|
||||||
|
INSERT INTO common_product_sku (
|
||||||
|
product_id, sku_code, barcode_69, spec_key, spec_text, price, benefit_amount, status,
|
||||||
|
allow_on_site_pickup, allow_online_purchase, allow_cross_city_delivery,
|
||||||
|
sale_unit, bottles_per_unit, is_default, sort_order, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.id, p.sku_code, p.barcode_69, '', COALESCE(p.spec, ''), p.price, p.benefit_amount, p.status,
|
||||||
|
p.allow_on_site_pickup, p.allow_online_purchase, p.allow_cross_city_delivery,
|
||||||
|
'BOTTLE', 1, 1, 0, NOW(3), NOW(3)
|
||||||
|
FROM common_product_item p
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM common_product_sku s WHERE s.product_id = p.id
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- SKU 规格主图(可选;空则 C 端回落商品封面)
|
||||||
|
ALTER TABLE `common_product_sku`
|
||||||
|
ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 将存量商品 / SKU 的 sku_code 全部改成 DK + 6 位数字。
|
||||||
|
* 已是 DK 数字码的保持不变;其余先写临时码再分配,避免唯一约束冲突。
|
||||||
|
*
|
||||||
|
* 用法:cd server/dukang-api && npx ts-node prisma/rewrite-sku-codes-dk.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const PREFIX = 'DK';
|
||||||
|
const PAD = 6;
|
||||||
|
const RE = /^DK(\d+)$/;
|
||||||
|
|
||||||
|
function isDk(code: string) {
|
||||||
|
return RE.test(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCode(seq: number) {
|
||||||
|
return `${PREFIX}${String(seq).padStart(PAD, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maxExistingSeq(): Promise<number> {
|
||||||
|
const [items, skus] = await Promise.all([
|
||||||
|
prisma.commonProductItem.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
prisma.commonProductSku.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let maxSeq = 0;
|
||||||
|
for (const row of [...items, ...skus]) {
|
||||||
|
const m = RE.exec(row.skuCode);
|
||||||
|
if (!m) continue;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
||||||
|
}
|
||||||
|
return maxSeq;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const skus = await prisma.commonProductSku.findMany({
|
||||||
|
orderBy: [{ productId: 'asc' }, { id: 'asc' }],
|
||||||
|
select: { id: true, productId: true, skuCode: true, isDefault: true },
|
||||||
|
});
|
||||||
|
const products = await prisma.commonProductItem.findMany({
|
||||||
|
select: { id: true, skuCode: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const skuNeed = skus.filter((s) => !isDk(s.skuCode));
|
||||||
|
const productNeed = products.filter((p) => !isDk(p.skuCode));
|
||||||
|
console.log(`sku total=${skus.length} rewrite=${skuNeed.length}`);
|
||||||
|
console.log(`product total=${products.length} rewrite=${productNeed.length}`);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const s of skuNeed) {
|
||||||
|
await tx.commonProductSku.update({
|
||||||
|
where: { id: s.id },
|
||||||
|
data: { skuCode: `TMP${s.id.toString()}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const p of productNeed) {
|
||||||
|
await tx.commonProductItem.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { skuCode: `TMPP${p.id.toString()}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seqStart = await (async () => {
|
||||||
|
const [items, skuRows] = await Promise.all([
|
||||||
|
tx.commonProductItem.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
tx.commonProductSku.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let maxSeq = 0;
|
||||||
|
for (const row of [...items, ...skuRows]) {
|
||||||
|
const m = RE.exec(row.skuCode);
|
||||||
|
if (!m) continue;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
||||||
|
}
|
||||||
|
return maxSeq;
|
||||||
|
})();
|
||||||
|
|
||||||
|
let seq = seqStart;
|
||||||
|
const defaultSkuCodeByProduct = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const s of skuNeed) {
|
||||||
|
seq += 1;
|
||||||
|
const code = formatCode(seq);
|
||||||
|
await tx.commonProductSku.update({
|
||||||
|
where: { id: s.id },
|
||||||
|
data: { skuCode: code },
|
||||||
|
});
|
||||||
|
if (s.isDefault) defaultSkuCodeByProduct.set(s.productId.toString(), code);
|
||||||
|
console.log(`sku ${s.id} ${s.skuCode} -> ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of productNeed) {
|
||||||
|
const fromDefault = defaultSkuCodeByProduct.get(p.id.toString());
|
||||||
|
let code = fromDefault;
|
||||||
|
if (!code) {
|
||||||
|
seq += 1;
|
||||||
|
code = formatCode(seq);
|
||||||
|
}
|
||||||
|
await tx.commonProductItem.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { skuCode: code },
|
||||||
|
});
|
||||||
|
console.log(`product ${p.id} ${p.skuCode} -> ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stillDefault = await tx.commonProductSku.findMany({
|
||||||
|
where: { isDefault: true },
|
||||||
|
select: { productId: true, skuCode: true },
|
||||||
|
});
|
||||||
|
for (const s of stillDefault) {
|
||||||
|
if (!isDk(s.skuCode)) continue;
|
||||||
|
await tx.commonProductItem.updateMany({
|
||||||
|
where: { id: s.productId, NOT: { skuCode: s.skuCode } },
|
||||||
|
data: { skuCode: s.skuCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftoverSku = await prisma.commonProductSku.count({
|
||||||
|
where: { NOT: { skuCode: { startsWith: PREFIX } } },
|
||||||
|
});
|
||||||
|
const leftoverProduct = await prisma.commonProductItem.count({
|
||||||
|
where: { NOT: { skuCode: { startsWith: PREFIX } } },
|
||||||
|
});
|
||||||
|
console.log(`done leftoverSku=${leftoverSku} leftoverProduct=${leftoverProduct} maxWas=${await maxExistingSeq()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -146,6 +146,12 @@ enum InvoiceKind {
|
|||||||
SPECIAL
|
SPECIAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// C 端开票品类(增值税票种固定普通发票)
|
||||||
|
enum InvoiceCategory {
|
||||||
|
LIQUOR
|
||||||
|
CATERING
|
||||||
|
}
|
||||||
|
|
||||||
enum InvoiceStatus {
|
enum InvoiceStatus {
|
||||||
PENDING
|
PENDING
|
||||||
ISSUED
|
ISSUED
|
||||||
@@ -164,6 +170,12 @@ enum ProductStatus {
|
|||||||
OFF_SALE
|
OFF_SALE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SKU 销售单位:瓶 / 箱(起购与物流按瓶当量)
|
||||||
|
enum ProductSaleUnit {
|
||||||
|
BOTTLE
|
||||||
|
BOX
|
||||||
|
}
|
||||||
|
|
||||||
enum DetailTemplateStatus {
|
enum DetailTemplateStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
DISABLED
|
DISABLED
|
||||||
@@ -504,6 +516,19 @@ model WecomMessagePush {
|
|||||||
@@map("wecom_message_push")
|
@@map("wecom_message_push")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 企微业务通知文案模板(v3.5.3 · 按事件全站一份,HQ 可编辑)
|
||||||
|
model WecomPushTemplate {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
eventKey String @unique @map("event_key") @db.VarChar(64)
|
||||||
|
title String @db.VarChar(128)
|
||||||
|
body String @db.Text
|
||||||
|
handleLabel String @default("去处理") @map("handle_label") @db.VarChar(32)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
@@map("wecom_push_template")
|
||||||
|
}
|
||||||
|
|
||||||
/// HQ 语言模型 API 配置(非超管仅可见/可开关自己创建的)
|
/// HQ 语言模型 API 配置(非超管仅可见/可开关自己创建的)
|
||||||
model LlmApiConfig {
|
model LlmApiConfig {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
@@ -778,10 +803,13 @@ model DevPlanTaskDispatch {
|
|||||||
@@map("dev_plan_task_dispatch")
|
@@map("dev_plan_task_dispatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 商品 SPU(详情页实体);价格/码/履约等列为默认 SKU 冗余,供旧客户端拍平读取
|
||||||
model CommonProductItem {
|
model CommonProductItem {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
skuCode String @unique @map("sku_code") @db.VarChar(32)
|
/// 默认 SKU 冗余;唯一约束已下放到 common_product_sku
|
||||||
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
|
skuCode String @map("sku_code") @db.VarChar(32)
|
||||||
|
/// 默认 SKU 冗余;唯一约束已下放到 common_product_sku
|
||||||
|
barcode69 String @map("barcode_69") @db.VarChar(32)
|
||||||
name String @db.VarChar(128)
|
name String @db.VarChar(128)
|
||||||
subtitle String? @db.VarChar(256)
|
subtitle String? @db.VarChar(256)
|
||||||
aromaType AromaType @map("aroma_type")
|
aromaType AromaType @map("aroma_type")
|
||||||
@@ -805,11 +833,96 @@ model CommonProductItem {
|
|||||||
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
||||||
orders Order[]
|
orders Order[]
|
||||||
visibilityPhones CommonProductVisibilityPhone[]
|
visibilityPhones CommonProductVisibilityPhone[]
|
||||||
|
specAttrs CommonProductSpecAttr[]
|
||||||
|
skus CommonProductSku[]
|
||||||
|
|
||||||
|
@@index([skuCode])
|
||||||
|
@@index([barcode69])
|
||||||
@@index([status, aromaType])
|
@@index([status, aromaType])
|
||||||
@@map("common_product_item")
|
@@map("common_product_item")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 销售规格轴(如「包装」)
|
||||||
|
model CommonProductSpecAttr {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
productId BigInt @map("product_id") @db.UnsignedBigInt
|
||||||
|
name String @db.VarChar(32)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
values CommonProductSpecValue[]
|
||||||
|
|
||||||
|
@@index([productId, sortOrder])
|
||||||
|
@@map("common_product_spec_attr")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规格值(如「单瓶」「整箱6瓶」)
|
||||||
|
model CommonProductSpecValue {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
attrId BigInt @map("attr_id") @db.UnsignedBigInt
|
||||||
|
name String @db.VarChar(64)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
attr CommonProductSpecAttr @relation(fields: [attrId], references: [id], onDelete: Cascade)
|
||||||
|
skuSpecs CommonProductSkuSpec[]
|
||||||
|
|
||||||
|
@@index([attrId, sortOrder])
|
||||||
|
@@map("common_product_spec_value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可售 SKU(规格组合)
|
||||||
|
model CommonProductSku {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
productId BigInt @map("product_id") @db.UnsignedBigInt
|
||||||
|
skuCode String @unique @map("sku_code") @db.VarChar(32)
|
||||||
|
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
|
||||||
|
/// 规格值 id 按 attr.sortOrder 拼接,无规格为 ""
|
||||||
|
specKey String @default("") @map("spec_key") @db.VarChar(128)
|
||||||
|
specText String @default("") @map("spec_text") @db.VarChar(128)
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
|
||||||
|
status ProductStatus @default(DRAFT)
|
||||||
|
allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup")
|
||||||
|
allowOnlinePurchase Boolean @default(true) @map("allow_online_purchase")
|
||||||
|
allowCrossCityDelivery Boolean @default(true) @map("allow_cross_city_delivery")
|
||||||
|
saleUnit ProductSaleUnit @default(BOTTLE) @map("sale_unit")
|
||||||
|
/// 每销售单位对应瓶数(瓶=1,箱默认=6)
|
||||||
|
bottlesPerUnit Int @default(1) @map("bottles_per_unit")
|
||||||
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
/// 规格主图;空则回落商品封面
|
||||||
|
imageUrl String? @map("image_url") @db.VarChar(512)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
skuSpecs CommonProductSkuSpec[]
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
|
@@unique([productId, specKey])
|
||||||
|
@@index([productId, status])
|
||||||
|
@@index([productId, isDefault])
|
||||||
|
@@map("common_product_sku")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SKU ↔ 规格值
|
||||||
|
model CommonProductSkuSpec {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
skuId BigInt @map("sku_id") @db.UnsignedBigInt
|
||||||
|
valueId BigInt @map("value_id") @db.UnsignedBigInt
|
||||||
|
|
||||||
|
sku CommonProductSku @relation(fields: [skuId], references: [id], onDelete: Cascade)
|
||||||
|
value CommonProductSpecValue @relation(fields: [valueId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@unique([skuId, valueId])
|
||||||
|
@@index([valueId])
|
||||||
|
@@map("common_product_sku_spec")
|
||||||
|
}
|
||||||
|
|
||||||
/// Product visibility whitelist phones (match by bound phone)
|
/// Product visibility whitelist phones (match by bound phone)
|
||||||
model CommonProductVisibilityPhone {
|
model CommonProductVisibilityPhone {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
@@ -1482,11 +1595,16 @@ model Order {
|
|||||||
promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
|
promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
|
||||||
channelSource String? @map("channel_source") @db.VarChar(128)
|
channelSource String? @map("channel_source") @db.VarChar(128)
|
||||||
productId BigInt @map("product_id") @db.UnsignedBigInt
|
productId BigInt @map("product_id") @db.UnsignedBigInt
|
||||||
|
/// v3.5.4 起可空;历史单无 sku 快照时仍用 product 冗余字段展示
|
||||||
|
skuId BigInt? @map("sku_id") @db.UnsignedBigInt
|
||||||
barcode69 String @map("barcode_69") @db.VarChar(32)
|
barcode69 String @map("barcode_69") @db.VarChar(32)
|
||||||
productName String @map("product_name") @db.VarChar(128)
|
productName String @map("product_name") @db.VarChar(128)
|
||||||
productSpec String @map("product_spec") @db.VarChar(128)
|
productSpec String @map("product_spec") @db.VarChar(128)
|
||||||
imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
|
imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
|
||||||
|
/// 下单数量(销售单位:瓶或箱)
|
||||||
quantity Int
|
quantity Int
|
||||||
|
saleUnit ProductSaleUnit @default(BOTTLE) @map("sale_unit")
|
||||||
|
bottlesPerUnit Int @default(1) @map("bottles_per_unit")
|
||||||
listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
|
listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
|
||||||
listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
|
listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
|
||||||
discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
|
discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
|
||||||
@@ -1540,6 +1658,7 @@ model Order {
|
|||||||
reshipments Order[] @relation("OrderReshipment")
|
reshipments Order[] @relation("OrderReshipment")
|
||||||
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
|
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
|
||||||
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
|
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||||
|
sku CommonProductSku? @relation(fields: [skuId], references: [id], onDelete: Restrict)
|
||||||
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
|
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
|
||||||
delivery OrderDelivery?
|
delivery OrderDelivery?
|
||||||
benefitCoupon BenefitCoupon?
|
benefitCoupon BenefitCoupon?
|
||||||
@@ -1548,6 +1667,7 @@ model Order {
|
|||||||
@@index([userId, status])
|
@@index([userId, status])
|
||||||
@@index([cityId, createdAt])
|
@@index([cityId, createdAt])
|
||||||
@@index([productId])
|
@@index([productId])
|
||||||
|
@@index([skuId])
|
||||||
@@index([barcode69])
|
@@index([barcode69])
|
||||||
@@index([payExternalNo])
|
@@index([payExternalNo])
|
||||||
@@index([ipCity])
|
@@index([ipCity])
|
||||||
@@ -1566,6 +1686,8 @@ model UserInvoice {
|
|||||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||||
titleType InvoiceTitleType @map("title_type")
|
titleType InvoiceTitleType @map("title_type")
|
||||||
invoiceKind InvoiceKind @map("invoice_kind")
|
invoiceKind InvoiceKind @map("invoice_kind")
|
||||||
|
/// 酒水类 / 餐饮类;历史单默认酒水类
|
||||||
|
invoiceCategory InvoiceCategory @default(LIQUOR) @map("invoice_category")
|
||||||
titleName String @map("title_name") @db.VarChar(128)
|
titleName String @map("title_name") @db.VarChar(128)
|
||||||
taxNo String? @map("tax_no") @db.VarChar(32)
|
taxNo String? @map("tax_no") @db.VarChar(32)
|
||||||
addressPhone String? @map("address_phone") @db.VarChar(256)
|
addressPhone String? @map("address_phone") @db.VarChar(256)
|
||||||
|
|||||||
@@ -412,6 +412,26 @@ async function main() {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.commonProductSku.create({
|
||||||
|
data: {
|
||||||
|
productId: product.id,
|
||||||
|
skuCode: def.skuCode,
|
||||||
|
barcode69: `69000000000${i + 1}`,
|
||||||
|
specKey: '',
|
||||||
|
specText: '500ml | 53度',
|
||||||
|
price: def.price,
|
||||||
|
benefitAmount: def.price,
|
||||||
|
status: 'ON_SALE',
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
|
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
|
||||||
|
|
||||||
await prisma.commonProductItem.update({
|
await prisma.commonProductItem.update({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { AuthUser } from '../guards/jwt-auth.guard';
|
import { AuthUser } from '../guards/jwt-auth.guard';
|
||||||
|
|
||||||
export const CurrentUser = createParamDecorator(
|
export const CurrentUser = createParamDecorator(
|
||||||
@@ -7,8 +8,29 @@ export const CurrentUser = createParamDecorator(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export function serializeBigInt<T>(value: T): T {
|
function isPrismaDecimal(value: unknown): value is Prisma.Decimal {
|
||||||
return JSON.parse(
|
return Prisma.Decimal.isDecimal(value);
|
||||||
JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
|
}
|
||||||
);
|
|
||||||
|
function convertForJson(value: unknown): unknown {
|
||||||
|
if (typeof value === 'bigint') return value.toString();
|
||||||
|
if (isPrismaDecimal(value)) {
|
||||||
|
const n = Number(value.toString());
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (Array.isArray(value)) return value.map(convertForJson);
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
out[k] = convertForJson(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 安全序列化:bigint → string,Prisma.Decimal → number(避免金额变成 {} 被前端显示成 0) */
|
||||||
|
export function serializeBigInt<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(convertForJson(value))) as T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
key: 'SHARE_HINT',
|
key: 'SHARE_HINT',
|
||||||
label: '分享引导文案',
|
label: '分享引导文案',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'global',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
placeholder: DEFAULT_SHARE_HINT,
|
placeholder: DEFAULT_SHARE_HINT,
|
||||||
@@ -254,6 +255,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
key: 'SHARE_DEFAULT_TITLE',
|
key: 'SHARE_DEFAULT_TITLE',
|
||||||
label: '默认分享标题',
|
label: '默认分享标题',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'global',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
placeholder: DEFAULT_SHARE_TITLE,
|
placeholder: DEFAULT_SHARE_TITLE,
|
||||||
@@ -263,6 +265,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
key: 'SHARE_DEFAULT_DESC',
|
key: 'SHARE_DEFAULT_DESC',
|
||||||
label: '默认分享描述',
|
label: '默认分享描述',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'global',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
placeholder: DEFAULT_SHARE_DESC,
|
placeholder: DEFAULT_SHARE_DESC,
|
||||||
@@ -272,150 +275,170 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
key: 'SHARE_DEFAULT_IMAGE_URL',
|
key: 'SHARE_DEFAULT_IMAGE_URL',
|
||||||
label: '默认分享图',
|
label: '默认分享图',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'global',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '各场景未配置图且无业务图时使用;建议接近 5:4',
|
description: '各场景未配置图且无业务图时使用;建议接近 5:4',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_HOME_TITLE',
|
key: 'SHARE_HOME_TITLE',
|
||||||
label: '首页 · 标题',
|
label: '标题',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'home',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_HOME_DESC',
|
key: 'SHARE_HOME_DESC',
|
||||||
label: '首页 · 描述',
|
label: '描述',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'home',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_HOME_IMAGE_URL',
|
key: 'SHARE_HOME_IMAGE_URL',
|
||||||
label: '首页 · 分享图',
|
label: '分享图',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'home',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '留空则优先用首页首张轮播图,再回退默认分享图',
|
description: '留空则优先用首页首张轮播图,再回退默认分享图',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_STORES_TITLE',
|
key: 'SHARE_STORES_TITLE',
|
||||||
label: '门店列表 · 标题',
|
label: '标题',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'stores',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
placeholder: DEFAULT_SHARE_STORES_TITLE,
|
placeholder: DEFAULT_SHARE_STORES_TITLE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_STORES_DESC',
|
key: 'SHARE_STORES_DESC',
|
||||||
label: '门店列表 · 描述',
|
label: '描述',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'stores',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_STORES_IMAGE_URL',
|
key: 'SHARE_STORES_IMAGE_URL',
|
||||||
label: '门店列表 · 分享图',
|
label: '分享图',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'stores',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_STORE_DETAIL_TITLE',
|
key: 'SHARE_STORE_DETAIL_TITLE',
|
||||||
label: '门店详情 · 标题',
|
label: '标题',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'storeDetail',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '留空则用门店名称',
|
description: '小程序好友/朋友圈分享固定用门店名称;本项仅作其它端回退参考,可留空',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_STORE_DETAIL_DESC',
|
key: 'SHARE_STORE_DETAIL_DESC',
|
||||||
label: '门店详情 · 描述',
|
label: '描述',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'storeDetail',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '留空则用门店简介/地址',
|
description: '留空则用门店简介/地址',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_STORE_DETAIL_IMAGE_URL',
|
key: 'SHARE_STORE_DETAIL_IMAGE_URL',
|
||||||
label: '门店详情 · 分享图',
|
label: '分享图',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'storeDetail',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '留空则用门头图/环境图',
|
description: '小程序分享固定用门头图/环境图;本项可留空',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_BENEFIT_TITLE',
|
key: 'SHARE_BENEFIT_TITLE',
|
||||||
label: '权益页 · 标题',
|
label: '标题',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'benefit',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
placeholder: DEFAULT_SHARE_BENEFIT_TITLE,
|
placeholder: DEFAULT_SHARE_BENEFIT_TITLE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_BENEFIT_DESC',
|
key: 'SHARE_BENEFIT_DESC',
|
||||||
label: '权益页 · 描述',
|
label: '描述',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'benefit',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_BENEFIT_IMAGE_URL',
|
key: 'SHARE_BENEFIT_IMAGE_URL',
|
||||||
label: '权益页 · 分享图',
|
label: '分享图',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'benefit',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_MINE_TITLE',
|
key: 'SHARE_MINE_TITLE',
|
||||||
label: '我的 · 标题',
|
label: '标题',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'mine',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
placeholder: DEFAULT_SHARE_MINE_TITLE,
|
placeholder: DEFAULT_SHARE_MINE_TITLE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_MINE_DESC',
|
key: 'SHARE_MINE_DESC',
|
||||||
label: '我的 · 描述',
|
label: '描述',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'mine',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_MINE_IMAGE_URL',
|
key: 'SHARE_MINE_IMAGE_URL',
|
||||||
label: '我的 · 分享图',
|
label: '分享图',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'mine',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_PRODUCT_DETAIL_TITLE',
|
key: 'SHARE_PRODUCT_DETAIL_TITLE',
|
||||||
label: '商品详情 · 标题',
|
label: '标题',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'productDetail',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '留空则用商品名称',
|
description: '小程序好友/朋友圈分享固定用商品名称;本项可留空',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_PRODUCT_DETAIL_DESC',
|
key: 'SHARE_PRODUCT_DETAIL_DESC',
|
||||||
label: '商品详情 · 描述',
|
label: '描述',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'productDetail',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '留空则用商品副标题',
|
description: '留空则用商品副标题',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_PRODUCT_DETAIL_IMAGE_URL',
|
key: 'SHARE_PRODUCT_DETAIL_IMAGE_URL',
|
||||||
label: '商品详情 · 分享图',
|
label: '分享图',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'productDetail',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '留空则用商品主图',
|
description: '小程序分享固定用商品主图;本项可留空',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_ORDER_DETAIL_TITLE',
|
key: 'SHARE_ORDER_DETAIL_TITLE',
|
||||||
label: '订单详情 · 标题',
|
label: '标题',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'orderDetail',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
placeholder: DEFAULT_SHARE_ORDER_TITLE,
|
placeholder: DEFAULT_SHARE_ORDER_TITLE,
|
||||||
@@ -423,15 +446,17 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_ORDER_DETAIL_DESC',
|
key: 'SHARE_ORDER_DETAIL_DESC',
|
||||||
label: '订单详情 · 描述',
|
label: '描述',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'orderDetail',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'SHARE_ORDER_DETAIL_IMAGE_URL',
|
key: 'SHARE_ORDER_DETAIL_IMAGE_URL',
|
||||||
label: '订单详情 · 分享图',
|
label: '分享图',
|
||||||
group: G.wechat_mini_share,
|
group: G.wechat_mini_share,
|
||||||
|
subgroup: 'orderDetail',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
WECOM_BIZ_TODO_PUSH_NAME,
|
||||||
|
WECOM_DEAL_BROADCAST_PUSH_NAME,
|
||||||
WECOM_PUSH_CONDITIONS,
|
WECOM_PUSH_CONDITIONS,
|
||||||
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
|
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
|
||||||
|
WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS,
|
||||||
|
WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS,
|
||||||
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
|
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
|
||||||
|
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
|
||||||
|
WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
|
||||||
|
WECOM_STORE_AUDIT_PUSH_NAME,
|
||||||
|
WECOM_TEMPLATE_EVENT_KEYS,
|
||||||
maskWecomWebhookUrl,
|
maskWecomWebhookUrl,
|
||||||
parseWecomPushConditions,
|
parseWecomPushConditions,
|
||||||
type WecomMessagePushDto,
|
type WecomMessagePushDto,
|
||||||
type WecomPushCondition,
|
type WecomPushCondition,
|
||||||
|
type WecomPushTemplateDto,
|
||||||
|
type WecomTemplateEventKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
|
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
|
||||||
|
import {
|
||||||
|
WECOM_PUSH_TEMPLATE_DEFAULTS,
|
||||||
|
buildHqHandleUrl,
|
||||||
|
getDefaultTemplate,
|
||||||
|
renderWecomTemplate,
|
||||||
|
} from './wecom-push-template.defaults';
|
||||||
|
|
||||||
type PushRow = {
|
type PushRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -24,6 +40,16 @@ type PushRow = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TemplateRow = {
|
||||||
|
id: bigint;
|
||||||
|
eventKey: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
handleLabel: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WecomMessagePushService implements OnModuleInit {
|
export class WecomMessagePushService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(WecomMessagePushService.name);
|
private readonly logger = new Logger(WecomMessagePushService.name);
|
||||||
@@ -40,11 +66,10 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送(v3.4.11) */
|
/** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 模板缺行 */
|
||||||
async ensureDefaults(): Promise<void> {
|
async ensureDefaults(): Promise<void> {
|
||||||
const count = await this.prisma.wecomMessagePush.count();
|
const count = await this.prisma.wecomMessagePush.count();
|
||||||
if (count > 0) return;
|
if (count === 0) {
|
||||||
|
|
||||||
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
|
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
|
||||||
if (alertUrl) {
|
if (alertUrl) {
|
||||||
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
|
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
|
||||||
@@ -98,6 +123,83 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.ensureNamedPush(
|
||||||
|
WECOM_STORE_AUDIT_PUSH_NAME,
|
||||||
|
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
|
||||||
|
20,
|
||||||
|
process.env.WECOM_STORE_AUDIT_WEBHOOK_URL,
|
||||||
|
);
|
||||||
|
await this.ensureNamedPush(
|
||||||
|
WECOM_BIZ_TODO_PUSH_NAME,
|
||||||
|
WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS,
|
||||||
|
25,
|
||||||
|
process.env.WECOM_BIZ_TODO_WEBHOOK_URL,
|
||||||
|
);
|
||||||
|
await this.ensureNamedPush(
|
||||||
|
WECOM_DEAL_BROADCAST_PUSH_NAME,
|
||||||
|
WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS,
|
||||||
|
30,
|
||||||
|
process.env.WECOM_DEAL_BROADCAST_WEBHOOK_URL,
|
||||||
|
);
|
||||||
|
await this.ensureTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureNamedPush(
|
||||||
|
name: string,
|
||||||
|
conditions: WecomPushCondition[],
|
||||||
|
sortOrder: number,
|
||||||
|
envUrl?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.prisma.wecomMessagePush.findFirst({ where: { name } });
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
const conditionsJson = JSON.stringify(conditions);
|
||||||
|
const url = (envUrl || '').trim();
|
||||||
|
if (url) {
|
||||||
|
await this.prisma.wecomMessagePush.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
webhookUrl: url,
|
||||||
|
enabled: true,
|
||||||
|
pushConditions: conditionsJson,
|
||||||
|
sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`seeded wecom message push: ${name} (from env)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.wecomMessagePush.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
|
||||||
|
enabled: false,
|
||||||
|
pushConditions: conditionsJson,
|
||||||
|
sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅插入缺失 eventKey,不覆盖已有文案 */
|
||||||
|
async ensureTemplates(): Promise<void> {
|
||||||
|
for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) {
|
||||||
|
const existing = await this.prisma.wecomPushTemplate.findUnique({
|
||||||
|
where: { eventKey: def.eventKey },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
await this.prisma.wecomPushTemplate.create({
|
||||||
|
data: {
|
||||||
|
eventKey: def.eventKey,
|
||||||
|
title: def.title,
|
||||||
|
body: def.body,
|
||||||
|
handleLabel: def.handleLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`seeded wecom push template: ${def.eventKey}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async listMatchingPushes(eventKey: WecomPushCondition): Promise<PushRow[]> {
|
async listMatchingPushes(eventKey: WecomPushCondition): Promise<PushRow[]> {
|
||||||
const rows = await this.prisma.wecomMessagePush.findMany({
|
const rows = await this.prisma.wecomMessagePush.findMany({
|
||||||
where: { enabled: true },
|
where: { enabled: true },
|
||||||
@@ -111,6 +213,58 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
return pushes.length > 0;
|
return pushes.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读 HQ 模板 → 插值 → 补快链 → 按条件路由推送。
|
||||||
|
* 失败只打日志,不抛给业务。
|
||||||
|
*/
|
||||||
|
async dispatchEvent(
|
||||||
|
eventKey: WecomTemplateEventKey,
|
||||||
|
vars: Record<string, string | number | null | undefined>,
|
||||||
|
options?: { applyMention?: boolean; handlePath?: string },
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const content = await this.renderEventContent(eventKey, vars, options?.handlePath);
|
||||||
|
return await this.dispatchMarkdown(eventKey, content, {
|
||||||
|
applyMention: options?.applyMention ?? false,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`dispatchEvent(${eventKey}) failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderEventContent(
|
||||||
|
eventKey: WecomTemplateEventKey,
|
||||||
|
vars: Record<string, string | number | null | undefined>,
|
||||||
|
handlePath?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } });
|
||||||
|
const def = getDefaultTemplate(eventKey);
|
||||||
|
const body = row?.body || def?.body || `**${eventKey}**`;
|
||||||
|
const handleLabel = row?.handleLabel || def?.handleLabel || '去处理';
|
||||||
|
|
||||||
|
const merged: Record<string, string | number | null | undefined> = {
|
||||||
|
...vars,
|
||||||
|
handleLabel: vars.handleLabel ?? handleLabel,
|
||||||
|
time:
|
||||||
|
vars.time ??
|
||||||
|
new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (handlePath && !merged.handleUrl) {
|
||||||
|
merged.handleUrl = buildHqHandleUrl(handlePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = renderWecomTemplate(body, merged).trim();
|
||||||
|
const url = String(merged.handleUrl || '').trim();
|
||||||
|
if (url && !content.includes(url) && !/\{\{handleUrl\}\}/.test(body)) {
|
||||||
|
content = `${content}\n[${handleLabel}](${url})`;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
/** 向所有匹配 eventKey 的启用推送发送 markdown;返回成功发送数 */
|
/** 向所有匹配 eventKey 的启用推送发送 markdown;返回成功发送数 */
|
||||||
async dispatchMarkdown(
|
async dispatchMarkdown(
|
||||||
eventKey: WecomPushCondition,
|
eventKey: WecomPushCondition,
|
||||||
@@ -196,6 +350,188 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
: { ok: false, message: 'Webhook 调用失败,请检查 URL 或 API 日志' };
|
: { ok: false, message: 'Webhook 调用失败,请检查 URL 或 API 日志' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── templates CRUD ──
|
||||||
|
|
||||||
|
async listTemplates(): Promise<WecomPushTemplateDto[]> {
|
||||||
|
await this.ensureTemplates();
|
||||||
|
const rows = await this.prisma.wecomPushTemplate.findMany({
|
||||||
|
orderBy: { eventKey: 'asc' },
|
||||||
|
});
|
||||||
|
return rows.map((r) => this.templateToDto(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTemplate(eventKey: string): Promise<WecomPushTemplateDto> {
|
||||||
|
this.assertTemplateKey(eventKey);
|
||||||
|
await this.ensureTemplates();
|
||||||
|
const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } });
|
||||||
|
if (!row) throw new BadRequestException('模板不存在');
|
||||||
|
return this.templateToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTemplate(
|
||||||
|
eventKey: string,
|
||||||
|
data: { title?: string; body?: string; handleLabel?: string },
|
||||||
|
): Promise<WecomPushTemplateDto> {
|
||||||
|
this.assertTemplateKey(eventKey);
|
||||||
|
await this.ensureTemplates();
|
||||||
|
const existing = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } });
|
||||||
|
if (!existing) throw new BadRequestException('模板不存在');
|
||||||
|
|
||||||
|
const title = data.title != null ? String(data.title).trim() : undefined;
|
||||||
|
const body = data.body != null ? String(data.body).trim() : undefined;
|
||||||
|
const handleLabel =
|
||||||
|
data.handleLabel != null ? String(data.handleLabel).trim() || '去处理' : undefined;
|
||||||
|
if (title !== undefined && !title) throw new BadRequestException('标题不能为空');
|
||||||
|
if (body !== undefined && !body) throw new BadRequestException('正文不能为空');
|
||||||
|
|
||||||
|
const row = await this.prisma.wecomPushTemplate.update({
|
||||||
|
where: { eventKey },
|
||||||
|
data: {
|
||||||
|
...(title !== undefined ? { title } : {}),
|
||||||
|
...(body !== undefined ? { body } : {}),
|
||||||
|
...(handleLabel !== undefined ? { handleLabel } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.templateToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetTemplate(eventKey: string): Promise<WecomPushTemplateDto> {
|
||||||
|
this.assertTemplateKey(eventKey);
|
||||||
|
const def = getDefaultTemplate(eventKey);
|
||||||
|
if (!def) throw new BadRequestException('无默认模板');
|
||||||
|
await this.ensureTemplates();
|
||||||
|
const row = await this.prisma.wecomPushTemplate.upsert({
|
||||||
|
where: { eventKey },
|
||||||
|
create: {
|
||||||
|
eventKey: def.eventKey,
|
||||||
|
title: def.title,
|
||||||
|
body: def.body,
|
||||||
|
handleLabel: def.handleLabel,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: def.title,
|
||||||
|
body: def.body,
|
||||||
|
handleLabel: def.handleLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.templateToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用示例变量渲染并推到勾选了该事件的启用群 */
|
||||||
|
async testTemplate(eventKey: string): Promise<{ ok: boolean; message: string; preview: string }> {
|
||||||
|
this.assertTemplateKey(eventKey);
|
||||||
|
const sample = this.sampleVars(eventKey);
|
||||||
|
const preview = await this.renderEventContent(
|
||||||
|
eventKey,
|
||||||
|
sample.vars,
|
||||||
|
sample.handlePath,
|
||||||
|
);
|
||||||
|
const sent = await this.dispatchMarkdown(eventKey, preview, { applyMention: false });
|
||||||
|
if (sent === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: '没有已启用且勾选该事件的消息推送,请先配置 webhook',
|
||||||
|
preview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, message: `已发送至 ${sent} 个推送`, preview };
|
||||||
|
}
|
||||||
|
|
||||||
|
private sampleVars(eventKey: WecomTemplateEventKey): {
|
||||||
|
vars: Record<string, string>;
|
||||||
|
handlePath: string;
|
||||||
|
} {
|
||||||
|
const samples: Record<WecomTemplateEventKey, { vars: Record<string, string>; handlePath: string }> = {
|
||||||
|
'order.paid': {
|
||||||
|
vars: {
|
||||||
|
orderNo: 'DK202608200001',
|
||||||
|
payAmount: '199.00',
|
||||||
|
cityName: '郑州',
|
||||||
|
skuSummary: '杜康原浆 ×2',
|
||||||
|
phoneMasked: '138****8000',
|
||||||
|
},
|
||||||
|
handlePath: '/orders?orderNo=DK202608200001',
|
||||||
|
},
|
||||||
|
'redeem.success': {
|
||||||
|
vars: {
|
||||||
|
redeemNo: 'RD202608200001',
|
||||||
|
amount: '88.00',
|
||||||
|
storeName: '示例门店',
|
||||||
|
channel: '扫码',
|
||||||
|
},
|
||||||
|
handlePath: '/redeem-records?redeemNo=RD202608200001',
|
||||||
|
},
|
||||||
|
'store.audit_pending': {
|
||||||
|
vars: {
|
||||||
|
storeName: '示例门店',
|
||||||
|
cityName: '郑州',
|
||||||
|
partnerLabel: '示例合伙人',
|
||||||
|
action: '新建',
|
||||||
|
storeId: '1',
|
||||||
|
},
|
||||||
|
handlePath: '/stores?auditStatus=PENDING&storeId=1',
|
||||||
|
},
|
||||||
|
'store.package_audit_pending': {
|
||||||
|
vars: {
|
||||||
|
storeName: '示例门店',
|
||||||
|
cityName: '郑州',
|
||||||
|
submitter: '合伙人',
|
||||||
|
packageCount: '3',
|
||||||
|
requestId: '1',
|
||||||
|
},
|
||||||
|
handlePath: '/store-package-audits?requestId=1',
|
||||||
|
},
|
||||||
|
'store.info_change_pending': {
|
||||||
|
vars: {
|
||||||
|
storeName: '示例门店',
|
||||||
|
cityName: '郑州',
|
||||||
|
submitter: '门店',
|
||||||
|
changedFields: '门店名称、详细地址',
|
||||||
|
requestId: '1',
|
||||||
|
},
|
||||||
|
handlePath: '/store-package-audits?tab=info&infoRequestId=1',
|
||||||
|
},
|
||||||
|
'store.withdraw_pending': {
|
||||||
|
vars: {
|
||||||
|
storeName: '示例门店',
|
||||||
|
withdrawNo: 'SW202608200001',
|
||||||
|
amount: '500.00',
|
||||||
|
payoutCount: '5',
|
||||||
|
storeId: '1',
|
||||||
|
},
|
||||||
|
handlePath: '/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=1',
|
||||||
|
},
|
||||||
|
'invoice.pending': {
|
||||||
|
vars: {
|
||||||
|
invoiceNo: 'IV202608200001',
|
||||||
|
orderNo: 'DK202608200001',
|
||||||
|
payAmount: '199.00',
|
||||||
|
titleName: '示例公司',
|
||||||
|
phoneMasked: '138****8000',
|
||||||
|
},
|
||||||
|
handlePath: '/invoices?status=PENDING&invoiceNo=IV202608200001',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return samples[eventKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertTemplateKey(eventKey: string): asserts eventKey is WecomTemplateEventKey {
|
||||||
|
if (!(WECOM_TEMPLATE_EVENT_KEYS as readonly string[]).includes(eventKey)) {
|
||||||
|
throw new BadRequestException(`无效模板事件:${eventKey}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templateToDto(row: TemplateRow): WecomPushTemplateDto {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
eventKey: row.eventKey as WecomTemplateEventKey,
|
||||||
|
title: row.title,
|
||||||
|
body: row.body,
|
||||||
|
handleLabel: row.handleLabel,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
toDto(row: PushRow): WecomMessagePushDto {
|
toDto(row: PushRow): WecomMessagePushDto {
|
||||||
return {
|
return {
|
||||||
id: row.id.toString(),
|
id: row.id.toString(),
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { WecomTemplateEventKey } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export type WecomTemplateDefault = {
|
||||||
|
eventKey: WecomTemplateEventKey;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
handleLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 代码内默认文案;ensureDefaults 仅在库中无行时写入,不覆盖 HQ 已改 */
|
||||||
|
export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
||||||
|
{
|
||||||
|
eventKey: 'order.paid',
|
||||||
|
title: '订单支付成功',
|
||||||
|
body: [
|
||||||
|
'**订单支付成功**',
|
||||||
|
'订单号:{{orderNo}}',
|
||||||
|
'实付:¥{{payAmount}}',
|
||||||
|
'城市:{{cityName}}',
|
||||||
|
'商品:{{skuSummary}}',
|
||||||
|
'用户:{{phoneMasked}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'redeem.success',
|
||||||
|
title: '门店核销成功',
|
||||||
|
body: [
|
||||||
|
'**门店核销成功**',
|
||||||
|
'核销单号:{{redeemNo}}',
|
||||||
|
'金额:¥{{amount}}',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'渠道:{{channel}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'store.audit_pending',
|
||||||
|
title: '门店审核待处理',
|
||||||
|
body: [
|
||||||
|
'**门店审核待处理 · {{action}}**',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'城市:{{cityName}}',
|
||||||
|
'合伙人:{{partnerLabel}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'store.package_audit_pending',
|
||||||
|
title: '套餐变更待审核',
|
||||||
|
body: [
|
||||||
|
'**套餐变更待审核**',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'城市:{{cityName}}',
|
||||||
|
'提交端:{{submitter}}',
|
||||||
|
'套餐条数:{{packageCount}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'store.info_change_pending',
|
||||||
|
title: '门店信息变更待审',
|
||||||
|
body: [
|
||||||
|
'**门店信息变更待审**',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'城市:{{cityName}}',
|
||||||
|
'提交端:{{submitter}}',
|
||||||
|
'变更字段:{{changedFields}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'store.withdraw_pending',
|
||||||
|
title: '门店提现待审',
|
||||||
|
body: [
|
||||||
|
'**门店提现待审**',
|
||||||
|
'门店:{{storeName}}',
|
||||||
|
'提现单号:{{withdrawNo}}',
|
||||||
|
'金额:¥{{amount}}',
|
||||||
|
'明细笔数:{{payoutCount}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventKey: 'invoice.pending',
|
||||||
|
title: '发票申请待开票',
|
||||||
|
body: [
|
||||||
|
'**发票申请待开票**',
|
||||||
|
'申请单号:{{invoiceNo}}',
|
||||||
|
'订单号:{{orderNo}}',
|
||||||
|
'金额:¥{{payAmount}}',
|
||||||
|
'抬头:{{titleName}}',
|
||||||
|
'用户:{{phoneMasked}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
|
handleLabel: '去处理',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined {
|
||||||
|
return WECOM_PUSH_TEMPLATE_DEFAULTS.find((t) => t.eventKey === eventKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 body 中的 {{key}} 替换为 vars;缺失置空 */
|
||||||
|
export function renderWecomTemplate(
|
||||||
|
body: string,
|
||||||
|
vars: Record<string, string | number | null | undefined>,
|
||||||
|
): string {
|
||||||
|
return body.replace(/\{\{(\w+)\}\}/g, (_m, key: string) => {
|
||||||
|
const v = vars[key];
|
||||||
|
if (v == null) return '';
|
||||||
|
return String(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HQ 后台公网根地址(无尾斜杠)。
|
||||||
|
* 优先 HQ_ADMIN_PUBLIC_URL;未配时按 WECOM_ALERT_ENV_LABEL / NODE_ENV 回退,
|
||||||
|
* 避免相对路径被企微解析成 http://orders/... 这类无效链接。
|
||||||
|
*/
|
||||||
|
export function resolveHqAdminPublicBase(): string {
|
||||||
|
const configured = (process.env.HQ_ADMIN_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
||||||
|
if (configured) return configured;
|
||||||
|
|
||||||
|
const label = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (label === 'production' || label === 'prod') {
|
||||||
|
return 'https://admin.dukanghaoke.com';
|
||||||
|
}
|
||||||
|
if (label === 'staging' || label === 'test' || label === 'testing') {
|
||||||
|
return 'https://admin-test.dukanghaoke.com';
|
||||||
|
}
|
||||||
|
// local / development / 未识别:本机 admin-web 默认端口
|
||||||
|
return 'http://localhost:5175';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHqHandleUrl(pathWithQuery: string): string {
|
||||||
|
const base = resolveHqAdminPublicBase();
|
||||||
|
let path = (pathWithQuery || '').trim();
|
||||||
|
if (!path) return base;
|
||||||
|
if (!path.startsWith('/')) path = `/${path}`;
|
||||||
|
return `${base}${path}`;
|
||||||
|
}
|
||||||
@@ -6,6 +6,14 @@ import {
|
|||||||
normalizeTestPhone,
|
normalizeTestPhone,
|
||||||
} from '../../common/test-whitelist/test-whitelist.service';
|
} from '../../common/test-whitelist/test-whitelist.service';
|
||||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||||
|
import {
|
||||||
|
flattenSkuOntoProduct,
|
||||||
|
listMinOnSalePrice,
|
||||||
|
mapSkuDto,
|
||||||
|
mapSpecAttrsDto,
|
||||||
|
pickDisplaySku,
|
||||||
|
resolveOrderSale,
|
||||||
|
} from './product-sku.util';
|
||||||
|
|
||||||
export type CatalogViewer = {
|
export type CatalogViewer = {
|
||||||
/** C 端用户手机号;无则无法看到白名单商品 */
|
/** C 端用户手机号;无则无法看到白名单商品 */
|
||||||
@@ -81,6 +89,8 @@ export class CatalogService {
|
|||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
|
skus: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
specAttrs: { select: { id: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,12 +116,25 @@ export class CatalogService {
|
|||||||
return serializeBigInt(
|
return serializeBigInt(
|
||||||
visible.map((p) => {
|
visible.map((p) => {
|
||||||
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
||||||
const { visibilityWhitelistEnabled: _wl, ...rest } = p;
|
const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = p;
|
||||||
|
const display = pickDisplaySku(skus);
|
||||||
|
const flat = flattenSkuOntoProduct(p, display);
|
||||||
|
const minPrice = listMinOnSalePrice(skus);
|
||||||
|
const price = minPrice ?? flat.price;
|
||||||
|
const benefitAmount = flat.benefitAmount;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
benefitAmount: p.benefitAmount ?? p.price,
|
skuCode: flat.skuCode,
|
||||||
price: Number(p.price),
|
barcode69: undefined,
|
||||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
spec: flat.spec,
|
||||||
|
price,
|
||||||
|
benefitAmount,
|
||||||
|
benefitDisplay: benefitAmount,
|
||||||
|
allowOnSitePickup: flat.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||||
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||||
|
saleUnit: flat.saleUnit,
|
||||||
...media,
|
...media,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -123,6 +146,14 @@ export class CatalogService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
|
skus: {
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
include: { skuSpecs: { select: { valueId: true } } },
|
||||||
|
},
|
||||||
|
specAttrs: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { values: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
@@ -144,30 +175,57 @@ export class CatalogService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const media = mapProductMedia(product, resources);
|
const media = mapProductMedia(product, resources);
|
||||||
const { visibilityWhitelistEnabled: _wl, ...rest } = product;
|
const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = product;
|
||||||
|
const display = pickDisplaySku(skus);
|
||||||
|
const flat = flattenSkuOntoProduct(product, display);
|
||||||
|
const defaultSku = skus.find((s) => s.isDefault) ?? display;
|
||||||
|
const cSkus = skus.map((s) => {
|
||||||
|
const dto = mapSkuDto(s);
|
||||||
|
const { skuCode: _c, barcode69: _b, sortOrder: _o, ...publicSku } = dto;
|
||||||
|
return publicSku;
|
||||||
|
});
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...rest,
|
...rest,
|
||||||
benefitAmount: product.benefitAmount ?? product.price,
|
skuCode: flat.skuCode,
|
||||||
price: Number(product.price),
|
spec: flat.spec,
|
||||||
|
price: flat.price,
|
||||||
|
benefitAmount: flat.benefitAmount,
|
||||||
|
benefitDisplay: flat.benefitAmount,
|
||||||
|
allowOnSitePickup: flat.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||||
|
saleUnit: flat.saleUnit,
|
||||||
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||||
|
specAttrs: mapSpecAttrsDto(specAttrs),
|
||||||
|
skus: cSkus,
|
||||||
|
defaultSkuId: defaultSku?.id.toString(),
|
||||||
...media,
|
...media,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
|
/** 下单前校验:白名单商品仅全局测试白名单手机号可买;无 SKU 时回落 SPU 字段 */
|
||||||
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
|
async assertPurchasable(
|
||||||
|
productId: bigint,
|
||||||
|
viewerPhone?: string | null,
|
||||||
|
skuId?: string | null,
|
||||||
|
options?: { bypassWhitelist?: boolean },
|
||||||
|
) {
|
||||||
const product = await this.prisma.commonProductItem.findUnique({
|
const product = await this.prisma.commonProductItem.findUnique({
|
||||||
where: { id: productId },
|
where: { id: productId },
|
||||||
|
include: { skus: true },
|
||||||
});
|
});
|
||||||
if (!product || product.status !== 'ON_SALE') {
|
if (!product || product.status !== 'ON_SALE') {
|
||||||
throw new BadRequestException('商品不可购买');
|
throw new BadRequestException('商品不可购买');
|
||||||
}
|
}
|
||||||
if (product.visibilityWhitelistEnabled) {
|
if (!options?.bypassWhitelist && product.visibilityWhitelistEnabled) {
|
||||||
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
|
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new BadRequestException('该商品暂不对当前账号开放');
|
throw new BadRequestException('该商品暂不对当前账号开放');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return product;
|
const sale = resolveOrderSale(product, product.skus ?? [], skuId);
|
||||||
|
return { product, sale };
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
||||||
@@ -177,4 +235,12 @@ export class CatalogService {
|
|||||||
});
|
});
|
||||||
return user?.phone ?? null;
|
return user?.phone ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSkusForProduct(productId: bigint) {
|
||||||
|
return this.prisma.commonProductSku.findMany({
|
||||||
|
where: { productId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
include: { skuSpecs: { select: { valueId: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import type { CommonProductItem, CommonProductSku, ProductSaleUnit } from '@prisma/client';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { BOTTLES_PER_BOX } from '@dukang/domain';
|
||||||
|
|
||||||
|
export type SkuWithSpecs = CommonProductSku & {
|
||||||
|
skuSpecs?: Array<{ valueId: bigint; value?: { id: bigint; name: string; attrId: bigint } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpecAttrWithValues = {
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
values: Array<{ id: bigint; name: string; sortOrder: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 可售 SKU */
|
||||||
|
export function isSkuOnSale(sku: { status: string }): boolean {
|
||||||
|
return sku.status === 'ON_SALE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSpecKey(valueIds: bigint[]): string {
|
||||||
|
if (!valueIds.length) return '';
|
||||||
|
return [...valueIds]
|
||||||
|
.map((id) => id.toString())
|
||||||
|
.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0))
|
||||||
|
.join('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSpecText(
|
||||||
|
valueIds: bigint[],
|
||||||
|
valueNameById: Map<string, string>,
|
||||||
|
): string {
|
||||||
|
if (!valueIds.length) return '';
|
||||||
|
return valueIds
|
||||||
|
.map((id) => valueNameById.get(id.toString()) ?? '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下单用销售快照:有可售 SKU 则用 SKU,否则回落 SPU(旧客户端 / 未回填) */
|
||||||
|
export type OrderSaleSnapshot = {
|
||||||
|
skuId: bigint | null;
|
||||||
|
skuCode: string;
|
||||||
|
barcode69: string;
|
||||||
|
specText: string;
|
||||||
|
price: CommonProductSku['price'];
|
||||||
|
benefitAmount: CommonProductSku['benefitAmount'];
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
saleUnit: ProductSaleUnit;
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function saleSnapshotFromProduct(product: CommonProductItem): OrderSaleSnapshot {
|
||||||
|
return {
|
||||||
|
skuId: null,
|
||||||
|
skuCode: product.skuCode,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
specText: product.spec,
|
||||||
|
price: product.price,
|
||||||
|
benefitAmount: product.benefitAmount,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saleSnapshotFromSku(
|
||||||
|
sku: CommonProductSku,
|
||||||
|
product: CommonProductItem,
|
||||||
|
): OrderSaleSnapshot {
|
||||||
|
return {
|
||||||
|
skuId: sku.id,
|
||||||
|
skuCode: sku.skuCode,
|
||||||
|
barcode69: sku.barcode69,
|
||||||
|
specText: sku.specText || product.spec,
|
||||||
|
price: sku.price,
|
||||||
|
benefitAmount: sku.benefitAmount,
|
||||||
|
allowOnSitePickup: sku.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: sku.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
||||||
|
saleUnit: sku.saleUnit,
|
||||||
|
bottlesPerUnit: sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : sku.saleUnit === 'BOX' ? BOTTLES_PER_BOX : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 现网兼容:
|
||||||
|
* - 不传 skuId:有可售 SKU 用默认/唯一可售;一个都没有则回落 SPU 字段(未回填也能下单)
|
||||||
|
* - 传入 skuId:按 SKU 校验(新客户端选规格)
|
||||||
|
* 不在旧接口上因「无 SKU」或「多规格未选」打断现网下单。
|
||||||
|
*/
|
||||||
|
export function resolveOrderSale(
|
||||||
|
product: CommonProductItem,
|
||||||
|
skus: CommonProductSku[],
|
||||||
|
skuId?: string | null,
|
||||||
|
): OrderSaleSnapshot {
|
||||||
|
if (skuId) {
|
||||||
|
const found = skus.find((s) => s.id.toString() === String(skuId));
|
||||||
|
if (!found) throw new BadRequestException('规格不存在');
|
||||||
|
if (!isSkuOnSale(found)) throw new BadRequestException('该规格暂不可购买');
|
||||||
|
return saleSnapshotFromSku(found, product);
|
||||||
|
}
|
||||||
|
const onSale = skus.filter(isSkuOnSale);
|
||||||
|
if (onSale.length >= 1) {
|
||||||
|
const picked = onSale.find((s) => s.isDefault) ?? onSale[0];
|
||||||
|
return saleSnapshotFromSku(picked, product);
|
||||||
|
}
|
||||||
|
return saleSnapshotFromProduct(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表/拍平:优先默认可售 → 最低价可售 → 默认任意 → 任意 */
|
||||||
|
export function pickDisplaySku(skus: CommonProductSku[]): CommonProductSku | null {
|
||||||
|
if (!skus.length) return null;
|
||||||
|
const onSale = skus.filter(isSkuOnSale);
|
||||||
|
const pool = onSale.length ? onSale : skus;
|
||||||
|
const def = pool.find((s) => s.isDefault);
|
||||||
|
if (def) return def;
|
||||||
|
return [...pool].sort((a, b) => Number(a.price) - Number(b.price) || a.sortOrder - b.sortOrder)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMinOnSalePrice(skus: CommonProductSku[]): number | null {
|
||||||
|
const onSale = skus.filter(isSkuOnSale);
|
||||||
|
if (!onSale.length) return null;
|
||||||
|
return Math.min(...onSale.map((s) => Number(s.price)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenSkuOntoProduct(
|
||||||
|
product: CommonProductItem,
|
||||||
|
sku: CommonProductSku | null,
|
||||||
|
): {
|
||||||
|
skuCode: string;
|
||||||
|
barcode69: string;
|
||||||
|
spec: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount: number;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
saleUnit: ProductSaleUnit;
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
} {
|
||||||
|
if (!sku) {
|
||||||
|
return {
|
||||||
|
skuCode: product.skuCode,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
spec: product.spec,
|
||||||
|
price: Number(product.price),
|
||||||
|
benefitAmount: Number(product.benefitAmount ?? product.price),
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
skuCode: sku.skuCode,
|
||||||
|
barcode69: sku.barcode69,
|
||||||
|
spec: sku.specText || product.spec,
|
||||||
|
price: Number(sku.price),
|
||||||
|
benefitAmount: Number(sku.benefitAmount ?? sku.price),
|
||||||
|
allowOnSitePickup: sku.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: sku.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
||||||
|
saleUnit: sku.saleUnit,
|
||||||
|
bottlesPerUnit: sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : sku.saleUnit === 'BOX' ? BOTTLES_PER_BOX : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapSkuDto(sku: SkuWithSpecs) {
|
||||||
|
const specValueIds = (sku.skuSpecs ?? []).map((row) => row.valueId.toString());
|
||||||
|
return {
|
||||||
|
id: sku.id.toString(),
|
||||||
|
specValueIds,
|
||||||
|
specText: sku.specText,
|
||||||
|
price: Number(sku.price),
|
||||||
|
benefitAmount: Number(sku.benefitAmount ?? sku.price),
|
||||||
|
status: sku.status,
|
||||||
|
allowOnlinePurchase: sku.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: sku.allowOnSitePickup,
|
||||||
|
saleUnit: sku.saleUnit as 'BOTTLE' | 'BOX',
|
||||||
|
bottlesPerUnit: sku.bottlesPerUnit,
|
||||||
|
isDefault: sku.isDefault,
|
||||||
|
skuCode: sku.skuCode,
|
||||||
|
barcode69: sku.barcode69,
|
||||||
|
imageUrl: (sku as { imageUrl?: string | null }).imageUrl || undefined,
|
||||||
|
sortOrder: sku.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapSpecAttrsDto(attrs: SpecAttrWithValues[]) {
|
||||||
|
return attrs
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((attr) => ({
|
||||||
|
id: attr.id.toString(),
|
||||||
|
name: attr.name,
|
||||||
|
values: attr.values
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((v) => ({ id: v.id.toString(), name: v.name })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncDefaultSkuFieldsFromProduct(product: {
|
||||||
|
skuCode: string;
|
||||||
|
barcode69: string;
|
||||||
|
spec: string;
|
||||||
|
price: unknown;
|
||||||
|
benefitAmount: unknown;
|
||||||
|
status: string;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
skuCode: product.skuCode,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
specText: product.spec,
|
||||||
|
price: product.price as never,
|
||||||
|
benefitAmount: product.benefitAmount as never,
|
||||||
|
status: product.status as never,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -76,11 +76,12 @@ export class FulfillmentService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送
|
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量)
|
||||||
if (shouldHoldAutoCourierDispatch(order.quantity)) {
|
const bottleQty = order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1);
|
||||||
const boxes = calcOrderBoxCount(order.quantity);
|
if (shouldHoldAutoCourierDispatch(bottleQty)) {
|
||||||
|
const boxes = calcOrderBoxCount(bottleQty);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
|
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity}×${order.bottlesPerUnit} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
|
||||||
);
|
);
|
||||||
await this.prisma.order.update({
|
await this.prisma.order.update({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
@@ -97,7 +98,7 @@ export class FulfillmentService {
|
|||||||
refType: 'ORDER',
|
refType: 'ORDER',
|
||||||
refId: order.id,
|
refId: order.id,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
errorMessage: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
|
errorMessage: `大单拦截:${bottleQty}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
|
||||||
0,
|
0,
|
||||||
512,
|
512,
|
||||||
),
|
),
|
||||||
@@ -155,7 +156,7 @@ export class FulfillmentService {
|
|||||||
addressDetail: order.receiverAddress,
|
addressDetail: order.receiverAddress,
|
||||||
},
|
},
|
||||||
goodsName: order.productName,
|
goodsName: order.productName,
|
||||||
goodsNum: order.quantity,
|
goodsNum: order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1),
|
||||||
weight: 2,
|
weight: 2,
|
||||||
payMode: CourierPayMode.SENDER,
|
payMode: CourierPayMode.SENDER,
|
||||||
remark: `仓配自动发货 ${order.orderNo}`,
|
remark: `仓配自动发货 ${order.orderNo}`,
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ export class AdminInvoicesController {
|
|||||||
@Get()
|
@Get()
|
||||||
list(
|
list(
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
|
@Query('invoiceNo') invoiceNo?: string,
|
||||||
@Query('page') page = '1',
|
@Query('page') page = '1',
|
||||||
@Query('pageSize') pageSize = '20',
|
@Query('pageSize') pageSize = '20',
|
||||||
) {
|
) {
|
||||||
return this.tradeService.adminListInvoices({
|
return this.tradeService.adminListInvoices({
|
||||||
status,
|
status,
|
||||||
|
invoiceNo,
|
||||||
page: Number(page),
|
page: Number(page),
|
||||||
pageSize: Number(pageSize),
|
pageSize: Number(pageSize),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
|||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { AdminProductsService } from './admin-products.service';
|
import { AdminProductsService } from './admin-products.service';
|
||||||
import { AdminProductsQueryDto } from './dto/admin-query.dto';
|
import { AdminProductsQueryDto } from './dto/admin-query.dto';
|
||||||
import { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
import {
|
||||||
|
CreateProductDto,
|
||||||
|
SaveProductSkusDto,
|
||||||
|
SaveProductSpecsDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
} from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
@Controller('admin/products')
|
@Controller('admin/products')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
@@ -33,6 +38,18 @@ export class AdminProductsController {
|
|||||||
return this.service.update(BigInt(id), dto);
|
return this.service.update(BigInt(id), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/specs')
|
||||||
|
@HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true })
|
||||||
|
saveSpecs(@Param('id') id: string, @Body() dto: SaveProductSpecsDto) {
|
||||||
|
return this.service.saveSpecs(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/skus')
|
||||||
|
@HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true })
|
||||||
|
saveSkus(@Param('id') id: string, @Body() dto: SaveProductSkusDto) {
|
||||||
|
return this.service.saveSkus(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HqOperation({ action: HqOperationAction.PRODUCT_DELETE, refType: 'PRODUCT', refIdParam: 'id' })
|
@HqOperation({ action: HqOperationAction.PRODUCT_DELETE, refType: 'PRODUCT', refIdParam: 'id' })
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
|
|||||||