From 1c4b9869248eeb3b5842411452d1749764300935 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 5 Aug 2026 22:34:29 +0800 Subject: [PATCH] feat(release): v3.4.13 experience optimizations across admin, mini-user, and H5 login Co-authored-by: Cursor --- .../admin-web/src/pages/RedeemRecordsPage.tsx | 36 +++- .../src/pages/SupportTicketsPage.tsx | 41 ++++- .../src/pages/promo/PromoCodeDetailPage.tsx | 8 + apps/h5-partner/src/pages/LoginPage.tsx | 12 +- apps/h5-shop/src/pages/LoginPage.tsx | 12 +- apps/mini-user/package.json | 2 +- apps/mini-user/src/app.config.ts | 1 + .../src/components/ProductCarousel.tsx | 3 +- .../src/components/WechatShareBootstrap.tsx | 2 + apps/mini-user/src/lib/client-version.ts | 70 ++++++++ .../src/pages/order-confirm-pickup/index.tsx | 8 + .../src/pages/order-detail/index.tsx | 24 +++ .../src/pages/order-logistics/index.config.ts | 3 + .../src/pages/order-logistics/index.tsx | 160 ++++++++++++++++++ .../src/pages/product-detail/index.tsx | 4 +- .../src/pages/store-detail/index.tsx | 40 ++++- apps/mini-user/src/styles/order.css | 39 +++++ apps/mini-user/src/styles/store-detail.css | 27 ++- packages/shared-types/src/support-ticket.ts | 15 ++ packages/shared-types/src/wechat.ts | 2 + server/dukang-api/prisma/schema.prisma | 8 + .../system-config/system-config.registry.ts | 9 + .../common/client-config.controller.ts | 2 + .../modules/common/dto/support-ticket.dto.ts | 12 ++ .../modules/common/support-ticket.service.ts | 7 +- .../src/modules/iam/auth.service.ts | 4 + 杜康好客-v3-PRD.md | 6 + 杜康好客-v3-现状对照.md | 19 ++- 杜康好客-v3.4.13-体验优化开发文档.md | 64 +++++++ 29 files changed, 617 insertions(+), 23 deletions(-) create mode 100644 apps/mini-user/src/lib/client-version.ts create mode 100644 apps/mini-user/src/pages/order-logistics/index.config.ts create mode 100644 apps/mini-user/src/pages/order-logistics/index.tsx create mode 100644 杜康好客-v3.4.13-体验优化开发文档.md diff --git a/apps/admin-web/src/pages/RedeemRecordsPage.tsx b/apps/admin-web/src/pages/RedeemRecordsPage.tsx index b0ce8a5..e52bfbe 100644 --- a/apps/admin-web/src/pages/RedeemRecordsPage.tsx +++ b/apps/admin-web/src/pages/RedeemRecordsPage.tsx @@ -13,11 +13,16 @@ type Row = { settleAmount: number; channel?: RedeemChannel; createdAt: string; - user?: { userNo: string; phone: string | null }; + user?: { userNo: string; phone: string | null; nickname?: string | null }; store?: { name: string; cityName: string }; coupon?: { couponNo: string }; }; +function maskPhone(phone: string | null | undefined) { + if (!phone || phone.length < 7) return phone ?? '—'; + return `${phone.slice(0, 3)}****${phone.slice(-4)}`; +} + export default function RedeemRecordsPage() { const [form] = Form.useForm(); const [filters, setFilters] = useState>({}); @@ -50,7 +55,19 @@ export default function RedeemRecordsPage() { ); }, }, - { title: '用户', dataIndex: ['user', 'userNo'], width: 110 }, + { title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 }, + { + title: '用户昵称', + dataIndex: ['user', 'nickname'], + width: 100, + render: (v: string | null | undefined) => v || '—', + }, + { + title: '用户手机', + dataIndex: ['user', 'phone'], + width: 120, + render: (v: string | null | undefined) => maskPhone(v), + }, { title: '门店', dataIndex: ['store', 'name'] }, { title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` }, { title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` }, @@ -113,7 +130,7 @@ export default function RedeemRecordsPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1100 }} + scroll={{ x: 1300 }} pagination={{ current: page, pageSize, @@ -138,6 +155,19 @@ export default function RedeemRecordsPage() { ¥{String(detail.amount)} ¥{String(detail.settleAmount)} + {detail.user && typeof detail.user === 'object' ? ( + <> + + {String((detail.user as { userNo?: string }).userNo ?? '—')} + + + {String((detail.user as { nickname?: string | null }).nickname ?? '—')} + + + {maskPhone((detail.user as { phone?: string | null }).phone)} + + + ) : null} {fmtTime(String(detail.createdAt))} )} diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx index 4bc7a9d..f889ea0 100644 --- a/apps/admin-web/src/pages/SupportTicketsPage.tsx +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -30,6 +30,7 @@ import { DEV_PLAN_TASK_TYPE_LABELS, SUPPORT_TICKET_STATUS_LABELS, SUPPORT_TICKET_TYPE_LABELS, + SUPPORT_TICKET_PRIORITY_LABELS, mapSupportTicketTypeToDevPlanTask, type BatchReviewPreviewItem, type BatchReviewPreviewResponse, @@ -37,6 +38,7 @@ import { type DevPlanVersionDto, type SupportTicketDto, type SupportTicketLinkedTaskDto, + type SupportTicketPriorityDto, type SupportTicketStatusDto, type SupportTicketTypeDto, } from '@dukang/shared-types'; @@ -64,6 +66,17 @@ const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTick (value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }), ); +const PRIORITY_COLOR: Record = { + LOW: 'default', + NORMAL: 'blue', + HIGH: 'orange', + URGENT: 'red', +}; + +const PRIORITY_OPTIONS = (Object.keys(SUPPORT_TICKET_PRIORITY_LABELS) as SupportTicketPriorityDto[]).map( + (value) => ({ value, label: SUPPORT_TICKET_PRIORITY_LABELS[value] }), +); + const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map( (v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }), ); @@ -80,6 +93,7 @@ export default function SupportTicketsPage() { const qs = new URLSearchParams(); if (filters.ticketType) qs.set('ticketType', filters.ticketType); if (filters.status) qs.set('status', filters.status); + if (filters.priority) qs.set('priority', filters.priority); return qs; }, [filters], @@ -154,6 +168,7 @@ export default function SupportTicketsPage() { title: values.title.trim(), content: values.content?.trim() || undefined, remark: values.remark?.trim() || undefined, + priority: values.priority, attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), }), }); @@ -217,6 +232,7 @@ export default function SupportTicketsPage() { title: detail.title, content: detail.content || '', remark: detail.remark || '', + priority: detail.priority ?? 'NORMAL', attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''], }); setEditOpen(true); @@ -233,6 +249,7 @@ export default function SupportTicketsPage() { title: values.title.trim(), content: values.content?.trim() || undefined, remark: values.remark?.trim() || undefined, + priority: values.priority, attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), }), }); @@ -451,6 +468,16 @@ export default function SupportTicketsPage() { width: 90, render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t, }, + { + title: '优先级', + dataIndex: 'priority', + width: 90, + render: (p: SupportTicketPriorityDto) => ( + + {SUPPORT_TICKET_PRIORITY_LABELS[p] ?? p ?? '普通'} + + ), + }, { title: '状态', dataIndex: 'status', @@ -562,6 +589,9 @@ export default function SupportTicketsPage() { + @@ -603,6 +633,9 @@ export default function SupportTicketsPage() { {detail.ticketNo} {SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType} + + {SUPPORT_TICKET_PRIORITY_LABELS[detail.priority ?? 'NORMAL']} + )} @@ -861,10 +894,13 @@ export default function SupportTicketsPage() { setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交"> -
+ + @@ -968,6 +1004,9 @@ export default function SupportTicketsPage() { + diff --git a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx index 2dfaf55..0092b3f 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx @@ -212,6 +212,14 @@ export default function PromoCodeDetailPage() { + + + + + ('PARTNER_H5', '/partner/auth/login/sms', { method: 'POST', - body: JSON.stringify({ phone, code }), + body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }), silent: true, }); saveRememberedSession(data); diff --git a/apps/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index 7a2088e..cee9945 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -140,12 +140,22 @@ export default function LoginPage() { async function login() { if (!ensureAgreed()) return; + const trimmedPhone = phone.trim(); + const trimmedCode = code.trim(); + if (!trimmedPhone) { + setMsg('phone should not be empty'); + return; + } + if (!trimmedCode) { + setMsg('code should not be empty'); + return; + } setLoading(true); setMsg(''); try { const data = await request('SHOP_H5', '/shop/auth/login/sms', { method: 'POST', - body: JSON.stringify({ phone, code }), + body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }), }); saveRememberedSession(data); applySession(data); diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json index 5b369d6..0ec1f8b 100644 --- a/apps/mini-user/package.json +++ b/apps/mini-user/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/mini-user", - "version": "0.1.0", + "version": "3.4.13", "private": true, "description": "杜康好客 · C 端用户微信小程序(Taro)", "scripts": { diff --git a/apps/mini-user/src/app.config.ts b/apps/mini-user/src/app.config.ts index 4739cb8..3e74f5a 100644 --- a/apps/mini-user/src/app.config.ts +++ b/apps/mini-user/src/app.config.ts @@ -11,6 +11,7 @@ export default defineAppConfig({ 'pages/pay/index', 'pages/orders/index', 'pages/order-detail/index', + 'pages/order-logistics/index', 'pages/pickup-receive/index', 'pages/addresses/index', 'pages/address-edit/index', diff --git a/apps/mini-user/src/components/ProductCarousel.tsx b/apps/mini-user/src/components/ProductCarousel.tsx index 2ffa2d8..438f3e5 100644 --- a/apps/mini-user/src/components/ProductCarousel.tsx +++ b/apps/mini-user/src/components/ProductCarousel.tsx @@ -20,6 +20,7 @@ export default function ProductCarousel({ const [activeIndex, setActiveIndex] = useState(0); const prefix = variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel'; + const imageMode = variant === 'store' ? 'aspectFit' : 'aspectFill'; function previewAt(index: number) { const urls = slides.filter(Boolean); @@ -41,7 +42,7 @@ export default function ProductCarousel({ {alt} previewAt(index) : undefined} /> diff --git a/apps/mini-user/src/components/WechatShareBootstrap.tsx b/apps/mini-user/src/components/WechatShareBootstrap.tsx index 65fb97b..dd27f7f 100644 --- a/apps/mini-user/src/components/WechatShareBootstrap.tsx +++ b/apps/mini-user/src/components/WechatShareBootstrap.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'; import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav'; import { toast } from '../lib/api'; import { capturePromoSceneAndTouchScan } from '../lib/promo'; +import { initClientVersionChecks } from '../lib/client-version'; import { saveWechatLoginResult } from '../lib/pay-wechat'; import { applyWechatShare } from '../lib/wechat-share'; import { handleWechatAuthCallback } from '../lib/wechat-auth'; @@ -38,6 +39,7 @@ export default function WechatShareBootstrap() { useEffect(() => { void capturePromoSceneAndTouchScan(); + initClientVersionChecks(); }, []); useEffect(() => { diff --git a/apps/mini-user/src/lib/client-version.ts b/apps/mini-user/src/lib/client-version.ts new file mode 100644 index 0000000..90537ab --- /dev/null +++ b/apps/mini-user/src/lib/client-version.ts @@ -0,0 +1,70 @@ +import Taro from '@tarojs/taro'; +import { fetchClientConfig } from './pay-wechat'; + +/** 与 package.json version 同步,供服务端 minClientVersion 比对 */ +export const APP_VERSION = '3.4.13'; + +function parseSemver(v: string): number[] { + return v.split('.').map((n) => parseInt(n, 10) || 0); +} + +export function compareSemver(a: string, b: string): number { + const pa = parseSemver(a); + const pb = parseSemver(b); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i += 1) { + const da = pa[i] ?? 0; + const db = pb[i] ?? 0; + if (da !== db) return da - db; + } + return 0; +} + +export async function checkClientVersionGate() { + try { + const config = await fetchClientConfig(); + const min = config.minClientVersion?.trim(); + if (min && compareSemver(APP_VERSION, min) < 0) { + await Taro.showModal({ + title: '版本过低', + content: '当前小程序版本过低,请更新至最新版本后继续使用。', + showCancel: false, + confirmText: '我知道了', + }); + } + } catch { + /* 配置拉取失败不阻塞启动 */ + } +} + +export function setupWeappUpdateManager() { + if (process.env.TARO_ENV !== 'weapp') return; + if (typeof Taro.getUpdateManager !== 'function') return; + + const manager = Taro.getUpdateManager(); + manager.onCheckForUpdate((res) => { + if (!res.hasUpdate) return; + manager.onUpdateReady(() => { + void Taro.showModal({ + title: '更新提示', + content: '新版本已准备好,是否重启应用?', + confirmText: '立即更新', + success: (r) => { + if (r.confirm) manager.applyUpdate(); + }, + }); + }); + manager.onUpdateFailed(() => { + void Taro.showModal({ + title: '更新失败', + content: '新版本下载失败,请删除小程序后重新搜索打开。', + showCancel: false, + }); + }); + }); +} + +export function initClientVersionChecks() { + setupWeappUpdateManager(); + void checkClientVersionGate(); +} diff --git a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx index 3166e60..74fbd5f 100644 --- a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx +++ b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx @@ -141,6 +141,14 @@ export default function OrderConfirmPickupPage() { const ready = await ensurePayReady(returnPath); if (!ready) return; + const { confirm } = await Taro.showModal({ + title: '确认提交订单', + content: `确认提交现场提货订单?共 ${quantity} 瓶,应付 ¥${Number(preview?.payAmount ?? 0).toFixed(2)}。`, + confirmText: '确认提交', + cancelText: '再想想', + }); + if (!confirm) return; + setLoading(true); setMsg(''); try { diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index 51587c8..69eb403 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -44,8 +44,15 @@ type OrderDetail = { orderType?: string; isProxyOrder?: boolean; proxyPartnerName?: string | null; + deliveryType?: string; items?: OrderItem[]; wechatConfirm?: WechatConfirmPayload | null; + delivery?: { + provider?: string; + trackingNo?: string; + logisticsCompany?: string; + manualQueryUrl?: string; + } | null; }; const STATUS_LABELS: Record = { @@ -99,6 +106,13 @@ export default function OrderDetailPage() { const canPay = !!order && order.status === 'PENDING_PAY' && !isReship; const canConfirmReceive = !!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || ''); + const canViewLogistics = + !!order && + order.deliveryType !== 'ON_SITE_PICKUP' && + !isReship && + ['PENDING_SHIP', 'OUT_WAREHOUSE', 'SHIPPING', 'SHIPPED', 'PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes( + order.status || '', + ); const item = order?.items?.[0]; const productName = item?.productName || order?.productName || '杜康商品'; @@ -134,6 +148,11 @@ export default function OrderDetailPage() { Taro.navigateTo({ url: '/pages/customer-service/index' }); } + function goLogistics() { + if (!order) return; + Taro.navigateTo({ url: `/pages/order-logistics/index?id=${order.id}` }); + } + async function confirmReceive() { if (!order || !canConfirmReceive || confirming) return; @@ -211,6 +230,11 @@ export default function OrderDetailPage() { {isProxy && order.proxyPartnerName ? ( 由合伙人 {order.proxyPartnerName} 代下 ) : null} + {canViewLogistics ? ( + + 查看物流追踪 + + ) : null} 商品信息 diff --git a/apps/mini-user/src/pages/order-logistics/index.config.ts b/apps/mini-user/src/pages/order-logistics/index.config.ts new file mode 100644 index 0000000..01b4695 --- /dev/null +++ b/apps/mini-user/src/pages/order-logistics/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '物流追踪', +}); diff --git a/apps/mini-user/src/pages/order-logistics/index.tsx b/apps/mini-user/src/pages/order-logistics/index.tsx new file mode 100644 index 0000000..3ec531c --- /dev/null +++ b/apps/mini-user/src/pages/order-logistics/index.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from 'react'; +import { View, Text } from '@tarojs/components'; +import Taro, { useRouter } from '@tarojs/taro'; +import PageShell from '../../components/PageShell'; +import SubPageHeader from '../../components/SubPageHeader'; +import { request, toast } from '../../lib/api'; +import { usePageView } from '../../lib/usePageView'; + +type OrderDelivery = { + provider?: string; + trackingNo?: string; + logisticsCompany?: string; + manualQueryUrl?: string; +}; + +type TrackNode = { + trackInfo?: string; + createdAt?: string; +}; + +type OrderDetail = { + id: string; + orderNo?: string; + status?: string; + delivery?: OrderDelivery | null; +}; + +const STATUS_LABELS: Record = { + PENDING_SHIP: '待发货', + OUT_WAREHOUSE: '出库中', + SHIPPING: '配送中', + SHIPPED: '配送中', + PENDING_RECEIVE: '待签收', + DELIVERED: '待签收', + COMPLETED: '已完成', +}; + +function deliveryProviderLabel(provider?: string, company?: string) { + if (company) return company; + if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') { + return '小飞侠配送'; + } + if (provider === 'LOGISTICS') return '快递配送'; + return provider; +} + +function formatDateTime(value: string) { + return String(value).slice(0, 19).replace('T', ' '); +} + +export default function OrderLogisticsPage() { + const router = useRouter(); + const orderId = router.params.id ?? ''; + usePageView('order_logistics_view', orderId ? { orderId } : undefined); + const [order, setOrder] = useState(null); + const [trackNodes, setTrackNodes] = useState([]); + + useEffect(() => { + if (!orderId) return; + let cancelled = false; + (async () => { + try { + const data = await request(`/trade/orders/${orderId}`); + if (cancelled) return; + setOrder(data); + if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') { + try { + const track = await request<{ nodes?: TrackNode[] }>(`/trade/orders/${orderId}/track`); + if (!cancelled) setTrackNodes(track.nodes ?? []); + } catch { + if (!cancelled) setTrackNodes([]); + } + } + } catch (e) { + if (!cancelled) toast(e instanceof Error ? e.message : '加载失败'); + } + })(); + return () => { + cancelled = true; + }; + }, [orderId]); + + function openManualQuery() { + const url = order?.delivery?.manualQueryUrl?.trim(); + if (!url) { + toast('暂无物流查询链接'); + return; + } + if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') { + window.open(url, '_blank'); + return; + } + Taro.setClipboardData({ data: url }) + .then(() => toast('查询链接已复制,请在浏览器中打开')) + .catch(() => toast('无法打开物流查询')); + } + + const delivery = order?.delivery; + + return ( + + + + {!order ? ( + 加载中… + ) : ( + <> + + 配送状态 + + {STATUS_LABELS[order.status || ''] || order.status || '处理中'} + + + + + 物流信息 + + 配送方式 + + {deliveryProviderLabel(delivery?.provider, delivery?.logisticsCompany)} + + + {delivery?.trackingNo ? ( + + 运单号 + {delivery.trackingNo} + + ) : null} + {delivery?.manualQueryUrl ? ( + + 物流查询 + + 查看物流 + + + ) : null} + {!delivery?.trackingNo && !delivery?.manualQueryUrl ? ( + 物流信息待更新,请稍后查看 + ) : null} + + + {trackNodes.length > 0 ? ( + + 物流动态 + {trackNodes.map((node, index) => ( + + + {node.createdAt ? formatDateTime(node.createdAt) : '—'} + + {node.trackInfo || '—'} + + ))} + + ) : null} + + )} + + + ); +} diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx index 9697354..d3b72a8 100644 --- a/apps/mini-user/src/pages/product-detail/index.tsx +++ b/apps/mini-user/src/pages/product-detail/index.tsx @@ -11,7 +11,6 @@ import type { ProductDetailContentDto } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import PageNavBar from '../../components/PageNavBar'; import ProductCarousel from '../../components/ProductCarousel'; -import ShareNavButton from '../../components/ShareNavButton'; import WechatShareReady from '../../components/WechatShareReady'; import { goLogin } from '../../lib/auth-nav'; import { ensurePayReady } from '../../lib/pay-ready'; @@ -167,12 +166,11 @@ export default function ProductDetailPage() { solid={headerSolid} titleVisible={headerSolid} onBack={goBack} - right={} /> - + diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index 276a6aa..dc9d59d 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -15,6 +15,8 @@ import ShareNavButton from '../../components/ShareNavButton'; import StoreRedeemMarquee from '../../components/StoreRedeemMarquee'; import WechatShareReady from '../../components/WechatShareReady'; import { request, toast } from '../../lib/api'; +import { maskPhone } from '../../lib/phone'; +import { track } from '../../lib/analytics'; import { formatShanghaiDateTime } from '../../lib/datetime'; import { DEFAULT_SHARE_DESC, @@ -154,6 +156,7 @@ export default function StoreDetailPage() { const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [headerSolid, setHeaderSolid] = useState(false); + const [expandedPackages, setExpandedPackages] = useState>({}); const storeRef = useRef(null); storeRef.current = store; @@ -271,6 +274,7 @@ export default function StoreDetailPage() { toast('暂无门店电话'); return; } + track('store_phone_call', { storeId: store.id }); Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话')); } @@ -374,7 +378,9 @@ export default function StoreDetailPage() { {store.phone ? ( - 电话: {store.phone} + + 电话: {maskPhone(store.phone)} + 拨打 @@ -399,15 +405,32 @@ export default function StoreDetailPage() { {store.packages && store.packages.length > 0 ? ( 门店套餐 - {store.packages.map((pkg, index) => ( - + {store.packages.map((pkg, index) => { + const expanded = !!expandedPackages[index]; + const bodyText = `${formatRedeemAmountYuan(pkg.price)} 元 · ${pkg.dishes}`; + const longBody = bodyText.length > 48 || (pkg.otherNotes?.length ?? 0) > 40; + return ( + {pkg.imageUrl ? ( ) : null} - {pkg.name} - - {formatRedeemAmountYuan(pkg.price)} 元 · {pkg.dishes} - + + {pkg.name} + {longBody ? ( + + setExpandedPackages((prev) => ({ ...prev, [index]: !prev[index] })) + } + > + {expanded ? '收起' : '展开'} + + ) : null} + + {bodyText} {pkg.usableTime ? ( 使用时间:{pkg.usableTime} ) : null} @@ -415,7 +438,8 @@ export default function StoreDetailPage() { 说明:{pkg.otherNotes} ) : null} - ))} + ); + })} ) : null} diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css index f9c49e6..5d0eebe 100644 --- a/apps/mini-user/src/styles/order.css +++ b/apps/mini-user/src/styles/order.css @@ -488,3 +488,42 @@ line-height: 1.5; text-align: center; } + +.order-logistics-page { + background: var(--color-background); + min-height: 100vh; +} + +.order-logistics-link { + display: inline-block; + margin-top: 8px; + font-size: 14px; + color: var(--color-heritage-red); +} + +.order-row-value--link { + color: var(--color-heritage-red); +} + +.order-logistics-node { + padding: 10px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +.order-logistics-node:last-child { + border-bottom: none; +} + +.order-logistics-node-time { + display: block; + font-size: 12px; + color: var(--color-on-surface-variant); + margin-bottom: 4px; +} + +.order-logistics-node-info { + display: block; + font-size: 14px; + color: var(--color-on-surface); + line-height: 1.5; +} diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css index 37e1c02..8e4157b 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -10,13 +10,15 @@ .store-detail-carousel-wrap { position: relative; width: 100%; - height: 280px; + min-height: 200px; + max-height: 360px; overflow: hidden; + background: var(--color-surface-container); } .store-detail-carousel { width: 100%; - height: 100%; + height: 280px; } .store-detail-carousel-item, @@ -241,6 +243,27 @@ border-bottom: 1px solid rgba(0, 0, 0, 0.06); } +.store-detail-package-card--collapsed .store-detail-package-body, +.store-detail-package-card--collapsed .store-detail-package-meta { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.store-detail-package-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.store-detail-package-toggle { + flex-shrink: 0; + font-size: 13px; + color: var(--color-heritage-red); +} + .store-detail-package-img { width: 100%; height: 160px; diff --git a/packages/shared-types/src/support-ticket.ts b/packages/shared-types/src/support-ticket.ts index 76c97cd..35a5133 100644 --- a/packages/shared-types/src/support-ticket.ts +++ b/packages/shared-types/src/support-ticket.ts @@ -33,11 +33,24 @@ export const SUPPORT_TICKET_STATUS_LABELS: Record = { + LOW: '低', + NORMAL: '普通', + HIGH: '高', + URGENT: '紧急', +}; + export interface SupportTicketDto { id: string; ticketNo: string; ticketType: SupportTicketTypeDto; status: SupportTicketStatusDto; + priority: SupportTicketPriorityDto; title: string; content?: string | null; rejectReason?: string | null; @@ -59,6 +72,7 @@ export interface CreateSupportTicketRequest { content?: string; remark?: string; attachmentUrls?: string[]; + priority?: SupportTicketPriorityDto; } export interface UpdateSupportTicketRequest { @@ -67,6 +81,7 @@ export interface UpdateSupportTicketRequest { content?: string; remark?: string; attachmentUrls?: string[]; + priority?: SupportTicketPriorityDto; } export interface RejectSupportTicketRequest { diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index 4b89643..b2d8ef8 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -48,6 +48,8 @@ export type ClientRuntimeConfig = { banners: string[]; footerUrl: string | null; }; + /** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */ + minClientVersion?: string | null; }; /** 是否展示微信授权入口 */ diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index e920f9d..7630161 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -109,6 +109,13 @@ enum SupportTicketStatus { PASSED } +enum SupportTicketPriority { + LOW + NORMAL + HIGH + URGENT +} + enum DevPlanTaskType { BUG REQUIREMENT @@ -646,6 +653,7 @@ model CommonSupportTicket { ticketNo String @unique @map("ticket_no") @db.VarChar(32) ticketType SupportTicketType @map("ticket_type") status SupportTicketStatus @default(PENDING_REVIEW) + priority SupportTicketPriority @default(NORMAL) @map("priority") title String @db.VarChar(128) content String? @db.Text rejectReason String? @map("reject_reason") @db.VarChar(512) diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index ef05aae..95eaf93 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -116,6 +116,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ requiresRestart: false, description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」', }, + { + key: 'MINI_USER_MIN_VERSION', + label: '小程序最低版本', + group: G.wechat_mini, + type: 'string', + requiresRestart: false, + placeholder: '3.4.13', + description: 'semver 格式;客户端低于此版本时提示更新', + }, { key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true }, { key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true }, diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts index 2273d30..8f65c25 100644 --- a/server/dukang-api/src/modules/common/client-config.controller.ts +++ b/server/dukang-api/src/modules/common/client-config.controller.ts @@ -11,6 +11,7 @@ export class ClientConfigController { const cfg = this.systemConfig.getAppConfig(); const env = this.systemConfig.getMergedEnv(); const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim(); + const minClientVersion = (env.MINI_USER_MIN_VERSION ?? '').trim() || null; return { mockPay: cfg.mockPay, wechatPayEnabled: cfg.wechatPayEnabled, @@ -19,6 +20,7 @@ export class ClientConfigController { wxAuthorize: cfg.wxAuthorize, /** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */ tencentLbsKey: cfg.tencentLbsKey || undefined, + minClientVersion, miniHome: { banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS), footerUrl: footer || null, diff --git a/server/dukang-api/src/modules/common/dto/support-ticket.dto.ts b/server/dukang-api/src/modules/common/dto/support-ticket.dto.ts index 3ada323..f0497a8 100644 --- a/server/dukang-api/src/modules/common/dto/support-ticket.dto.ts +++ b/server/dukang-api/src/modules/common/dto/support-ticket.dto.ts @@ -11,6 +11,10 @@ export class SupportTicketListQueryDto { @IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED']) status?: string; + @IsOptional() + @IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT']) + priority?: string; + @IsOptional() @Type(() => Number) @IsInt() @@ -46,6 +50,10 @@ export class CreateSupportTicketDto { @IsArray() @IsString({ each: true }) attachmentUrls?: string[]; + + @IsOptional() + @IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT']) + priority?: string; } export class UpdateSupportTicketDto { @@ -72,6 +80,10 @@ export class UpdateSupportTicketDto { @IsArray() @IsString({ each: true }) attachmentUrls?: string[]; + + @IsOptional() + @IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT']) + priority?: string; } export class BatchUpdateSupportTicketStatusDto { diff --git a/server/dukang-api/src/modules/common/support-ticket.service.ts b/server/dukang-api/src/modules/common/support-ticket.service.ts index 55093e5..fce12a4 100644 --- a/server/dukang-api/src/modules/common/support-ticket.service.ts +++ b/server/dukang-api/src/modules/common/support-ticket.service.ts @@ -3,7 +3,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import type { SupportTicketStatus, SupportTicketType } from '@prisma/client'; +import type { SupportTicketStatus, SupportTicketType, SupportTicketPriority } from '@prisma/client'; import { Prisma } from '@prisma/client'; import type { CreateDevPlanTaskFromTicketInput } from '@dukang/shared-types'; import { mapSupportTicketTypeToDevPlanTask } from '@dukang/shared-types'; @@ -68,6 +68,7 @@ export class SupportTicketService { title: dto.title.trim(), content: dto.content?.trim() || null, remark: dto.remark?.trim() || null, + priority: (dto.priority as SupportTicketPriority | undefined) ?? 'NORMAL', attachmentUrls: dto.attachmentUrls?.length ? (dto.attachmentUrls.map((u) => u.trim()).filter(Boolean) as unknown as Prisma.InputJsonValue) : undefined, @@ -178,6 +179,7 @@ export class SupportTicketService { const urls = dto.attachmentUrls.map((u) => u.trim()).filter(Boolean); data.attachmentUrls = urls.length ? (urls as unknown as Prisma.InputJsonValue) : Prisma.JsonNull; } + if (dto.priority != null) data.priority = dto.priority as SupportTicketPriority; const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data }); return serializeBigInt(mapSupportTicketRow(updated)); } @@ -247,6 +249,9 @@ export class SupportTicketService { if (query.status) { where.status = query.status as SupportTicketStatus; } + if (query.priority) { + where.priority = query.priority as SupportTicketPriority; + } const [items, total] = await Promise.all([ this.prisma.commonSupportTicket.findMany({ diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index b9fa7f5..d5d54d0 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -1692,6 +1692,10 @@ export class AuthService { throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信'); } + if (account.status !== 'ACTIVE') { + throw new BadRequestException('合伙人账号已停用'); + } + account = await this.prisma.partnerAccount.update({ where: { id: account.id }, data: { diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md index 6453dbe..d650004 100644 --- a/杜康好客-v3-PRD.md +++ b/杜康好客-v3-PRD.md @@ -44,6 +44,12 @@ |------|------|------| | **3.4.12** | 2026-08-04 | 售后退款回滚、mini-user 门店详情、酒厂 T+3、门店多笔提现、开发计划批量编辑/审批企微派发、技术支持编辑/附件/批量改状态、套餐 imageUrl;开发设计见 [`杜康好客-v3.4.12-工单迭代开发文档.md`](./杜康好客-v3.4.12-工单迭代开发文档.md) | +### 0.5 变更:3.4.13 体验优化 + +| 版本 | 日期 | 说明 | +|------|------|------| +| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/物流体验、H5 登录校验、合伙人微信暂停禁登;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) | + --- ## 1. 背景与目标 diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md index c9d1dc8..08aa901 100644 --- a/杜康好客-v3-现状对照.md +++ b/杜康好客-v3-现状对照.md @@ -330,10 +330,27 @@ C2~C7、C14 见 §1.3。 --- -## 10. 变更记录 +## 10. v3.4.13 体验优化(2026-08-05) + +| 项 | 状态 | 说明 | +|----|------|------| +| 推广码 attributionCount | ✅ | HQ 详情统计卡 | +| 核销用户信息 | ✅ | RedeemRecordsPage 列表+详情 | +| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI | +| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat | +| H5 登录前端校验 | ✅ | partner/shop LoginPage | +| mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 | +| mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 | +| mini-user 版本/物流 | ✅ | minClientVersion + UpdateManager;order-logistics | +| 文档 | ✅ | PRD §0.5 + v3.4.13 开发文档 | + +--- + +## 11. 变更记录 | 日期 | 说明 | |------|------| +| 2026-08-05 | v3.4.13 体验优化(20 条 ST) | | 2026-08-04 | v3.4.12 工单迭代(退款/财务/C端/开发计划/技术支持/套餐) | | 2026-08-04 | v3.4.11 开发计划 + 企微智能机器人/消息推送 + 角色权限重构 | | 2026-07-12 | **P0 已执行**:C2~C7、C14 代码与文档对齐;§1 改为计划+状态表 | diff --git a/杜康好客-v3.4.13-体验优化开发文档.md b/杜康好客-v3.4.13-体验优化开发文档.md new file mode 100644 index 0000000..2f342fa --- /dev/null +++ b/杜康好客-v3.4.13-体验优化开发文档.md @@ -0,0 +1,64 @@ +# 杜康好客 · v3.4.13 体验优化开发文档 + +> 版本:**v3.4.13** · 日期:2026-08-05 +> 需求源:20 条 ST 工单(体验优化 / Bug / 客户端验证) + +## 1. 范围 + +| 模块 | 内容 | +|------|------| +| admin-web | 推广码 attributionCount;核销记录用户信息;技术支持工单优先级 | +| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;物流追踪页 | +| h5-partner / h5-shop | 登录 phone/code 前端校验 | +| 后端 | 工单 priority 字段;client-config minClientVersion;合伙人微信登录暂停拦截 | + +## 2. ST 映射 + +| ST | 标题 | 状态 | +|----|------|------| +| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount 统计卡 | +| ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call | +| ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI | +| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ 已有(v3.4.12 前) | +| ST1785907536648201 | 版本不对提示更新 | ✅ UpdateManager + minClientVersion | +| ST1785906800359657 | 合伙人暂停后禁登 | ✅ loginPartnerWechat status | +| ST1785906592340255 | PARTNER_H5 login 校验 | ✅ 前端空字段拦截 | +| ST1785906390321653 | 现场提货提交确认弹框 | ✅ showModal | +| ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情 | +| ST1785904849234806 | 工单中心 | ✅ 已有 | +| ST1785904076632841 | 门店列表开城合伙人 | ✅ 已有 | +| ST1785902173093977 | 去掉商品详情分享按钮 | ✅ 移除 ShareNavButton | +| ST1785902141731113 | 商品首图大图 | ✅ previewable | +| ST1785901870913349 | 门店列表营业时间 | ✅ 已有 | +| ST1785901838948572 | SHOP 未绑定门店 | ✅ 已有 | +| ST1785901775231811 | 门店套餐遮挡 | ✅ 折叠/行数限制 | +| ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 | +| ST1785901314893145 | 门头照裁剪 | ✅ aspectFit + preview | +| ST1785939375449985 | 订单物流追踪页 | ✅ order-logistics | + +## 3. 后端 API / 配置 + +| 方法 | 路径 / 配置 | 说明 | +|------|-------------|------| +| GET | `/common/client-config` | 新增 `minClientVersion`(env `MINI_USER_MIN_VERSION`) | +| — | `CommonSupportTicket.priority` | `LOW \| NORMAL \| HIGH \| URGENT`,默认 NORMAL | +| — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 | +| GET | `/trade/orders/:id/track` | 物流追踪页复用 | + +## 4. 数据表 + +- `common_support_ticket.priority` ENUM,默认 `NORMAL` + +## 5. HQ 开发计划 + +在 admin-web **开发计划 → 版本列表** 创建 `v3.4.13`(状态 `IN_PROGRESS`),审批 ST 后关联 `dev_plan_task`。 + +## 6. 验收 ACC + +- [ ] 推广码详情展示 attributionCount +- [ ] 核销记录含 userNo/nickname/phone +- [ ] 技术支持可创建/筛选/编辑优先级 +- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒 +- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常 +- [ ] mini-user:低版本弹窗、UpdateManager、物流追踪页 +- [ ] `pnpm lint` 无新增错误