From 49a5c057db2005d41f22b28892aeff075a23d6f6 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 2 Aug 2026 17:54:42 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=88=97=E8=A1=A8=E4=B8=8A=E4=B8=8B=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mini-user/src/pages/home/index.tsx | 197 +++++++++++++++++------- apps/mini-user/src/styles/home.css | 26 ++++ 2 files changed, 166 insertions(+), 57 deletions(-) diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index 1b3409c..a6274be 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -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'; @@ -42,13 +48,25 @@ 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('QINGXIANG'); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [displayCity, setDisplayCity] = useState('郑州市'); const [cityCode, setCityCode] = useState('410100'); const [miniHome, setMiniHome] = useState({ banners: [], footerUrl: null }); + const scrollingToRef = useRef(null); + const scrollLockTimerRef = useRef | null>(null); + const lastScrollSyncAtRef = useRef(0); const loadMiniHome = useCallback(() => { return request<{ miniHome?: MiniHomeConfig }>('/common/client-config') @@ -126,7 +144,19 @@ export default function HomePage() { Taro.navigateTo({ url: returnPath }); } - const filtered = products.filter((p) => p.aromaType === tab); + const productsByAroma = useMemo(() => { + const map: Record = { + QINGXIANG: [], + JIANGXIANG: [], + NONGXIANG: [], + }; + for (const p of products) { + const key = p.aromaType as AromaKey; + if (key in map) map[key].push(p); + } + return map; + }, [products]); + const banners = miniHome.banners; const footerUrl = miniHome.footerUrl; @@ -147,6 +177,100 @@ 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 ( + + openProductDetail(p.id)}> + + {thumb ? ( + + ) : ( + + )} + + + + {p.name} + ¥{Number(p.price).toFixed(0)} + + {spec ? {spec} : null} + + + + + {p.allowOnSitePickup ? ( + { + e.stopPropagation?.(); + void goOnSitePickup(p.id); + }} + > + 现场取货 + + ) : null} + { + e.stopPropagation?.(); + openProductDetail(p.id); + }} + > + 立即购买 + + + + + + ); + } + return ( @@ -175,8 +299,8 @@ export default function HomePage() { {AROMA_TABS.map((t) => ( setTab(t.key)} + className={`home-aroma-tab${activeAroma === t.key ? ' home-aroma-tab--active' : ''}`} + onClick={() => scrollToAroma(t.key)} > {t.label} @@ -190,59 +314,18 @@ export default function HomePage() { {!loading && products.length === 0 ? ( 当前城市暂无在售商品 ) : null} - {!loading && products.length > 0 && filtered.length === 0 ? ( - 该香型暂未上线 - ) : null} {!loading && - filtered.map((p) => { - const thumb = getProductMainImage(p); - const spec = p.subtitle || p.spec || ''; + products.length > 0 && + AROMA_TABS.map((t) => { + const list = productsByAroma[t.key]; return ( - - openProductDetail(p.id)} - > - - {thumb ? ( - - ) : ( - - )} - - - - {p.name} - ¥{Number(p.price).toFixed(0)} - - {spec ? {spec} : null} - - - - - {p.allowOnSitePickup ? ( - { - e.stopPropagation?.(); - void goOnSitePickup(p.id); - }} - > - 现场取货 - - ) : null} - { - e.stopPropagation?.(); - openProductDetail(p.id); - }} - > - 立即购买 - - - - + + {t.label} + {list.length === 0 ? ( + 该香型暂未上线 + ) : ( + list.map((p) => renderProductCard(p)) + )} ); })} diff --git a/apps/mini-user/src/styles/home.css b/apps/mini-user/src/styles/home.css index 14a24f2..2aef509 100644 --- a/apps/mini-user/src/styles/home.css +++ b/apps/mini-user/src/styles/home.css @@ -110,6 +110,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; From 847a3f0dc370b3c84b4e0dfc6c0997133376274b Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 2 Aug 2026 18:10:16 +0800 Subject: [PATCH 2/2] feat(catalog): add product fulfillment flags and auto SKU Enable online/cross-city/on-site purchase switches with trade gates, HQ and proxy UI, and server-generated DK SKUs. Co-authored-by: Cursor --- .../src/components/ProxyOrderModal.tsx | 59 +++++-- apps/admin-web/src/pages/ProductsPage.tsx | 84 +++++++-- apps/h5-partner/src/pages/ProxyOrderPage.tsx | 78 ++++++--- .../src/pages/ProxyOrderProductsPage.tsx | 60 ++++++- apps/h5-partner/src/styles.css | 44 +++++ apps/mini-user/src/pages/home/index.tsx | 65 +++++-- .../src/pages/product-detail/index.tsx | 36 +++- apps/mini-user/src/styles/home.css | 24 +++ apps/mini-user/src/styles/product-detail.css | 25 +++ packages/shared-types/src/catalog.ts | 4 + packages/shared-types/src/trade.ts | 2 + server/dukang-api/prisma/schema.prisma | 4 + .../src/modules/ops/admin-products.service.ts | 161 ++++++++++++++---- .../src/modules/ops/dto/admin-mutate.dto.ts | 21 ++- .../src/modules/trade/trade.service.ts | 35 ++++ 15 files changed, 601 insertions(+), 101 deletions(-) diff --git a/apps/admin-web/src/components/ProxyOrderModal.tsx b/apps/admin-web/src/components/ProxyOrderModal.tsx index 2aeccde..19d53a6 100644 --- a/apps/admin-web/src/components/ProxyOrderModal.tsx +++ b/apps/admin-web/src/components/ProxyOrderModal.tsx @@ -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} - { - setDeliveryMode(e.target.value); - if (e.target.value !== 'ADDRESS') setAutoReceive(false); - }} - options={[ - { value: 'ADDRESS', label: '配送到址' }, - { value: 'ON_SITE_PICKUP', label: '现场提货' }, - ]} - /> + {allowOnline && allowOnSite ? ( + { + setDeliveryMode(e.target.value); + if (e.target.value !== 'ADDRESS') setAutoReceive(false); + }} + options={[ + { value: 'ADDRESS', label: '配送到址' }, + { value: 'ON_SITE_PICKUP', label: '现场提货' }, + ]} + /> + ) : ( + + {!allowOnline && allowOnSite + ? '该商品仅支持现场提货' + : allowOnline && !allowOnSite + ? allowCrossCity + ? '该商品仅支持配送到址(含跨城)' + : '该商品仅支持配送到址(不可跨城)' + : '该商品暂无可选履约方式'} + + )} - {deliveryMode === 'ADDRESS' ? ( + {deliveryMode === 'ADDRESS' && allowOnline ? ( <> ) { 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' && ( <> - - + + @@ -320,6 +330,36 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns + + { + if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false }); + }} + /> + + prev.allowOnlinePurchase !== cur.allowOnlinePurchase}> + {() => ( + + + + )} + @@ -383,10 +423,19 @@ export default function ProductsPage() { v ? 限{row.visibilityPhones?.length ?? 0}人 : 公开, }, { - title: '现场取货', - dataIndex: 'allowOnSitePickup', - width: 90, - render: (v: boolean) => (v ? 允许 : ), + title: '履约', + key: 'fulfillment', + width: 160, + render: (_, row) => ( + + {row.allowOnlinePurchase !== false ? 线上 : null} + {row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? ( + 跨城 + ) : null} + {row.allowOnSitePickup ? 现场 : null} + {row.allowOnlinePurchase === false && !row.allowOnSitePickup ? : null} + + ), }, { title: '排序', dataIndex: 'sortOrder', width: 60 }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, @@ -431,12 +480,16 @@ export default function ProductsPage() { - { setPage(p); setPageSize(ps); } }} /> setDrawerOpen(false)} extra={detail && ( - - + {allowOnline && allowOnSite ? ( +
+ + +
+ ) : ( +

+ {!allowOnline && allowOnSite + ? '该商品仅支持现场提货' + : allowOnline && !allowOnSite + ? allowCrossCity + ? '该商品仅支持配送到址(含跨城)' + : '该商品仅支持配送到址(不可跨城)' + : '该商品暂无可选履约方式'} +

+ )} - {deliveryMode === 'ADDRESS' ? ( + {deliveryMode === 'ADDRESS' && allowOnline ? ( <>
diff --git a/apps/h5-partner/src/pages/ProxyOrderProductsPage.tsx b/apps/h5-partner/src/pages/ProxyOrderProductsPage.tsx index 6feb39f..6536436 100644 --- a/apps/h5-partner/src/pages/ProxyOrderProductsPage.tsx +++ b/apps/h5-partner/src/pages/ProxyOrderProductsPage.tsx @@ -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([]); const [loading, setLoading] = useState(true); + const [keyword, setKeyword] = useState(''); + const [filter, setFilter] = useState('ALL'); useEffect(() => { request('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 (
navigate(-1)} /> +
+ setKeyword(e.target.value)} + /> +
+ {FILTERS.map((f) => ( + + ))} +
+
{loading ?

加载中…

: null} - {!loading && products.length === 0 ? ( -

暂无可售商品

+ {!loading && filtered.length === 0 ? ( +

暂无符合条件的商品

) : 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() {

{p.spec}

好客权益 ¥{fmtMoney(benefit)}

+
+ {p.allowOnlinePurchase !== false ? 线上 : null} + {p.allowCrossCityDelivery !== false ? 跨城 : null} + {p.allowOnSitePickup ? 现场 : null} +
); diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index 83582bc..47a1f7b 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -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; diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index a6274be..d41b2fb 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -35,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; @@ -59,6 +77,7 @@ function aromaSectionId(key: AromaKey) { export default function HomePage() { const [activeAroma, setActiveAroma] = useState('QINGXIANG'); + const [fulfillmentFilter, setFulfillmentFilter] = useState('ALL'); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [displayCity, setDisplayCity] = useState('郑州市'); @@ -144,18 +163,23 @@ export default function HomePage() { Taro.navigateTo({ url: returnPath }); } + const filteredProducts = useMemo( + () => products.filter((p) => matchFulfillmentFilter(p, fulfillmentFilter)), + [products, fulfillmentFilter], + ); + const productsByAroma = useMemo(() => { const map: Record = { QINGXIANG: [], JIANGXIANG: [], NONGXIANG: [], }; - for (const p of products) { + for (const p of filteredProducts) { const key = p.aromaType as AromaKey; if (key in map) map[key].push(p); } return map; - }, [products]); + }, [filteredProducts]); const banners = miniHome.banners; const footerUrl = miniHome.footerUrl; @@ -255,15 +279,17 @@ export default function HomePage() { 现场取货 ) : null} - { - e.stopPropagation?.(); - openProductDetail(p.id); - }} - > - 立即购买 - + {p.allowOnlinePurchase !== false ? ( + { + e.stopPropagation?.(); + openProductDetail(p.id); + }} + > + 立即购买 + + ) : null} @@ -309,13 +335,28 @@ export default function HomePage() { {displayCity} + + {FULFILLMENT_FILTERS.map((f) => ( + setFulfillmentFilter(f.key)} + > + {f.label} + + ))} + + {loading ? 加载中… : null} {!loading && products.length === 0 ? ( 当前城市暂无在售商品 ) : null} + {!loading && products.length > 0 && filteredProducts.length === 0 ? ( + 暂无符合履约方式的商品 + ) : null} {!loading && - products.length > 0 && + filteredProducts.length > 0 && AROMA_TABS.map((t) => { const list = productsByAroma[t.key]; return ( diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx index 0b312d4..c3248d2 100644 --- a/apps/mini-user/src/pages/product-detail/index.tsx +++ b/apps/mini-user/src/pages/product-detail/index.tsx @@ -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 ( @@ -199,9 +217,21 @@ export default function ProductDetailPage() { 首页 - void goBuy()}> - 立即购买 - + {allowOnSite ? ( + void goOnSitePickup()}> + 现场取货 + + ) : null} + {allowOnline ? ( + void goBuy()}> + 立即购买 + + ) : null} + {!allowOnline && !allowOnSite ? ( + + 暂不可购 + + ) : null} ); diff --git a/apps/mini-user/src/styles/home.css b/apps/mini-user/src/styles/home.css index 2aef509..15bfbad 100644 --- a/apps/mini-user/src/styles/home.css +++ b/apps/mini-user/src/styles/home.css @@ -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; diff --git a/apps/mini-user/src/styles/product-detail.css b/apps/mini-user/src/styles/product-detail.css index 9ca8194..e115655 100644 --- a/apps/mini-user/src/styles/product-detail.css +++ b/apps/mini-user/src/styles/product-detail.css @@ -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); diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts index b522834..e6817c3 100644 --- a/packages/shared-types/src/catalog.ts +++ b/packages/shared-types/src/catalog.ts @@ -27,6 +27,10 @@ export interface ProductDto { detailContent?: ProductDetailContentDto | null; /** 是否允许现场取货下单 */ allowOnSitePickup?: boolean; + /** 是否允许配送到址(同城线上购买) */ + allowOnlinePurchase?: boolean; + /** 是否允许跨城配送 */ + allowCrossCityDelivery?: boolean; } export interface ProductDetailFeatureDto { diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts index 3f02fa9..9c38a61 100644 --- a/packages/shared-types/src/trade.ts +++ b/packages/shared-types/src/trade.ts @@ -85,6 +85,8 @@ export type PartnerProxyOrderProductOption = { benefitAmount: number | null; coverUrl?: string | null; allowOnSitePickup?: boolean; + allowOnlinePurchase?: boolean; + allowCrossCityDelivery?: boolean; }; export type PartnerProxyOrderPromoOption = { diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 343c5d1..78e53ec 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -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 diff --git a/server/dukang-api/src/modules/ops/admin-products.service.ts b/server/dukang-api/src/modules/ops/admin-products.service.ts index 71a40c5..a9275d0 100644 --- a/server/dukang-api/src/modules/ops/admin-products.service.ts +++ b/server/dukang-api/src/modules/ops/admin-products.service.ts @@ -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 { + 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, + ) { + 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 } }); diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 2c744b1..4ed54c2 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -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() diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 2b52c77..b10c9ae 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -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: [],