merge(dev): product fulfillment flags and auto SKU
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-02 18:10:31 +08:00
15 changed files with 754 additions and 145 deletions
@@ -86,6 +86,22 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
.finally(() => setLoadingOptions(false));
}, [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(() => {
if (!open || !productId || quantity < 1 || step !== 'form') {
if (step === 'form') setPreview(null);
@@ -128,7 +144,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
setCreated(null);
setCodeUrl(null);
setPayLoading(false);
setMockConfirming(false);
}
function handleClose() {
@@ -140,12 +155,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!productId) return '请选择商品';
if (deliveryMode === 'ADDRESS') {
if (!allowOnline) return '该商品不支持线上购买';
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
} else {
const product = options?.products.find((p) => p.id === productId);
if (product && product.allowOnSitePickup === false) return '该商品不支持现场提货';
} else if (!allowOnSite) {
return '该商品不支持现场提货';
}
if (!preview) return '请等待费用计算完成';
return null;
@@ -329,20 +344,32 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
) : null}
<Form.Item label="履约方式" required>
<Radio.Group
value={deliveryMode}
onChange={(e) => {
setDeliveryMode(e.target.value);
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
}}
options={[
{ value: 'ADDRESS', label: '配送到址' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
]}
/>
{allowOnline && allowOnSite ? (
<Radio.Group
value={deliveryMode}
onChange={(e) => {
setDeliveryMode(e.target.value);
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
}}
options={[
{ value: 'ADDRESS', label: '配送到址' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
]}
/>
) : (
<Typography.Text type="secondary">
{!allowOnline && allowOnSite
? '该商品仅支持现场提货'
: allowOnline && !allowOnSite
? allowCrossCity
? '该商品仅支持配送到址(含跨城)'
: '该商品仅支持配送到址(不可跨城)'
: '该商品暂无可选履约方式'}
</Typography.Text>
)}
</Form.Item>
{deliveryMode === 'ADDRESS' ? (
{deliveryMode === 'ADDRESS' && allowOnline ? (
<>
<Form.Item label="收货人(选填)">
<Input
+71 -13
View File
@@ -32,6 +32,8 @@ type Row = {
status: string;
sortOrder: number;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
mainImageUrl?: string | null;
@@ -53,6 +55,8 @@ type ProductFormValues = {
status?: string;
sortOrder?: number;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
coverUrl?: string;
@@ -72,13 +76,17 @@ type UserPickRow = {
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row;
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
visibilityWhitelistEnabled: !!(d as Row).visibilityWhitelistEnabled,
visibilityPhones: ((d as Row).visibilityPhones ?? []) as string[],
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
allowOnlinePurchase: row.allowOnlinePurchase !== false,
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
allowOnSitePickup: !!row.allowOnSitePickup,
visibilityWhitelistEnabled: !!row.visibilityWhitelistEnabled,
visibilityPhones: (row.visibilityPhones ?? []) as string[],
storyTitle: detail.storyTitle ?? '',
storyText: detail.storyText ?? '',
features: detail.features?.length
@@ -109,7 +117,6 @@ function buildProductPayload(v: ProductFormValues) {
.filter(Boolean);
return {
skuCode: v.skuCode,
barcode69: v.barcode69,
name: v.name,
subtitle: v.subtitle,
@@ -120,6 +127,9 @@ function buildProductPayload(v: ProductFormValues) {
status: v.status,
sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
allowOnlinePurchase: v.allowOnlinePurchase !== false,
allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones,
coverUrl: v.coverUrl,
@@ -288,8 +298,8 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<>
{mode === 'create' && (
<>
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}>
<Input />
<Form.Item label="SKU">
<Input disabled placeholder="保存后自动生成" />
</Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
<Input />
@@ -320,6 +330,36 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</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">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
@@ -383,10 +423,19 @@ export default function ProductsPage() {
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>,
},
{
title: '现场取货',
dataIndex: 'allowOnSitePickup',
width: 90,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
title: '履约',
key: 'fulfillment',
width: 160,
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: 'createdAt', width: 160, render: fmtTime },
@@ -431,12 +480,16 @@ export default function ProductsPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</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); } }} />
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
@@ -471,6 +524,10 @@ export default function ProductsPage() {
</Drawer>
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
message.success('已创建');
@@ -479,7 +536,8 @@ export default function ProductsPage() {
void reload();
}} width={720}>
<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: [],
carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }],
+57 -21
View File
@@ -151,6 +151,22 @@ export default function ProxyOrderPage() {
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(() => {
if (!productId || quantity < 1) {
setPreview(null);
@@ -169,8 +185,14 @@ export default function ProxyOrderPage() {
}),
silent: true,
})
.then(setPreview)
.catch(() => setPreview(null))
.then((data) => {
setPreview(data);
setMsg('');
})
.catch((e) => {
setPreview(null);
setMsg(e instanceof Error ? e.message : '费用预览失败');
})
.finally(() => setPreviewLoading(false));
}, 300);
return () => clearTimeout(timer);
@@ -180,11 +202,14 @@ export default function ProxyOrderPage() {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!productId) return '请选择商品';
if (deliveryMode === 'ADDRESS') {
if (!allowOnline) return '该商品不支持线上购买';
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
} else if (!allowOnSite) {
return '该商品不支持现场提货';
}
if (!preview) return '请等待费用计算完成';
if (!preview) return msg || '请等待费用计算完成';
return null;
}
@@ -305,7 +330,6 @@ export default function ProxyOrderPage() {
}
}
const selectedProduct = options?.products.find((p) => p.id === productId);
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
@@ -477,25 +501,37 @@ export default function ProxyOrderPage() {
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ADDRESS')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
>
</button>
</div>
{allowOnline && allowOnSite ? (
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ADDRESS')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
>
</button>
</div>
) : (
<p className="label-md text-muted">
{!allowOnline && allowOnSite
? '该商品仅支持现场提货'
: allowOnline && !allowOnSite
? allowCrossCity
? '该商品仅支持配送到址(含跨城)'
: '该商品仅支持配送到址(不可跨城)'
: '该商品暂无可选履约方式'}
</p>
)}
</section>
{deliveryMode === 'ADDRESS' ? (
{deliveryMode === 'ADDRESS' && allowOnline ? (
<>
<section className="partner-form-section">
<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 PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
@@ -9,12 +9,30 @@ function fmtMoney(n: number) {
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() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const selectedId = searchParams.get('selected') || '';
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState('');
const [filter, setFilter] = useState<FulfillmentFilter>('ALL');
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
@@ -23,6 +41,15 @@ export default function ProxyOrderProductsPage() {
.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) {
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
}
@@ -30,12 +57,32 @@ export default function ProxyOrderProductsPage() {
return (
<div className="page partner-proxy-order-page">
<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">
{loading ? <p className="label-md text-muted"></p> : null}
{!loading && products.length === 0 ? (
<p className="label-md text-muted"></p>
{!loading && filtered.length === 0 ? (
<p className="label-md text-muted"></p>
) : null}
{products.map((p) => {
{filtered.map((p) => {
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
const active = p.id === selectedId;
return (
@@ -59,6 +106,11 @@ export default function ProxyOrderProductsPage() {
</div>
<p className="partner-proxy-product-spec">{p.spec}</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>
</button>
);
+44
View File
@@ -3374,6 +3374,50 @@ body {
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 {
padding: 0 16px 24px;
display: flex;
+180 -56
View File
@@ -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 Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import Taro, {
useDidShow,
usePageScroll,
usePullDownRefresh,
useShareAppMessage,
useShareTimeline,
} from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge';
@@ -29,8 +35,26 @@ type Product = {
carouselUrls?: string[] | null;
aromaType: string;
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 = {
banners: string[];
footerUrl: string | null;
@@ -42,13 +66,26 @@ const AROMA_TABS = [
{ key: 'NONGXIANG', label: '浓香型' },
] 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() {
const [tab, setTab] = useState('QINGXIANG');
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
const [fulfillmentFilter, setFulfillmentFilter] = useState<FulfillmentFilter>('ALL');
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [displayCity, setDisplayCity] = useState('郑州市');
const [cityCode, setCityCode] = useState('410100');
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(() => {
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
@@ -126,7 +163,24 @@ export default function HomePage() {
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 footerUrl = miniHome.footerUrl;
@@ -147,6 +201,102 @@ export default function HomePage() {
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 (
<PageShell variant="tab" className="home-page no-tab-header">
<WechatShareReady payload={sharePayload} />
@@ -175,8 +325,8 @@ export default function HomePage() {
{AROMA_TABS.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
onClick={() => setTab(t.key)}
className={`home-aroma-tab${activeAroma === t.key ? ' home-aroma-tab--active' : ''}`}
onClick={() => scrollToAroma(t.key)}
>
{t.label}
</Text>
@@ -185,64 +335,38 @@ export default function HomePage() {
<Text className="home-aroma-city">{displayCity}</Text>
</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">
{loading ? <View className="home-empty"></View> : null}
{!loading && products.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading && products.length > 0 && filtered.length === 0 ? (
<View className="home-empty">线</View>
{!loading && products.length > 0 && filteredProducts.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading &&
filtered.map((p) => {
const thumb = getProductMainImage(p);
const spec = p.subtitle || p.spec || '';
filteredProducts.length > 0 &&
AROMA_TABS.map((t) => {
const list = productsByAroma[t.key];
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}
<Text
className="home-buy-btn"
onClick={(e) => {
e.stopPropagation?.();
openProductDetail(p.id);
}}
>
</Text>
</View>
</View>
</View>
<View key={t.key} id={aromaSectionId(t.key)} className="home-aroma-section">
<Text className="home-aroma-section-title">{t.label}</Text>
{list.length === 0 ? (
<View className="home-empty home-empty--section">线</View>
) : (
list.map((p) => renderProductCard(p))
)}
</View>
);
})}
@@ -31,6 +31,9 @@ type Product = ProductImageSource & {
benefitAmount?: number;
benefitDisplay?: number;
detailContent?: ProductDetailContentDto | null;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
};
export default function ProductDetailPage() {
@@ -92,6 +95,21 @@ export default function ProductDetailPage() {
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) {
return (
<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" />
<Text className="product-detail-bar-home-label"></Text>
</View>
<View className="product-detail-buy-btn" onClick={() => void goBuy()}>
<Text className="product-detail-buy-btn-text"></Text>
</View>
{allowOnSite ? (
<View className="product-detail-pickup-btn" onClick={() => void goOnSitePickup()}>
<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>
</PageShell>
);
+50
View File
@@ -103,6 +103,30 @@
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 {
display: flex;
flex-direction: column;
@@ -110,6 +134,32 @@
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 {
background: var(--color-card);
border-radius: 12px;
@@ -301,6 +301,25 @@
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 {
flex: 1;
height: 48px;
@@ -312,6 +331,12 @@
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 {
color: #fff;
font-family: var(--font-headline);
+4
View File
@@ -27,6 +27,10 @@ export interface ProductDto {
detailContent?: ProductDetailContentDto | null;
/** 是否允许现场取货下单 */
allowOnSitePickup?: boolean;
/** 是否允许配送到址(同城线上购买) */
allowOnlinePurchase?: boolean;
/** 是否允许跨城配送 */
allowCrossCityDelivery?: boolean;
}
export interface ProductDetailFeatureDto {
+2
View File
@@ -85,6 +85,8 @@ export type PartnerProxyOrderProductOption = {
benefitAmount: number | null;
coverUrl?: string | null;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
};
export type PartnerProxyOrderPromoOption = {
+4
View File
@@ -593,6 +593,10 @@ model CommonProductItem {
status ProductStatus @default(DRAFT)
sortOrder Int @default(0) @map("sort_order")
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
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
@@ -24,6 +24,37 @@ function normalizePhones(phones?: string[]): string[] {
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()
export class AdminProductsService {
constructor(private readonly prisma: PrismaService) {}
@@ -96,39 +127,44 @@ export class AdminProductsService {
}
async create(dto: CreateProductDto) {
const exists = await this.prisma.commonProductItem.findFirst({
where: { OR: [{ skuCode: dto.skuCode }, { barcode69: dto.barcode69 }] },
const barcodeExists = await this.prisma.commonProductItem.findFirst({
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 whitelistEnabled = !!dto.visibilityWhitelistEnabled;
const product = await this.prisma.commonProductItem.create({
data: {
skuCode: dto.skuCode,
barcode69: dto.barcode69,
name: dto.name,
subtitle: dto.subtitle,
aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG',
spec: dto.spec,
price: dto.price,
benefitAmount: dto.benefitAmount ?? dto.price,
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
sortOrder: dto.sortOrder ?? 0,
allowOnSitePickup: dto.allowOnSitePickup ?? false,
visibilityWhitelistEnabled: whitelistEnabled,
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
...(phones.length
? {
visibilityPhones: {
create: phones.map((phone) => ({ phone })),
},
}
: {}),
},
const product = await this.createWithGeneratedSku({
barcode69: dto.barcode69,
name: dto.name,
subtitle: dto.subtitle,
aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG',
spec: dto.spec,
price: dto.price,
benefitAmount: dto.benefitAmount ?? dto.price,
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
sortOrder: dto.sortOrder ?? 0,
allowOnSitePickup: flags.allowOnSitePickup,
allowOnlinePurchase: flags.allowOnlinePurchase,
allowCrossCityDelivery: flags.allowCrossCityDelivery,
visibilityWhitelistEnabled: whitelistEnabled,
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
...(phones.length
? {
visibilityPhones: {
create: phones.map((phone) => ({ phone })),
},
}
: {}),
});
if (dto.coverUrl) {
@@ -143,7 +179,26 @@ export class AdminProductsService {
}
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({
where: { id },
data: {
@@ -154,7 +209,13 @@ export class AdminProductsService {
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
...(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
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}),
@@ -196,6 +257,46 @@ export class AdminProductsService {
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[]) {
await this.prisma.$transaction(async (tx) => {
await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } });
@@ -1068,9 +1068,10 @@ export class SaveHqAccountPermissionsDto {
}
export class CreateProductDto {
/** 可选;创建时由服务端自增生成,忽略客户端传入 */
@IsOptional()
@IsString()
@IsNotEmpty()
skuCode: string;
skuCode?: string;
@IsString()
@IsNotEmpty()
@@ -1110,6 +1111,14 @@ export class CreateProductDto {
@IsBoolean()
allowOnSitePickup?: boolean;
@IsOptional()
@IsBoolean()
allowOnlinePurchase?: boolean;
@IsOptional()
@IsBoolean()
allowCrossCityDelivery?: boolean;
/** 开启后仅白名单手机号在 C 端可见/可购 */
@IsOptional()
@IsBoolean()
@@ -1173,6 +1182,14 @@ export class UpdateProductDto {
@IsBoolean()
allowOnSitePickup?: boolean;
@IsOptional()
@IsBoolean()
allowOnlinePurchase?: boolean;
@IsOptional()
@IsBoolean()
allowCrossCityDelivery?: boolean;
/** 开启后仅白名单手机号在 C 端可见/可购 */
@IsOptional()
@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(
deliveryType,
body.quantity,
@@ -1121,6 +1137,9 @@ export class TradeService {
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
allowCrossCityDelivery:
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
})),
promoCodes,
stores: stores.map((s) => ({
@@ -1166,6 +1185,19 @@ export class TradeService {
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
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(
@@ -1486,6 +1518,9 @@ export class TradeService {
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
allowCrossCityDelivery:
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
})),
promoCodes,
stores: [],