v3.5.4版本提交
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-21 15:48:15 +08:00
parent 26334ed072
commit a01217539c
32 changed files with 2278 additions and 147 deletions
@@ -0,0 +1,445 @@
import { useMemo, useState } from 'react';
import {
Button, Input, InputNumber, Radio, Select, Space, Switch, Table, Typography, message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { request } from '../lib/api';
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
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;
};
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,
}))
: [
{
specValueIds: [],
barcode69: '',
price: 0,
status: 'ON_SALE',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
},
],
);
const [saving, setSaving] = useState(false);
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,
};
});
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
setSkus(next.length ? next : skus);
}
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 ?? []),
skuCode: s.skuCode,
barcode69: s.barcode69,
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,
}));
for (const row of payloadSkus) {
if (!row.barcode69?.trim()) throw new Error('请填写全部 SKU 的 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);
}
}
return (
<div>
<Typography.Paragraph type="secondary">
SKU 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: 1100 }}
dataSource={skus}
columns={[
{
title: '规格',
width: 140,
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
},
{
title: '69码',
width: 140,
render: (_, row, index) => (
<Input
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 })));
}}
/>
),
},
]}
/>
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
SKU
</Button>
</div>
);
}
@@ -51,6 +51,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState<string>();
const [skuId, setSkuId] = useState<string>();
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
@@ -87,9 +88,37 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
}, [open]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
const selectedSku =
skuOptions.find((s) => s.id === skuId) ||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
skuOptions[0];
const allowOnline = selectedSku
? selectedSku.allowOnlinePurchase !== false
: selectedProduct
? selectedProduct.allowOnlinePurchase !== false
: true;
const allowOnSite = selectedSku
? !!selectedSku.allowOnSitePickup
: !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedSku
? selectedSku.allowCrossCityDelivery !== false
: selectedProduct
? selectedProduct.allowCrossCityDelivery !== false
: true;
useEffect(() => {
if (!open || !selectedProduct) return;
const def =
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
skuOptions.find((s) => s.isDefault) ||
skuOptions[0];
if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
setSkuId(def.id);
setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, productId, selectedProduct?.id]);
useEffect(() => {
if (!open || !selectedProduct) return;
@@ -115,6 +144,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
productId,
quantity,
deliveryMode,
skuId: skuId || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
@@ -124,7 +154,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
.finally(() => setPreviewLoading(false));
}, 300);
return () => window.clearTimeout(timer);
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
}, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
useEffect(() => () => stopPoll(), []);
@@ -135,6 +165,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
setRegionCodes([]);
setAddressDetail('');
setQuantity(2);
setProductId(undefined);
setSkuId(undefined);
setPromoCodeId(undefined);
setDeliveryMode('ADDRESS');
setAutoReceive(false);
@@ -202,6 +234,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
productId: productId!,
quantity,
promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
};
setSubmitting(true);
@@ -317,7 +350,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
loading={loadingOptions}
placeholder="选择商品"
value={productId}
onChange={setProductId}
onChange={(id) => {
setProductId(id);
setSkuId(undefined);
}}
options={(options?.products ?? []).map((p) => ({
value: p.id,
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
@@ -325,7 +361,25 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
/>
</Form.Item>
<Form.Item label="数量" required>
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
<Form.Item label="规格" required>
<Select
placeholder="选择规格"
value={skuId}
onChange={(id) => {
setSkuId(id);
const sku = skuOptions.find((s) => s.id === id);
if (sku?.saleUnit === 'BOX') setQuantity(1);
}}
options={skuOptions.map((s) => ({
value: s.id,
label: `${s.specText || '默认'} · ¥${fmtMoney(s.price)} · ${s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label={selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'} required>
<InputNumber
min={1}
value={quantity}
+18
View File
@@ -12,6 +12,7 @@ import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import ProductSpecsEditor from '../components/ProductSpecsEditor';
import type { FormInstance } from 'antd/es/form';
type ProductDetailContentDto = {
@@ -437,6 +438,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>
</>
+63 -5
View File
@@ -58,6 +58,7 @@ export default function ProxyOrderPage() {
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
const [productId, setProductId] = useState(draft0.productId);
const [skuId, setSkuId] = useState('');
const [quantity, setQuantity] = useState(draft0.quantity);
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
@@ -156,9 +157,37 @@ export default function ProxyOrderPage() {
]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
const selectedSku =
skuOptions.find((s) => s.id === skuId) ||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
skuOptions[0];
const allowOnline = selectedSku
? selectedSku.allowOnlinePurchase !== false
: selectedProduct
? selectedProduct.allowOnlinePurchase !== false
: true;
const allowOnSite = selectedSku
? !!selectedSku.allowOnSitePickup
: !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedSku
? selectedSku.allowCrossCityDelivery !== false
: selectedProduct
? selectedProduct.allowCrossCityDelivery !== false
: true;
useEffect(() => {
if (!selectedProduct) return;
const def =
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
skuOptions.find((s) => s.isDefault) ||
skuOptions[0];
if (def && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
setSkuId(def.id);
if (def.saleUnit === 'BOX') setQuantity(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, selectedProduct?.id, options]);
useEffect(() => {
if (!selectedProduct) return;
@@ -184,6 +213,7 @@ export default function ProxyOrderPage() {
productId,
quantity,
deliveryMode,
skuId: skuId || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
@@ -200,7 +230,7 @@ export default function ProxyOrderPage() {
.finally(() => setPreviewLoading(false));
}, 300);
return () => clearTimeout(timer);
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
}, [productId, skuId, quantity, deliveryMode, region?.city, region?.district]);
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
@@ -359,6 +389,7 @@ export default function ProxyOrderPage() {
productId,
quantity,
promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
};
setSubmitting(true);
@@ -524,8 +555,35 @@ export default function ProxyOrderPage() {
</button>
</section>
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<select
className="partner-input"
value={skuId}
onChange={(e) => {
const id = e.target.value;
setSkuId(id);
const sku = skuOptions.find((s) => s.id === id);
if (sku?.saleUnit === 'BOX') setQuantity(1);
}}
>
{skuOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.specText || '默认'} · ¥{fmtMoney(s.price)} ·{' '}
{s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}
</option>
))}
</select>
</div>
</section>
) : null}
<section className="partner-form-section">
<label className="partner-form-label"></label>
<label className="partner-form-label">
{selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'}
</label>
<div className="partner-input-wrap">
<input
className="partner-input"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,19 +1,20 @@
import { Text, View } from '@tarojs/components';
import BenefitFigure from './BenefitFigure';
type CouponBadgeProps = {
amount: number | string;
label?: string;
};
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge */
/** 首页商品角标:纯文案「享{amount}好客权益」(无门店图标 */
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
const n = Number(amount);
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
return (
<View className="coupon-badge">
<Text></Text>
<BenefitFigure value={`${display} ${label}`} size="sm" />
<Text>
{display}
{label}
</Text>
</View>
);
}
+5
View File
@@ -1,5 +1,6 @@
export type CheckoutContext = {
productId?: string;
skuId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
@@ -9,6 +10,7 @@ export type CheckoutContext = {
export function buildQuery(ctx: CheckoutContext): string {
const parts: string[] = [];
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.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
if (ctx.cross) parts.push('cross=1');
@@ -36,12 +38,14 @@ export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext
export function buildPayUrl(params: {
orderId: string;
productId?: string;
skuId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
}): string {
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
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.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
if (params.cross) parts.push('cross=1');
@@ -51,6 +55,7 @@ export function buildPayUrl(params: {
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
return {
productId: params.productId,
skuId: params.skuId,
qty: params.qty,
addressId: params.addressId,
cross: params.cross === '1',
@@ -32,12 +32,14 @@ type OrderPreview = {
quantityOk?: boolean;
quantityMessage?: string | null;
minQty?: number;
saleUnit?: 'BOTTLE' | 'BOX';
};
export default function OrderConfirmPickupPage() {
const router = useRouter();
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 [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
@@ -50,7 +52,7 @@ export default function OrderConfirmPickupPage() {
setPreviewLoading(true);
request<OrderPreview>('/trade/orders/preview', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
})
.then((data) => {
if (!cancelled) {
@@ -70,15 +72,16 @@ export default function OrderConfirmPickupPage() {
return () => {
cancelled = true;
};
}, [productId, quantity]);
}, [productId, skuId, quantity]);
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
const minQty = preview?.minQty ?? 2;
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < minQty) {
const tip = `现场提货至少购买 ${minQty}`;
const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
toast(tip);
setMsg(tip);
if (next < 1) return;
@@ -91,12 +94,13 @@ export default function OrderConfirmPickupPage() {
async function doSubmit() {
const order = await request<{ id: string }>('/trade/orders', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
});
Taro.redirectTo({
url: buildPayUrl({
orderId: order.id,
productId,
skuId: skuId || undefined,
qty: String(quantity),
}),
});
@@ -54,6 +54,9 @@ type OrderPreview = {
minQty?: number;
allowCrossCityDelivery?: boolean;
allowOnlinePurchase?: boolean;
skuId?: string;
saleUnit?: 'BOTTLE' | 'BOX';
bottlesPerUnit?: number;
};
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
@@ -66,6 +69,7 @@ export default function OrderConfirmPage() {
const router = useRouter();
const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? '';
const skuId = checkoutCtx.skuId ?? '';
const forceCross = checkoutCtx.cross === true;
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
const [addresses, setAddresses] = useState<Address[]>([]);
@@ -96,11 +100,12 @@ export default function OrderConfirmPage() {
if (!productId) return;
let cancelled = false;
setPreviewLoading(true);
const body: { productId: string; quantity: number; addressId?: string } = {
const body: { productId: string; quantity: number; addressId?: string; skuId?: string } = {
productId,
quantity,
};
if (addressId) body.addressId = addressId;
if (skuId) body.skuId = skuId;
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
.then((data) => {
@@ -139,7 +144,7 @@ export default function OrderConfirmPage() {
return () => {
cancelled = true;
};
}, [productId, quantity, addressId]);
}, [productId, skuId, quantity, addressId]);
const selectedAddress = useMemo(
() => addresses.find((a) => String(a.id) === addressId),
@@ -157,6 +162,7 @@ export default function OrderConfirmPage() {
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
const crossBlocked = isCross && !allowCross;
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
const minQty =
preview?.minQty ??
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
@@ -171,8 +177,8 @@ export default function OrderConfirmPage() {
function updateQuantity(next: number) {
if (next < minQty) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`;
? `跨城配送至少购买 ${minQty}${unitLabel}`
: `同城配送至少购买 ${minQty}${unitLabel}`;
toast(tip);
setMsg(tip);
if (next < 1) return;
@@ -195,6 +201,7 @@ export default function OrderConfirmPage() {
productId,
quantity,
addressId,
...(skuId ? { skuId } : {}),
...(clientLocation ? { clientLocation } : {}),
},
});
@@ -202,6 +209,7 @@ export default function OrderConfirmPage() {
url: buildPayUrl({
orderId: order.id,
productId,
skuId: skuId || undefined,
qty: String(quantity),
addressId,
cross: forceCross,
@@ -223,15 +231,15 @@ export default function OrderConfirmPage() {
}
if (!quantityOk) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`;
? `跨城配送至少购买 ${minQty}${unitLabel}`
: `同城配送至少购买 ${minQty}${unitLabel}`;
setMsg(tip);
toast(tip);
}
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) {
try {
@@ -371,8 +379,8 @@ export default function OrderConfirmPage() {
{!quantityOk ? (
<Text className="order-qty-hint">
{isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
: `同城配送至少购买 ${minQty},请调整数量`}
? `跨城配送至少购买 ${minQty}${unitLabel},请调整数量`
: `同城配送至少购买 ${minQty}${unitLabel},请调整数量`}
</Text>
) : null}
</View>
+138 -10
View File
@@ -8,7 +8,7 @@ import Taro, {
useShareAppMessage,
useShareTimeline,
} 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 PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
@@ -47,8 +47,39 @@ type Product = ProductImageSource & {
allowOnSitePickup?: boolean;
allowOnlinePurchase?: 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() {
const router = useRouter();
const productId = router.params.id ?? '';
@@ -58,6 +89,7 @@ export default function ProductDetailPage() {
);
const [product, setProduct] = useState<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false);
const [selected, setSelected] = useState<Record<string, string>>({});
usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100);
@@ -72,7 +104,25 @@ export default function ProductDetailPage() {
toast('商品不存在或暂未开放');
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) => {
setProduct(null);
@@ -84,11 +134,38 @@ export default function ProductDetailPage() {
loadProduct();
}, [loadProduct]);
// 登录后返回详情须带 token 重拉,否则白名单商品会一直空白
useDidShow(() => {
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 sharePayload = useMemo(
() =>
buildSceneSharePayload('productDetail', {
@@ -118,9 +195,23 @@ export default function ProductDetailPage() {
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() {
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()) {
goLogin(returnPath);
return;
@@ -132,7 +223,12 @@ export default function ProductDetailPage() {
async function goOnSitePickup() {
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()) {
goLogin(returnPath);
return;
@@ -151,9 +247,8 @@ export default function ProductDetailPage() {
);
}
const allowOnline = canBuyOnline(product);
const allowOnSite = canPickupOnSite(product);
const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price);
const allowOnline = canBuyOnline(fulfillment ?? {});
const allowOnSite = canPickupOnSite(fulfillment ?? {});
const carouselImages = getProductCarouselImages(product);
const detailImages = getProductDetailImages(product);
const detail = product.detailContent ?? {};
@@ -177,12 +272,45 @@ export default function ProductDetailPage() {
<View className="product-detail-info">
<View className="product-detail-price">
<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>
<Text className="product-detail-name">{product.name}</Text>
{product.subtitle ? (
<Text className="product-detail-subtitle">{product.subtitle}</Text>
) : 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-glow" />
@@ -192,7 +320,7 @@ export default function ProductDetailPage() {
</View>
<View className="product-detail-promo-title">
<Text> · </Text>
<BenefitFigure value={String(benefit)} size="sm" className="product-detail-promo-amount" />
<BenefitFigure value={String(displayBenefit)} size="sm" className="product-detail-promo-amount" />
</View>
</View>
<Text className="product-detail-promo-desc">
@@ -453,7 +453,7 @@ export default function StoreDetailPage() {
{benefitRule ? (
<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>
</View>
) : null}
@@ -375,3 +375,53 @@
font-size: 16px;
font-weight: 700;
}
.product-detail-specs {
margin: 12px 0 4px;
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;
}