This commit is contained in:
@@ -86,6 +86,22 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
.finally(() => setLoadingOptions(false));
|
.finally(() => setLoadingOptions(false));
|
||||||
}, [open]);
|
}, [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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !selectedProduct) return;
|
||||||
|
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
|
||||||
|
setDeliveryMode('ON_SITE_PICKUP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
|
||||||
|
setDeliveryMode('ADDRESS');
|
||||||
|
}
|
||||||
|
}, [open, selectedProduct, allowOnline, allowOnSite, deliveryMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !productId || quantity < 1 || step !== 'form') {
|
if (!open || !productId || quantity < 1 || step !== 'form') {
|
||||||
if (step === 'form') setPreview(null);
|
if (step === 'form') setPreview(null);
|
||||||
@@ -128,7 +144,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
setCreated(null);
|
setCreated(null);
|
||||||
setCodeUrl(null);
|
setCodeUrl(null);
|
||||||
setPayLoading(false);
|
setPayLoading(false);
|
||||||
setMockConfirming(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@@ -140,12 +155,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||||
if (!productId) return '请选择商品';
|
if (!productId) return '请选择商品';
|
||||||
if (deliveryMode === 'ADDRESS') {
|
if (deliveryMode === 'ADDRESS') {
|
||||||
|
if (!allowOnline) return '该商品不支持线上购买';
|
||||||
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||||
if (!addressDetail.trim()) return '请填写详细地址';
|
if (!addressDetail.trim()) return '请填写详细地址';
|
||||||
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||||
} else {
|
} else if (!allowOnSite) {
|
||||||
const product = options?.products.find((p) => p.id === productId);
|
return '该商品不支持现场提货';
|
||||||
if (product && product.allowOnSitePickup === false) return '该商品不支持现场提货';
|
|
||||||
}
|
}
|
||||||
if (!preview) return '请等待费用计算完成';
|
if (!preview) return '请等待费用计算完成';
|
||||||
return null;
|
return null;
|
||||||
@@ -329,20 +344,32 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Form.Item label="履约方式" required>
|
<Form.Item label="履约方式" required>
|
||||||
<Radio.Group
|
{allowOnline && allowOnSite ? (
|
||||||
value={deliveryMode}
|
<Radio.Group
|
||||||
onChange={(e) => {
|
value={deliveryMode}
|
||||||
setDeliveryMode(e.target.value);
|
onChange={(e) => {
|
||||||
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
|
setDeliveryMode(e.target.value);
|
||||||
}}
|
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
|
||||||
options={[
|
}}
|
||||||
{ value: 'ADDRESS', label: '配送到址' },
|
options={[
|
||||||
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
|
{ value: 'ADDRESS', label: '配送到址' },
|
||||||
]}
|
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
|
||||||
/>
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{!allowOnline && allowOnSite
|
||||||
|
? '该商品仅支持现场提货'
|
||||||
|
: allowOnline && !allowOnSite
|
||||||
|
? allowCrossCity
|
||||||
|
? '该商品仅支持配送到址(含跨城)'
|
||||||
|
: '该商品仅支持配送到址(不可跨城)'
|
||||||
|
: '该商品暂无可选履约方式'}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{deliveryMode === 'ADDRESS' ? (
|
{deliveryMode === 'ADDRESS' && allowOnline ? (
|
||||||
<>
|
<>
|
||||||
<Form.Item label="收货人(选填)">
|
<Form.Item label="收货人(选填)">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
@@ -53,6 +55,8 @@ type ProductFormValues = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
@@ -72,13 +76,17 @@ type UserPickRow = {
|
|||||||
|
|
||||||
function mapDetailToForm(d: Record<string, unknown>) {
|
function mapDetailToForm(d: Record<string, unknown>) {
|
||||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||||
|
const row = d as Row;
|
||||||
return {
|
return {
|
||||||
...d,
|
...d,
|
||||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||||
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
|
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
|
||||||
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
|
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
|
||||||
visibilityWhitelistEnabled: !!(d as Row).visibilityWhitelistEnabled,
|
allowOnlinePurchase: row.allowOnlinePurchase !== false,
|
||||||
visibilityPhones: ((d as Row).visibilityPhones ?? []) as string[],
|
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
|
||||||
|
allowOnSitePickup: !!row.allowOnSitePickup,
|
||||||
|
visibilityWhitelistEnabled: !!row.visibilityWhitelistEnabled,
|
||||||
|
visibilityPhones: (row.visibilityPhones ?? []) as string[],
|
||||||
storyTitle: detail.storyTitle ?? '',
|
storyTitle: detail.storyTitle ?? '',
|
||||||
storyText: detail.storyText ?? '',
|
storyText: detail.storyText ?? '',
|
||||||
features: detail.features?.length
|
features: detail.features?.length
|
||||||
@@ -109,7 +117,6 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skuCode: v.skuCode,
|
|
||||||
barcode69: v.barcode69,
|
barcode69: v.barcode69,
|
||||||
name: v.name,
|
name: v.name,
|
||||||
subtitle: v.subtitle,
|
subtitle: v.subtitle,
|
||||||
@@ -120,6 +127,9 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
status: v.status,
|
status: v.status,
|
||||||
sortOrder: v.sortOrder,
|
sortOrder: v.sortOrder,
|
||||||
allowOnSitePickup: !!v.allowOnSitePickup,
|
allowOnSitePickup: !!v.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: v.allowOnlinePurchase !== false,
|
||||||
|
allowCrossCityDelivery:
|
||||||
|
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
||||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||||
visibilityPhones,
|
visibilityPhones,
|
||||||
coverUrl: v.coverUrl,
|
coverUrl: v.coverUrl,
|
||||||
@@ -288,8 +298,8 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
|||||||
<>
|
<>
|
||||||
{mode === 'create' && (
|
{mode === 'create' && (
|
||||||
<>
|
<>
|
||||||
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}>
|
<Form.Item label="SKU">
|
||||||
<Input />
|
<Input disabled placeholder="保存后自动生成" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
||||||
<Input />
|
<Input />
|
||||||
@@ -320,6 +330,36 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
|||||||
<Form.Item name="sortOrder" label="排序">
|
<Form.Item name="sortOrder" label="排序">
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="allowOnlinePurchase"
|
||||||
|
label="允许线上购买"
|
||||||
|
valuePropName="checked"
|
||||||
|
extra="配送到址(同城)"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="开"
|
||||||
|
unCheckedChildren="关"
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
|
||||||
|
{() => (
|
||||||
|
<Form.Item
|
||||||
|
name="allowCrossCityDelivery"
|
||||||
|
label="允许跨城配送"
|
||||||
|
valuePropName="checked"
|
||||||
|
extra="须先开启线上购买"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="开"
|
||||||
|
unCheckedChildren="关"
|
||||||
|
disabled={!form.getFieldValue('allowOnlinePurchase')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -383,10 +423,19 @@ export default function ProductsPage() {
|
|||||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '现场取货',
|
title: '履约',
|
||||||
dataIndex: 'allowOnSitePickup',
|
key: 'fulfillment',
|
||||||
width: 90,
|
width: 160,
|
||||||
render: (v: boolean) => (v ? <Tag color="green">允许</Tag> : <Tag>否</Tag>),
|
render: (_, row) => (
|
||||||
|
<Space size={[0, 4]} wrap>
|
||||||
|
{row.allowOnlinePurchase !== false ? <Tag color="blue">线上</Tag> : null}
|
||||||
|
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
|
||||||
|
<Tag color="cyan">跨城</Tag>
|
||||||
|
) : null}
|
||||||
|
{row.allowOnSitePickup ? <Tag color="green">现场</Tag> : null}
|
||||||
|
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag>无</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
@@ -431,12 +480,16 @@ export default function ProductsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1240 }}
|
<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={720} 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();
|
||||||
|
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||||
|
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = buildProductPayload(v);
|
const payload = buildProductPayload(v);
|
||||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
@@ -471,6 +524,10 @@ export default function ProductsPage() {
|
|||||||
</Drawer>
|
</Drawer>
|
||||||
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||||
const v = await createForm.validateFields();
|
const v = await createForm.validateFields();
|
||||||
|
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||||
|
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = buildProductPayload(v);
|
const payload = buildProductPayload(v);
|
||||||
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
|
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
message.success('已创建');
|
message.success('已创建');
|
||||||
@@ -479,7 +536,8 @@ export default function ProductsPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}} width={720}>
|
}} width={720}>
|
||||||
<Form form={createForm} layout="vertical" initialValues={{
|
<Form form={createForm} layout="vertical" initialValues={{
|
||||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
|
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||||
|
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
|
||||||
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
||||||
carouselUrls: [''], detailImageUrls: [''],
|
carouselUrls: [''], detailImageUrls: [''],
|
||||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
|
|||||||
@@ -151,6 +151,22 @@ export default function ProxyOrderPage() {
|
|||||||
autoReceive,
|
autoReceive,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProduct) return;
|
||||||
|
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
|
||||||
|
setDeliveryMode('ON_SITE_PICKUP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
|
||||||
|
setDeliveryMode('ADDRESS');
|
||||||
|
}
|
||||||
|
}, [selectedProduct, allowOnline, allowOnSite, deliveryMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!productId || quantity < 1) {
|
if (!productId || quantity < 1) {
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
@@ -169,8 +185,14 @@ export default function ProxyOrderPage() {
|
|||||||
}),
|
}),
|
||||||
silent: true,
|
silent: true,
|
||||||
})
|
})
|
||||||
.then(setPreview)
|
.then((data) => {
|
||||||
.catch(() => setPreview(null))
|
setPreview(data);
|
||||||
|
setMsg('');
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setPreview(null);
|
||||||
|
setMsg(e instanceof Error ? e.message : '费用预览失败');
|
||||||
|
})
|
||||||
.finally(() => setPreviewLoading(false));
|
.finally(() => setPreviewLoading(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@@ -180,11 +202,14 @@ export default function ProxyOrderPage() {
|
|||||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||||
if (!productId) return '请选择商品';
|
if (!productId) return '请选择商品';
|
||||||
if (deliveryMode === 'ADDRESS') {
|
if (deliveryMode === 'ADDRESS') {
|
||||||
|
if (!allowOnline) return '该商品不支持线上购买';
|
||||||
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||||
if (!addressDetail.trim()) return '请填写详细地址';
|
if (!addressDetail.trim()) return '请填写详细地址';
|
||||||
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||||
|
} else if (!allowOnSite) {
|
||||||
|
return '该商品不支持现场提货';
|
||||||
}
|
}
|
||||||
if (!preview) return '请等待费用计算完成';
|
if (!preview) return msg || '请等待费用计算完成';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +330,6 @@ export default function ProxyOrderPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
|
||||||
const deliveryLabel =
|
const deliveryLabel =
|
||||||
preview?.deliveryType === 'ON_SITE_PICKUP'
|
preview?.deliveryType === 'ON_SITE_PICKUP'
|
||||||
? '现场提货'
|
? '现场提货'
|
||||||
@@ -477,25 +501,37 @@ export default function ProxyOrderPage() {
|
|||||||
|
|
||||||
<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-proxy-mode-tabs">
|
{allowOnline && allowOnSite ? (
|
||||||
<button
|
<div className="partner-proxy-mode-tabs">
|
||||||
type="button"
|
<button
|
||||||
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
|
type="button"
|
||||||
onClick={() => setDeliveryMode('ADDRESS')}
|
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
|
||||||
>
|
onClick={() => setDeliveryMode('ADDRESS')}
|
||||||
配送到址
|
>
|
||||||
</button>
|
配送到址
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
|
type="button"
|
||||||
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
|
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
|
||||||
>
|
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
|
||||||
现场提货
|
>
|
||||||
</button>
|
现场提货
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="label-md text-muted">
|
||||||
|
{!allowOnline && allowOnSite
|
||||||
|
? '该商品仅支持现场提货'
|
||||||
|
: allowOnline && !allowOnSite
|
||||||
|
? allowCrossCity
|
||||||
|
? '该商品仅支持配送到址(含跨城)'
|
||||||
|
: '该商品仅支持配送到址(不可跨城)'
|
||||||
|
: '该商品暂无可选履约方式'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{deliveryMode === 'ADDRESS' ? (
|
{deliveryMode === 'ADDRESS' && allowOnline ? (
|
||||||
<>
|
<>
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
<label className="partner-form-label">收货人(选填)</label>
|
<label className="partner-form-label">收货人(选填)</label>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
@@ -9,12 +9,30 @@ function fmtMoney(n: number) {
|
|||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FulfillmentFilter = 'ALL' | 'ONLINE' | 'CROSS_CITY' | 'ON_SITE';
|
||||||
|
|
||||||
|
const FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [
|
||||||
|
{ key: 'ALL', label: '全部' },
|
||||||
|
{ key: 'ONLINE', label: '线上' },
|
||||||
|
{ key: 'CROSS_CITY', label: '跨城' },
|
||||||
|
{ key: 'ON_SITE', label: '现场' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function matchFilter(p: PartnerProxyOrderProductOption, filter: FulfillmentFilter): boolean {
|
||||||
|
if (filter === 'ALL') return true;
|
||||||
|
if (filter === 'ONLINE') return p.allowOnlinePurchase !== false;
|
||||||
|
if (filter === 'CROSS_CITY') return p.allowCrossCityDelivery !== false;
|
||||||
|
return !!p.allowOnSitePickup;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProxyOrderProductsPage() {
|
export default function ProxyOrderProductsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const selectedId = searchParams.get('selected') || '';
|
const selectedId = searchParams.get('selected') || '';
|
||||||
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
|
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filter, setFilter] = useState<FulfillmentFilter>('ALL');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
||||||
@@ -23,6 +41,15 @@ export default function ProxyOrderProductsPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = keyword.trim().toLowerCase();
|
||||||
|
return products.filter((p) => {
|
||||||
|
if (!matchFilter(p, filter)) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return `${p.name} ${p.spec}`.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [products, keyword, filter]);
|
||||||
|
|
||||||
function pick(product: PartnerProxyOrderProductOption) {
|
function pick(product: PartnerProxyOrderProductOption) {
|
||||||
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
|
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
|
||||||
}
|
}
|
||||||
@@ -30,12 +57,32 @@ export default function ProxyOrderProductsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page partner-proxy-order-page">
|
<div className="page partner-proxy-order-page">
|
||||||
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
|
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
|
||||||
|
<div className="partner-proxy-product-toolbar">
|
||||||
|
<input
|
||||||
|
className="partner-input"
|
||||||
|
placeholder="搜索酒品名称 / 规格"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="partner-proxy-product-filters">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
type="button"
|
||||||
|
className={`partner-proxy-product-filter${filter === f.key ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setFilter(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<main className="partner-proxy-product-list">
|
<main className="partner-proxy-product-list">
|
||||||
{loading ? <p className="label-md text-muted">加载中…</p> : null}
|
{loading ? <p className="label-md text-muted">加载中…</p> : null}
|
||||||
{!loading && products.length === 0 ? (
|
{!loading && filtered.length === 0 ? (
|
||||||
<p className="label-md text-muted">暂无可售商品</p>
|
<p className="label-md text-muted">暂无符合条件的商品</p>
|
||||||
) : null}
|
) : null}
|
||||||
{products.map((p) => {
|
{filtered.map((p) => {
|
||||||
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
|
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
|
||||||
const active = p.id === selectedId;
|
const active = p.id === selectedId;
|
||||||
return (
|
return (
|
||||||
@@ -59,6 +106,11 @@ export default function ProxyOrderProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="partner-proxy-product-spec">{p.spec}</p>
|
<p className="partner-proxy-product-spec">{p.spec}</p>
|
||||||
<p className="partner-proxy-product-benefit">好客权益 ¥{fmtMoney(benefit)}</p>
|
<p className="partner-proxy-product-benefit">好客权益 ¥{fmtMoney(benefit)}</p>
|
||||||
|
<div className="partner-proxy-product-tags">
|
||||||
|
{p.allowOnlinePurchase !== false ? <span className="partner-proxy-tag">线上</span> : null}
|
||||||
|
{p.allowCrossCityDelivery !== false ? <span className="partner-proxy-tag">跨城</span> : null}
|
||||||
|
{p.allowOnSitePickup ? <span className="partner-proxy-tag">现场</span> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3374,6 +3374,50 @@ body {
|
|||||||
color: var(--color-muted, #999);
|
color: var(--color-muted, #999);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-toolbar {
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-filter {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-filter.is-active {
|
||||||
|
border-color: var(--color-heritage-red);
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
background: rgba(166, 29, 36, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
color: var(--color-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
.partner-proxy-product-list {
|
.partner-proxy-product-list {
|
||||||
padding: 0 16px 24px;
|
padding: 0 16px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
|
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
|
||||||
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
import Taro, {
|
||||||
|
useDidShow,
|
||||||
|
usePageScroll,
|
||||||
|
usePullDownRefresh,
|
||||||
|
useShareAppMessage,
|
||||||
|
useShareTimeline,
|
||||||
|
} from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import TabMainHeader from '../../components/TabMainHeader';
|
import TabMainHeader from '../../components/TabMainHeader';
|
||||||
import CouponBadge from '../../components/CouponBadge';
|
import CouponBadge from '../../components/CouponBadge';
|
||||||
@@ -29,8 +35,26 @@ type Product = {
|
|||||||
carouselUrls?: string[] | null;
|
carouselUrls?: string[] | null;
|
||||||
aromaType: string;
|
aromaType: string;
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FulfillmentFilter = 'ALL' | 'ONLINE' | 'CROSS_CITY' | 'ON_SITE';
|
||||||
|
|
||||||
|
const FULFILLMENT_FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [
|
||||||
|
{ key: 'ALL', label: '全部' },
|
||||||
|
{ key: 'ONLINE', label: '线上' },
|
||||||
|
{ key: 'CROSS_CITY', label: '跨城' },
|
||||||
|
{ key: 'ON_SITE', label: '现场' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function matchFulfillmentFilter(p: Product, filter: FulfillmentFilter): boolean {
|
||||||
|
if (filter === 'ALL') return true;
|
||||||
|
if (filter === 'ONLINE') return p.allowOnlinePurchase !== false;
|
||||||
|
if (filter === 'CROSS_CITY') return p.allowCrossCityDelivery !== false;
|
||||||
|
return !!p.allowOnSitePickup;
|
||||||
|
}
|
||||||
|
|
||||||
type MiniHomeConfig = {
|
type MiniHomeConfig = {
|
||||||
banners: string[];
|
banners: string[];
|
||||||
footerUrl: string | null;
|
footerUrl: string | null;
|
||||||
@@ -42,13 +66,26 @@ const AROMA_TABS = [
|
|||||||
{ key: 'NONGXIANG', label: '浓香型' },
|
{ key: 'NONGXIANG', label: '浓香型' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
type AromaKey = (typeof AROMA_TABS)[number]['key'];
|
||||||
|
|
||||||
|
/** sticky 香型导航高度(与 CSS 大致一致),锚点滚动时预留 */
|
||||||
|
const AROMA_NAV_OFFSET_PX = 44;
|
||||||
|
|
||||||
|
function aromaSectionId(key: AromaKey) {
|
||||||
|
return `aroma-section-${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [tab, setTab] = useState('QINGXIANG');
|
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
|
||||||
|
const [fulfillmentFilter, setFulfillmentFilter] = useState<FulfillmentFilter>('ALL');
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [displayCity, setDisplayCity] = useState('郑州市');
|
const [displayCity, setDisplayCity] = useState('郑州市');
|
||||||
const [cityCode, setCityCode] = useState('410100');
|
const [cityCode, setCityCode] = useState('410100');
|
||||||
const [miniHome, setMiniHome] = useState<MiniHomeConfig>({ banners: [], footerUrl: null });
|
const [miniHome, setMiniHome] = useState<MiniHomeConfig>({ banners: [], footerUrl: null });
|
||||||
|
const scrollingToRef = useRef<AromaKey | null>(null);
|
||||||
|
const scrollLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastScrollSyncAtRef = useRef(0);
|
||||||
|
|
||||||
const loadMiniHome = useCallback(() => {
|
const loadMiniHome = useCallback(() => {
|
||||||
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
|
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
|
||||||
@@ -126,7 +163,24 @@ export default function HomePage() {
|
|||||||
Taro.navigateTo({ url: returnPath });
|
Taro.navigateTo({ url: returnPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = products.filter((p) => p.aromaType === tab);
|
const filteredProducts = useMemo(
|
||||||
|
() => products.filter((p) => matchFulfillmentFilter(p, fulfillmentFilter)),
|
||||||
|
[products, fulfillmentFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const productsByAroma = useMemo(() => {
|
||||||
|
const map: Record<AromaKey, Product[]> = {
|
||||||
|
QINGXIANG: [],
|
||||||
|
JIANGXIANG: [],
|
||||||
|
NONGXIANG: [],
|
||||||
|
};
|
||||||
|
for (const p of filteredProducts) {
|
||||||
|
const key = p.aromaType as AromaKey;
|
||||||
|
if (key in map) map[key].push(p);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [filteredProducts]);
|
||||||
|
|
||||||
const banners = miniHome.banners;
|
const banners = miniHome.banners;
|
||||||
const footerUrl = miniHome.footerUrl;
|
const footerUrl = miniHome.footerUrl;
|
||||||
|
|
||||||
@@ -147,6 +201,102 @@ export default function HomePage() {
|
|||||||
imageUrl: sharePayload.imgUrl,
|
imageUrl: sharePayload.imgUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function scrollToAroma(key: AromaKey) {
|
||||||
|
setActiveAroma(key);
|
||||||
|
scrollingToRef.current = key;
|
||||||
|
if (scrollLockTimerRef.current) clearTimeout(scrollLockTimerRef.current);
|
||||||
|
scrollLockTimerRef.current = setTimeout(() => {
|
||||||
|
scrollingToRef.current = null;
|
||||||
|
}, 450);
|
||||||
|
|
||||||
|
const query = Taro.createSelectorQuery();
|
||||||
|
query.select(`#${aromaSectionId(key)}`).boundingClientRect();
|
||||||
|
query.selectViewport().scrollOffset();
|
||||||
|
query.exec((res) => {
|
||||||
|
const rect = res?.[0] as { top?: number } | undefined;
|
||||||
|
const viewport = res?.[1] as { scrollTop?: number } | undefined;
|
||||||
|
if (rect?.top == null || viewport?.scrollTop == null) return;
|
||||||
|
const scrollTop = Math.max(0, viewport.scrollTop + rect.top - AROMA_NAV_OFFSET_PX);
|
||||||
|
void Taro.pageScrollTo({ scrollTop, duration: 280 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
usePageScroll(() => {
|
||||||
|
if (scrollingToRef.current) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastScrollSyncAtRef.current < 80) return;
|
||||||
|
lastScrollSyncAtRef.current = now;
|
||||||
|
const query = Taro.createSelectorQuery();
|
||||||
|
AROMA_TABS.forEach((t) => {
|
||||||
|
query.select(`#${aromaSectionId(t.key)}`).boundingClientRect();
|
||||||
|
});
|
||||||
|
query.exec((rects) => {
|
||||||
|
if (!Array.isArray(rects) || rects.length === 0) return;
|
||||||
|
let next: AromaKey = AROMA_TABS[0].key;
|
||||||
|
for (let i = 0; i < AROMA_TABS.length; i++) {
|
||||||
|
const rect = rects[i] as { top?: number } | null;
|
||||||
|
if (!rect || rect.top == null) continue;
|
||||||
|
// 区块顶进入导航下方一带时视为当前香型
|
||||||
|
if (rect.top <= AROMA_NAV_OFFSET_PX + 24) {
|
||||||
|
next = AROMA_TABS[i].key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setActiveAroma((prev) => (prev === next ? prev : next));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderProductCard(p: Product) {
|
||||||
|
const thumb = getProductMainImage(p);
|
||||||
|
const spec = p.subtitle || p.spec || '';
|
||||||
|
return (
|
||||||
|
<View key={p.id} className="home-product-card">
|
||||||
|
<View className="home-product-card-inner" onClick={() => openProductDetail(p.id)}>
|
||||||
|
<View className="home-product-thumb-wrap">
|
||||||
|
{thumb ? (
|
||||||
|
<Image className="home-product-thumb" src={thumb} mode="aspectFill" />
|
||||||
|
) : (
|
||||||
|
<View className="home-product-thumb home-product-thumb--empty" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="home-product-main">
|
||||||
|
<View className="home-product-row">
|
||||||
|
<Text className="home-product-name">{p.name}</Text>
|
||||||
|
<Text className="home-product-price">¥{Number(p.price).toFixed(0)}</Text>
|
||||||
|
</View>
|
||||||
|
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
|
||||||
|
<View className="home-product-footer">
|
||||||
|
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
||||||
|
</View>
|
||||||
|
<View className="home-product-actions">
|
||||||
|
{p.allowOnSitePickup ? (
|
||||||
|
<Text
|
||||||
|
className="home-pickup-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
void goOnSitePickup(p.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
现场取货
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{p.allowOnlinePurchase !== false ? (
|
||||||
|
<Text
|
||||||
|
className="home-buy-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
openProductDetail(p.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
立即购买
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="tab" className="home-page no-tab-header">
|
<PageShell variant="tab" className="home-page no-tab-header">
|
||||||
<WechatShareReady payload={sharePayload} />
|
<WechatShareReady payload={sharePayload} />
|
||||||
@@ -175,8 +325,8 @@ export default function HomePage() {
|
|||||||
{AROMA_TABS.map((t) => (
|
{AROMA_TABS.map((t) => (
|
||||||
<Text
|
<Text
|
||||||
key={t.key}
|
key={t.key}
|
||||||
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
|
className={`home-aroma-tab${activeAroma === t.key ? ' home-aroma-tab--active' : ''}`}
|
||||||
onClick={() => setTab(t.key)}
|
onClick={() => scrollToAroma(t.key)}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -185,64 +335,38 @@ export default function HomePage() {
|
|||||||
<Text className="home-aroma-city">{displayCity}</Text>
|
<Text className="home-aroma-city">{displayCity}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="home-fulfillment-filters">
|
||||||
|
{FULFILLMENT_FILTERS.map((f) => (
|
||||||
|
<Text
|
||||||
|
key={f.key}
|
||||||
|
className={`home-fulfillment-chip${fulfillmentFilter === f.key ? ' home-fulfillment-chip--active' : ''}`}
|
||||||
|
onClick={() => setFulfillmentFilter(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="home-product-list">
|
<View className="home-product-list">
|
||||||
{loading ? <View className="home-empty">加载中…</View> : null}
|
{loading ? <View className="home-empty">加载中…</View> : null}
|
||||||
{!loading && products.length === 0 ? (
|
{!loading && products.length === 0 ? (
|
||||||
<View className="home-empty">当前城市暂无在售商品</View>
|
<View className="home-empty">当前城市暂无在售商品</View>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && products.length > 0 && filtered.length === 0 ? (
|
{!loading && products.length > 0 && filteredProducts.length === 0 ? (
|
||||||
<View className="home-empty">该香型暂未上线</View>
|
<View className="home-empty">暂无符合履约方式的商品</View>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
filtered.map((p) => {
|
filteredProducts.length > 0 &&
|
||||||
const thumb = getProductMainImage(p);
|
AROMA_TABS.map((t) => {
|
||||||
const spec = p.subtitle || p.spec || '';
|
const list = productsByAroma[t.key];
|
||||||
return (
|
return (
|
||||||
<View key={p.id} className="home-product-card">
|
<View key={t.key} id={aromaSectionId(t.key)} className="home-aroma-section">
|
||||||
<View
|
<Text className="home-aroma-section-title">{t.label}</Text>
|
||||||
className="home-product-card-inner"
|
{list.length === 0 ? (
|
||||||
onClick={() => openProductDetail(p.id)}
|
<View className="home-empty home-empty--section">该香型暂未上线</View>
|
||||||
>
|
) : (
|
||||||
<View className="home-product-thumb-wrap">
|
list.map((p) => renderProductCard(p))
|
||||||
{thumb ? (
|
)}
|
||||||
<Image className="home-product-thumb" src={thumb} mode="aspectFill" />
|
|
||||||
) : (
|
|
||||||
<View className="home-product-thumb home-product-thumb--empty" />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View className="home-product-main">
|
|
||||||
<View className="home-product-row">
|
|
||||||
<Text className="home-product-name">{p.name}</Text>
|
|
||||||
<Text className="home-product-price">¥{Number(p.price).toFixed(0)}</Text>
|
|
||||||
</View>
|
|
||||||
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
|
|
||||||
<View className="home-product-footer">
|
|
||||||
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
|
||||||
</View>
|
|
||||||
<View className="home-product-actions">
|
|
||||||
{p.allowOnSitePickup ? (
|
|
||||||
<Text
|
|
||||||
className="home-pickup-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation?.();
|
|
||||||
void goOnSitePickup(p.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
现场取货
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Text
|
|
||||||
className="home-buy-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation?.();
|
|
||||||
openProductDetail(p.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
立即购买
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ type Product = ProductImageSource & {
|
|||||||
benefitAmount?: number;
|
benefitAmount?: number;
|
||||||
benefitDisplay?: number;
|
benefitDisplay?: number;
|
||||||
detailContent?: ProductDetailContentDto | null;
|
detailContent?: ProductDetailContentDto | null;
|
||||||
|
allowOnSitePickup?: boolean;
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProductDetailPage() {
|
export default function ProductDetailPage() {
|
||||||
@@ -92,6 +95,21 @@ export default function ProductDetailPage() {
|
|||||||
Taro.navigateTo({ url: returnPath });
|
Taro.navigateTo({ url: returnPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goOnSitePickup() {
|
||||||
|
if (!productId) return;
|
||||||
|
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
goLogin(returnPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ready = await ensurePayReady(returnPath);
|
||||||
|
if (!ready) return;
|
||||||
|
Taro.navigateTo({ url: returnPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowOnline = product?.allowOnlinePurchase !== false;
|
||||||
|
const allowOnSite = !!product?.allowOnSitePickup;
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return (
|
return (
|
||||||
<PageShell variant="scroll" className="product-detail-page">
|
<PageShell variant="scroll" className="product-detail-page">
|
||||||
@@ -199,9 +217,21 @@ export default function ProductDetailPage() {
|
|||||||
<Image className="product-detail-bar-home-icon" src={iconHome} mode="aspectFit" />
|
<Image className="product-detail-bar-home-icon" src={iconHome} mode="aspectFit" />
|
||||||
<Text className="product-detail-bar-home-label">首页</Text>
|
<Text className="product-detail-bar-home-label">首页</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="product-detail-buy-btn" onClick={() => void goBuy()}>
|
{allowOnSite ? (
|
||||||
<Text className="product-detail-buy-btn-text">立即购买</Text>
|
<View className="product-detail-pickup-btn" onClick={() => void goOnSitePickup()}>
|
||||||
</View>
|
<Text className="product-detail-pickup-btn-text">现场取货</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{allowOnline ? (
|
||||||
|
<View className="product-detail-buy-btn" onClick={() => void goBuy()}>
|
||||||
|
<Text className="product-detail-buy-btn-text">立即购买</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{!allowOnline && !allowOnSite ? (
|
||||||
|
<View className="product-detail-buy-btn product-detail-buy-btn--disabled">
|
||||||
|
<Text className="product-detail-buy-btn-text">暂不可购</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,6 +103,30 @@
|
|||||||
border-bottom-color: var(--color-heritage-red);
|
border-bottom-color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-fulfillment-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px var(--space-page) 2px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-fulfillment-chip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-fulfillment-chip--active {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
background: rgba(179, 38, 30, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.home-product-list {
|
.home-product-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -110,6 +134,32 @@
|
|||||||
padding: 8px var(--space-page) 12px;
|
padding: 8px var(--space-page) 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-aroma-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
scroll-margin-top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-aroma-section + .home-aroma-section {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-aroma-section-title {
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
padding: 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-empty--section {
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.home-product-card {
|
.home-product-card {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -301,6 +301,25 @@
|
|||||||
color: var(--color-on-surface-variant);
|
color: var(--color-on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-pickup-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-heritage-red);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-pickup-btn-text {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.product-detail-buy-btn {
|
.product-detail-buy-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
@@ -312,6 +331,12 @@
|
|||||||
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.2);
|
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-buy-btn--disabled {
|
||||||
|
background: #c4c4c4;
|
||||||
|
box-shadow: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.product-detail-buy-btn-text {
|
.product-detail-buy-btn-text {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export interface ProductDto {
|
|||||||
detailContent?: ProductDetailContentDto | null;
|
detailContent?: ProductDetailContentDto | null;
|
||||||
/** 是否允许现场取货下单 */
|
/** 是否允许现场取货下单 */
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
|
/** 是否允许配送到址(同城线上购买) */
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
/** 是否允许跨城配送 */
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductDetailFeatureDto {
|
export interface ProductDetailFeatureDto {
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export type PartnerProxyOrderProductOption = {
|
|||||||
benefitAmount: number | null;
|
benefitAmount: number | null;
|
||||||
coverUrl?: string | null;
|
coverUrl?: string | null;
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartnerProxyOrderPromoOption = {
|
export type PartnerProxyOrderPromoOption = {
|
||||||
|
|||||||
@@ -593,6 +593,10 @@ model CommonProductItem {
|
|||||||
status ProductStatus @default(DRAFT)
|
status ProductStatus @default(DRAFT)
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup")
|
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")
|
||||||
/// Online test: only listed phones can see/buy when enabled
|
/// Online test: only listed phones can see/buy when enabled
|
||||||
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
|
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
|
||||||
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
|
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
|
||||||
|
|||||||
@@ -24,6 +24,37 @@ function normalizePhones(phones?: string[]): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SKU_AUTO_PREFIX = 'DK';
|
||||||
|
const SKU_AUTO_PAD = 6;
|
||||||
|
|
||||||
|
/** 解析履约开关:无线上则强制不可跨城;须至少线上或现场之一 */
|
||||||
|
function resolveFulfillmentFlags(input: {
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
|
allowOnSitePickup?: boolean;
|
||||||
|
defaults?: {
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const d = input.defaults ?? {
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
};
|
||||||
|
const allowOnlinePurchase = input.allowOnlinePurchase ?? d.allowOnlinePurchase;
|
||||||
|
const allowOnSitePickup = input.allowOnSitePickup ?? d.allowOnSitePickup;
|
||||||
|
let allowCrossCityDelivery = input.allowCrossCityDelivery ?? d.allowCrossCityDelivery;
|
||||||
|
if (!allowOnlinePurchase) {
|
||||||
|
allowCrossCityDelivery = false;
|
||||||
|
}
|
||||||
|
if (!allowOnlinePurchase && !allowOnSitePickup) {
|
||||||
|
throw new BadRequestException('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||||
|
}
|
||||||
|
return { allowOnlinePurchase, allowCrossCityDelivery, allowOnSitePickup };
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminProductsService {
|
export class AdminProductsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -96,39 +127,44 @@ export class AdminProductsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateProductDto) {
|
async create(dto: CreateProductDto) {
|
||||||
const exists = await this.prisma.commonProductItem.findFirst({
|
const barcodeExists = await this.prisma.commonProductItem.findFirst({
|
||||||
where: { OR: [{ skuCode: dto.skuCode }, { barcode69: dto.barcode69 }] },
|
where: { barcode69: dto.barcode69 },
|
||||||
|
});
|
||||||
|
if (barcodeExists) throw new BadRequestException('69 码已存在');
|
||||||
|
|
||||||
|
const flags = resolveFulfillmentFlags({
|
||||||
|
allowOnlinePurchase: dto.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: dto.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: dto.allowOnSitePickup,
|
||||||
});
|
});
|
||||||
if (exists) throw new BadRequestException('SKU 或 69 码已存在');
|
|
||||||
|
|
||||||
const phones = normalizePhones(dto.visibilityPhones);
|
const phones = normalizePhones(dto.visibilityPhones);
|
||||||
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
||||||
|
|
||||||
const product = await this.prisma.commonProductItem.create({
|
const product = await this.createWithGeneratedSku({
|
||||||
data: {
|
barcode69: dto.barcode69,
|
||||||
skuCode: dto.skuCode,
|
name: dto.name,
|
||||||
barcode69: dto.barcode69,
|
subtitle: dto.subtitle,
|
||||||
name: dto.name,
|
aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG',
|
||||||
subtitle: dto.subtitle,
|
spec: dto.spec,
|
||||||
aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG',
|
price: dto.price,
|
||||||
spec: dto.spec,
|
benefitAmount: dto.benefitAmount ?? dto.price,
|
||||||
price: dto.price,
|
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
|
||||||
benefitAmount: dto.benefitAmount ?? dto.price,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
|
allowOnSitePickup: flags.allowOnSitePickup,
|
||||||
sortOrder: dto.sortOrder ?? 0,
|
allowOnlinePurchase: flags.allowOnlinePurchase,
|
||||||
allowOnSitePickup: dto.allowOnSitePickup ?? false,
|
allowCrossCityDelivery: flags.allowCrossCityDelivery,
|
||||||
visibilityWhitelistEnabled: whitelistEnabled,
|
visibilityWhitelistEnabled: whitelistEnabled,
|
||||||
...(dto.detailContent !== undefined
|
...(dto.detailContent !== undefined
|
||||||
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
||||||
: {}),
|
: {}),
|
||||||
...(phones.length
|
...(phones.length
|
||||||
? {
|
? {
|
||||||
visibilityPhones: {
|
visibilityPhones: {
|
||||||
create: phones.map((phone) => ({ phone })),
|
create: phones.map((phone) => ({ phone })),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl) {
|
||||||
@@ -143,7 +179,26 @@ export class AdminProductsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateProductDto) {
|
async update(id: bigint, dto: UpdateProductDto) {
|
||||||
await this.detail(id);
|
const existing = await this.prisma.commonProductItem.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('商品不存在');
|
||||||
|
|
||||||
|
const fulfillmentTouched =
|
||||||
|
dto.allowOnlinePurchase !== undefined ||
|
||||||
|
dto.allowCrossCityDelivery !== undefined ||
|
||||||
|
dto.allowOnSitePickup !== undefined;
|
||||||
|
const flags = fulfillmentTouched
|
||||||
|
? resolveFulfillmentFlags({
|
||||||
|
allowOnlinePurchase: dto.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: dto.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: dto.allowOnSitePickup,
|
||||||
|
defaults: {
|
||||||
|
allowOnlinePurchase: existing.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: existing.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: existing.allowOnSitePickup,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
await this.prisma.commonProductItem.update({
|
await this.prisma.commonProductItem.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -154,7 +209,13 @@ export class AdminProductsService {
|
|||||||
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
|
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
|
||||||
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
|
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
|
||||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
...(dto.allowOnSitePickup !== undefined ? { allowOnSitePickup: dto.allowOnSitePickup } : {}),
|
...(flags
|
||||||
|
? {
|
||||||
|
allowOnSitePickup: flags.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: flags.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: flags.allowCrossCityDelivery,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(dto.visibilityWhitelistEnabled !== undefined
|
...(dto.visibilityWhitelistEnabled !== undefined
|
||||||
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -196,6 +257,46 @@ export class AdminProductsService {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 生成 DK + 6 位自增 SKU,冲突重试 */
|
||||||
|
private async nextAutoSkuCode(): Promise<string> {
|
||||||
|
const rows = await this.prisma.commonProductItem.findMany({
|
||||||
|
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
});
|
||||||
|
let maxSeq = 0;
|
||||||
|
const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const m = re.exec(row.skuCode);
|
||||||
|
if (!m) continue;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
||||||
|
}
|
||||||
|
return `${SKU_AUTO_PREFIX}${String(maxSeq + 1).padStart(SKU_AUTO_PAD, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWithGeneratedSku(
|
||||||
|
data: Omit<Prisma.CommonProductItemCreateInput, 'skuCode'>,
|
||||||
|
) {
|
||||||
|
for (let attempt = 0; attempt < 8; attempt++) {
|
||||||
|
const skuCode = await this.nextAutoSkuCode();
|
||||||
|
try {
|
||||||
|
return await this.prisma.commonProductItem.create({
|
||||||
|
data: { ...data, skuCode },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||||
|
const target = err.meta?.target;
|
||||||
|
const fields = Array.isArray(target) ? target.map(String) : [String(target ?? '')];
|
||||||
|
if (fields.some((f) => f.includes('sku'))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BadRequestException('SKU 生成失败,请重试');
|
||||||
|
}
|
||||||
|
|
||||||
private async syncVisibilityPhones(productId: bigint, phones: string[]) {
|
private async syncVisibilityPhones(productId: bigint, phones: string[]) {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } });
|
await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } });
|
||||||
|
|||||||
@@ -1068,9 +1068,10 @@ export class SaveHqAccountPermissionsDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreateProductDto {
|
export class CreateProductDto {
|
||||||
|
/** 可选;创建时由服务端自增生成,忽略客户端传入 */
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
skuCode?: string;
|
||||||
skuCode: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@@ -1110,6 +1111,14 @@ export class CreateProductDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
|
|
||||||
/** 开启后仅白名单手机号在 C 端可见/可购 */
|
/** 开启后仅白名单手机号在 C 端可见/可购 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@@ -1173,6 +1182,14 @@ export class UpdateProductDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
|
|
||||||
/** 开启后仅白名单手机号在 C 端可见/可购 */
|
/** 开启后仅白名单手机号在 C 端可见/可购 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -86,6 +86,22 @@ export class TradeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!onSitePickup) {
|
||||||
|
const allowOnline = product.allowOnlinePurchase !== false;
|
||||||
|
const allowCross = product.allowCrossCityDelivery !== false;
|
||||||
|
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||||
|
throw new BadRequestException('该商品不支持线上购买');
|
||||||
|
}
|
||||||
|
if (deliveryType === 'CROSS_CITY') {
|
||||||
|
if (!allowOnline) {
|
||||||
|
throw new BadRequestException('该商品不支持线上购买');
|
||||||
|
}
|
||||||
|
if (!allowCross) {
|
||||||
|
throw new BadRequestException('该商品不支持跨城配送');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const check = validateMinPurchase(
|
const check = validateMinPurchase(
|
||||||
deliveryType,
|
deliveryType,
|
||||||
body.quantity,
|
body.quantity,
|
||||||
@@ -1121,6 +1137,9 @@ export class TradeService {
|
|||||||
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
||||||
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
||||||
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
|
||||||
|
allowCrossCityDelivery:
|
||||||
|
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
|
||||||
})),
|
})),
|
||||||
promoCodes,
|
promoCodes,
|
||||||
stores: stores.map((s) => ({
|
stores: stores.map((s) => ({
|
||||||
@@ -1166,6 +1185,19 @@ export class TradeService {
|
|||||||
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
|
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
|
||||||
deliveryType = 'CROSS_CITY';
|
deliveryType = 'CROSS_CITY';
|
||||||
}
|
}
|
||||||
|
const allowOnline = product.allowOnlinePurchase !== false;
|
||||||
|
const allowCross = product.allowCrossCityDelivery !== false;
|
||||||
|
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||||
|
throw new BadRequestException('该商品不支持线上购买');
|
||||||
|
}
|
||||||
|
if (deliveryType === 'CROSS_CITY') {
|
||||||
|
if (!allowOnline) {
|
||||||
|
throw new BadRequestException('该商品不支持线上购买');
|
||||||
|
}
|
||||||
|
if (!allowCross) {
|
||||||
|
throw new BadRequestException('该商品不支持跨城配送');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const check = validateMinPurchase(
|
const check = validateMinPurchase(
|
||||||
@@ -1486,6 +1518,9 @@ export class TradeService {
|
|||||||
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
||||||
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
||||||
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
|
||||||
|
allowCrossCityDelivery:
|
||||||
|
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
|
||||||
})),
|
})),
|
||||||
promoCodes,
|
promoCodes,
|
||||||
stores: [],
|
stores: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user