From 4f363fa9e77530fc109d87ef146c57acba03b45d Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Thu, 6 Aug 2026 01:02:07 +0800 Subject: [PATCH] feat(v3.4.13): partner column, version release cascade, client UX fixes - HQ stores: partner column uses companyName/name/phone via partnerOptionLabel - Dev plan: version RELEASED auto-marks tasks RELEASED and tickets PUBLISHED with releasedVersionNo - mini-user/h5-shop/h5-partner v3.4.13 UX fixes (store UI, iOS scan OAuth, partner login guard) - Docs: v3.4.13 dev doc + status audit updates Co-authored-by: Cursor --- apps/admin-web/src/pages/StoresPage.tsx | 15 +- .../src/pages/SupportTicketsPage.tsx | 18 +- apps/h5-partner/src/lib/wechat-auth.ts | 13 + apps/h5-partner/src/pages/LoginPage.tsx | 30 +- .../src/contexts/StoreSessionContext.tsx | 3 +- apps/h5-shop/src/lib/shop-scan-auth.ts | 33 + apps/h5-shop/src/lib/wechat-auth.ts | 14 + apps/h5-shop/src/pages/HomePage.tsx | 578 +++++++++--------- apps/h5-shop/src/pages/LoginPage.tsx | 4 +- apps/h5-shop/src/pages/MinePage.tsx | 4 +- .../src/components/ProductCarousel.tsx | 2 +- .../src/pages/store-detail/index.tsx | 79 +-- apps/mini-user/src/pages/stores/index.tsx | 15 +- apps/mini-user/src/styles/store-detail.css | 124 ++-- apps/mini-user/src/styles/stores.css | 11 +- packages/domain/src/support-ticket.test.ts | 9 + packages/domain/src/support-ticket.ts | 6 +- packages/shared-types/src/support-ticket.ts | 7 +- packages/weixin-sdk/src/index.ts | 4 +- packages/weixin-sdk/src/scan.ts | 17 +- server/dukang-api/prisma/schema.prisma | 4 + .../src/modules/dev-plan/dev-plan.service.ts | 52 ++ .../src/modules/ops/admin-stores.service.ts | 2 +- 杜康好客-v3-现状对照.md | 2 +- 杜康好客-v3.4.13-体验优化开发文档.md | 33 +- 25 files changed, 658 insertions(+), 421 deletions(-) create mode 100644 apps/h5-shop/src/lib/shop-scan-auth.ts diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 8d64d01..533a766 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -376,7 +376,7 @@ type StoreRow = { visibilityWhitelistEnabled?: boolean; visibilityPhones?: string[]; cityRef?: { name: string; code: string }; - partner?: { companyName: string }; + partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null }; account?: { phone: string; name: string; @@ -394,13 +394,14 @@ type StoreRow = { category?: { id: string; name: string; parentId?: string | null } | null; }; -type PartnerOption = { id: string; companyName: string; name?: string }; +type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null }; function partnerOptionLabel(p: PartnerOption): string { const company = (p.companyName || '').trim(); const person = (p.name || '').trim(); + const phone = (p.phone || '').trim(); if (company && person) return `${company}-${person}`; - return company || person || p.id; + return company || person || phone || p.id; } type CityOption = { id: string; @@ -867,7 +868,13 @@ export default function StoresPage() { ); }, }, - { title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 }, + { + title: '开城合伙人', + dataIndex: 'partner', + width: 140, + render: (partner: StoreRow['partner']) => + partner ? partnerOptionLabel({ id: partner.id ?? '', ...partner }) : '—', + }, { title: '可见', dataIndex: 'visibilityWhitelistEnabled', diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx index f889ea0..eea6009 100644 --- a/apps/admin-web/src/pages/SupportTicketsPage.tsx +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -56,6 +56,7 @@ const STATUS_COLOR: Record = { DEVELOPING: 'blue', TESTING: 'purple', PASSED: 'green', + PUBLISHED: 'cyan', }; const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map( @@ -482,8 +483,15 @@ export default function SupportTicketsPage() { title: '状态', dataIndex: 'status', width: 100, - render: (s: SupportTicketStatusDto) => ( - {SUPPORT_TICKET_STATUS_LABELS[s] ?? s} + render: (s: SupportTicketStatusDto, row) => ( + + {SUPPORT_TICKET_STATUS_LABELS[s] ?? s} + {row.releasedVersionNo ? ( + + {row.releasedVersionNo} + + ) : null} + ), }, { title: '标题', dataIndex: 'title', ellipsis: true }, @@ -666,6 +674,11 @@ export default function SupportTicketsPage() { {detail.status === 'PASSED' && ( 此工单已测试通过 )} + {detail.status === 'PUBLISHED' && ( + + 已发布{detail.releasedVersionNo ? ` · ${detail.releasedVersionNo}` : ''} + + )} 创建于 {fmtTime(detail.createdAt)} @@ -689,6 +702,7 @@ export default function SupportTicketsPage() { { key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt }, { key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null }, { key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt }, + { key: 'PUBLISHED' as SupportTicketStatusDto, label: '已发布', time: detail.publishedAt }, ] : [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]), ]; diff --git a/apps/h5-partner/src/lib/wechat-auth.ts b/apps/h5-partner/src/lib/wechat-auth.ts index 5a14d7b..2e7bb8a 100644 --- a/apps/h5-partner/src/lib/wechat-auth.ts +++ b/apps/h5-partner/src/lib/wechat-auth.ts @@ -48,6 +48,19 @@ export async function canPartnerUseWechatLogin(options: { } } +/** 短信登录/发码前:校验手机号对应合伙人存在且未暂停 */ +export async function checkPartnerPhoneForLogin(phone: string): Promise { + const normalized = phone.trim(); + if (!/^1\d{10}$/.test(normalized)) { + throw new Error('请输入正确的手机号'); + } + return request('PARTNER_H5', '/partner/auth/phone/check', { + method: 'POST', + body: JSON.stringify({ phone: normalized }), + silent: true, + }); +} + export async function fetchClientConfig(): Promise { return request('PARTNER_H5', '/common/client-config'); } diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx index 5078e19..6c958ec 100644 --- a/apps/h5-partner/src/pages/LoginPage.tsx +++ b/apps/h5-partner/src/pages/LoginPage.tsx @@ -12,8 +12,10 @@ import { } from '../lib/api'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { partnerHomePath } from '../lib/partnerAccess'; +import type { PartnerPhoneCheckResponse } from '@dukang/shared-types'; import { bindPartnerWechatAfterSmsLogin, + checkPartnerPhoneForLogin, fetchClientConfig, loginPartnerWithWechat, } from '../lib/wechat-auth'; @@ -69,14 +71,17 @@ function formatPartnerError(e: unknown): string { if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) { return '未找到合伙人账号'; } - if (text.includes('合伙人账号已停用')) { - return '合伙人账号已停用'; + if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) { + return '合伙人账号已暂停,无法登录'; } return text; } function formatWechatError(e: unknown): string { const text = e instanceof Error ? e.message : '微信登录失败'; + if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) { + return '合伙人账号已暂停,无法登录'; + } if (text.includes('首次登录') || text.includes('手机验证码')) { return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信'; } @@ -138,11 +143,21 @@ export default function LoginPage() { async function sendCode() { if (!ensureAgreed()) return; + const trimmedPhone = phone.trim(); + if (!trimmedPhone) { + setMsg('请输入手机号'); + return; + } + if (!/^1\d{10}$/.test(trimmedPhone)) { + setMsg('请输入正确的手机号'); + return; + } setMsg(''); try { + await checkPartnerPhoneForLogin(trimmedPhone); await request('PARTNER_H5', '/partner/auth/sms/send', { method: 'POST', - body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }), + body: JSON.stringify({ phone: trimmedPhone, scene: 'PARTNER_LOGIN' }), silent: true, }); setMsg('验证码已发送'); @@ -171,16 +186,21 @@ export default function LoginPage() { const trimmedPhone = phone.trim(); const trimmedCode = code.trim(); if (!trimmedPhone) { - setMsg('phone should not be empty'); + setMsg('请输入手机号'); + return; + } + if (!/^1\d{10}$/.test(trimmedPhone)) { + setMsg('请输入正确的手机号'); return; } if (!trimmedCode) { - setMsg('code should not be empty'); + setMsg('请输入验证码'); return; } setLoading(true); setMsg(''); try { + await checkPartnerPhoneForLogin(trimmedPhone); const data = await request('PARTNER_H5', '/partner/auth/login/sms', { method: 'POST', body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }), diff --git a/apps/h5-shop/src/contexts/StoreSessionContext.tsx b/apps/h5-shop/src/contexts/StoreSessionContext.tsx index 823ced3..678a451 100644 --- a/apps/h5-shop/src/contexts/StoreSessionContext.tsx +++ b/apps/h5-shop/src/contexts/StoreSessionContext.tsx @@ -19,6 +19,7 @@ import { import { fetchClientConfig, handleShopWechatCallback, + handleShopWechatCallbackOnce, handleShopWechatLoginResult, isShopWechatUnboundError, SHOP_WX_NEED_PHONE_LOGIN_MSG, @@ -79,7 +80,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) { try { const config = await fetchClientConfig(); if (isWxAuthorizeEnabled(config)) { - const result = await handleShopWechatCallback(); + const result = await handleShopWechatCallbackOnce(); if (result && !cancelled) { const session = handleShopWechatLoginResult(result); if (session) applySession(session); diff --git a/apps/h5-shop/src/lib/shop-scan-auth.ts b/apps/h5-shop/src/lib/shop-scan-auth.ts new file mode 100644 index 0000000..1dbc5da --- /dev/null +++ b/apps/h5-shop/src/lib/shop-scan-auth.ts @@ -0,0 +1,33 @@ +import { isIosDevice } from '@dukang/weixin-sdk'; + +/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */ +export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan'; + +export function markPendingScanAfterAuth(): void { + try { + sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1'); + } catch { + /* ignore */ + } +} + +export function peekPendingScanAfterAuth(): boolean { + try { + return sessionStorage.getItem(SHOP_PENDING_SCAN_KEY) === '1'; + } catch { + return false; + } +} + +export function clearPendingScanAfterAuth(): void { + try { + sessionStorage.removeItem(SHOP_PENDING_SCAN_KEY); + } catch { + /* ignore */ + } +} + +/** OAuth 回跳后延迟再调 scanQRCode(iOS JSSDK 离线校验更慢) */ +export function getPostAuthScanDelayMs(): number { + return isIosDevice() ? 1200 : 600; +} diff --git a/apps/h5-shop/src/lib/wechat-auth.ts b/apps/h5-shop/src/lib/wechat-auth.ts index a40328a..6c4f320 100644 --- a/apps/h5-shop/src/lib/wechat-auth.ts +++ b/apps/h5-shop/src/lib/wechat-auth.ts @@ -138,6 +138,20 @@ export async function handleShopWechatCallback(): Promise | null = null; + +/** 全局单例:避免 Context 与页面 effect 重复消费 OAuth code */ +export async function handleShopWechatCallbackOnce(): Promise { + if (!isWechatEnv()) return null; + const params = new URLSearchParams(window.location.search); + if (!params.get('code')) return null; + if (oauthCallbackInflight) return oauthCallbackInflight; + oauthCallbackInflight = handleShopWechatCallback().finally(() => { + oauthCallbackInflight = null; + }); + return oauthCallbackInflight; +} + /** * 微信一键登录(已绑定微信的门店账号免验证码)。 * 返回 session = 已登录;void = 已跳转授权页等待回调。 diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 966ffb6..77f9db4 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -1,285 +1,293 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; -import { useStoreSession } from '../contexts/StoreSessionContext'; -import { request } from '../lib/api'; -import { parseRedeemTokenFromScan } from '../lib/redeem-scan'; -import { - authorizeShopWechat, - checkNeedsWechatAuth, - fetchShopAccount, - handleShopWechatCallback, - handleShopWechatLoginResult, -} from '../lib/wechat-auth'; -import { isWechatEnv, weixinSdk } from '../lib/weixin'; -import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; -import WechatScanAuthModal from '../components/WechatScanAuthModal'; -import { useStorePageView } from '../lib/usePageView'; -import { trackStore } from '../lib/analytics'; - -const PENDING_SCAN_KEY = 'shop_pending_scan'; - -function formatMoney(n: number) { - return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); -} - -function formatScanError(e: unknown): string { - const msg = e instanceof Error ? e.message : '扫码失败,请重试'; - if (/invalid signature/i.test(msg)) { - return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; - } - if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) { - return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)'; - } - return msg; -} - -export default function HomePage() { - useStorePageView('store_home_view'); - const navigate = useNavigate(); - const { applySession } = useStoreSession(); - const [searchParams, setSearchParams] = useSearchParams(); - const [dash, setDash] = useState | null>(null); - const [scanMsg, setScanMsg] = useState(''); - const [scanning, setScanning] = useState(false); - const [authModalOpen, setAuthModalOpen] = useState(false); - const [authLoading, setAuthLoading] = useState(false); - const [authError, setAuthError] = useState(''); - - const loadDashboard = useCallback(() => { - return request>('SHOP_H5', '/shop/dashboard') - .then((d) => { - setDash(d); - }) - .catch(() => {}); - }, []); - - useEffect(() => { - void loadDashboard(); - }, [loadDashboard]); - - useEffect(() => { - function onResume() { - setScanning(false); - void loadDashboard(); - } - function onVisibility() { - if (document.visibilityState === 'visible') onResume(); - } - document.addEventListener('visibilitychange', onVisibility); - window.addEventListener('pageshow', onResume); - window.addEventListener('focus', onResume); - return () => { - document.removeEventListener('visibilitychange', onVisibility); - window.removeEventListener('pageshow', onResume); - window.removeEventListener('focus', onResume); - }; - }, [loadDashboard]); - - async function runScan(opts?: { postAuthWarmup?: boolean }) { - trackStore('store_redeem_scan_start'); - if (!isWechatEnv()) { - setScanMsg('请在微信内打开门店端进行扫码核销'); - return; - } - setScanning(true); - setScanMsg(''); - try { - // 授权回跳后强制重签,避免沿用带 code 的旧签名态 - if (opts?.postAuthWarmup) { - weixinSdk.reset(); - } - await weixinSdk.init(); - const raw = await weixinSdk.scanQrCode( - opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined, - ); - if (!raw) { - void loadDashboard(); - return; - } - const token = parseRedeemTokenFromScan(raw); - if (!token) { - setScanMsg('无法识别核销码,请扫描用户出示的核销二维码'); - return; - } - navigate(`/redeem?token=${encodeURIComponent(token)}`); - } catch (e) { - setScanMsg(formatScanError(e)); - } finally { - setScanning(false); - } - } - - useEffect(() => { - if (!isWechatEnv() || !searchParams.get('code')) return; - void handleShopWechatCallback() - .then((result) => { - if (!result) return; - const session = handleShopWechatLoginResult(result); - if (session) { - applySession(session); - } - setAuthModalOpen(false); - setAuthError(''); - // 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致 - stripOAuthParamsFromLocation(); - setSearchParams({}, { replace: true }); - const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1'; - sessionStorage.removeItem(PENDING_SCAN_KEY); - if (shouldScan) { - // OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次 - window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400); - } - }) - .catch((e) => { - setAuthError(e instanceof Error ? e.message : '微信授权失败'); - }); - }, [searchParams, applySession, setSearchParams]); - - async function handleScan() { - setScanMsg(''); - if (!isWechatEnv()) { - setScanMsg('请在微信内打开门店端进行扫码核销'); - return; - } - try { - const profile = await fetchShopAccount(); - if (await checkNeedsWechatAuth(profile)) { - setAuthModalOpen(true); - return; - } - await runScan(); - } catch (e) { - setScanMsg(e instanceof Error ? e.message : '无法发起扫码'); - } - } - - async function startWechatAuth() { - setAuthLoading(true); - setAuthError(''); - try { - sessionStorage.setItem(PENDING_SCAN_KEY, '1'); - await authorizeShopWechat(); - } catch (e) { - sessionStorage.removeItem(PENDING_SCAN_KEY); - setAuthError(e instanceof Error ? e.message : '微信授权失败'); - setAuthLoading(false); - } - } - - const store = dash?.store as Record | undefined; - const recent = (dash?.recentRecords as Array>) || []; - const status = String(store?.status || ''); - const open = status === 'OPEN'; - const hoursParts: string[] = []; - if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`); - if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`); - const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00'; - const statusText = - status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店'; - - return ( - -
-

门店管理中心

-
- -
-
-
- store -

{String(store?.name || '门店')}

-
-
-
-

今日核销笔数

-

{Number(dash?.todayCount || 0)}

-

- 扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)} -

-
-
-

今日到账金额

-

- ¥ - {formatMoney(Number(dash?.todayAmount || 0))} -

-
-
-
- -
- -

{scanning ? '正在打开相机…' : '扫码核销'}

- {scanMsg &&

{scanMsg}

} - - smartphone - 手机号核销 - -
- -
-
-
- schedule -
-
-

营业状态

-

{statusText}

-

营业时间: {hoursText}

-
-
- -
- -
-
-

核销记录

- - 查看全部 - chevron_right - -
-
- {recent.length === 0 && ( -

暂无核销记录

- )} - {recent.map((r) => ( -
-
-

核销时间

-

- {new Date(String(r.createdAt)).toLocaleString('zh-CN')} -

-
-

¥{formatMoney(Number(r.amount))}

-
- ))} -
-
-
- - void startWechatAuth()} - onCancel={() => { - setAuthModalOpen(false); - setAuthError(''); - sessionStorage.removeItem(PENDING_SCAN_KEY); - }} - /> -
- ); -} +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; +import { isScanPermissionWarmupError } from '@dukang/weixin-sdk'; +import { useStoreSession } from '../contexts/StoreSessionContext'; +import { request } from '../lib/api'; +import { parseRedeemTokenFromScan } from '../lib/redeem-scan'; +import { + authorizeShopWechat, + checkNeedsWechatAuth, + fetchShopAccount, +} from '../lib/wechat-auth'; +import { isWechatEnv, weixinSdk } from '../lib/weixin'; +import { + clearPendingScanAfterAuth, + getPostAuthScanDelayMs, + markPendingScanAfterAuth, + peekPendingScanAfterAuth, +} from '../lib/shop-scan-auth'; +import WechatScanAuthModal from '../components/WechatScanAuthModal'; +import { useStorePageView } from '../lib/usePageView'; +import { trackStore } from '../lib/analytics'; + +function formatMoney(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); +} + +function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string { + const msg = e instanceof Error ? e.message : '扫码失败,请重试'; + if (/invalid signature/i.test(msg)) { + return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; + } + if (isScanPermissionWarmupError(msg)) { + if (opts?.afterAuth) { + return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」'; + } + return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)'; + } + return msg; +} + +export default function HomePage() { + useStorePageView('store_home_view'); + const navigate = useNavigate(); + const { ready, authenticated } = useStoreSession(); + const [searchParams] = useSearchParams(); + const [dash, setDash] = useState | null>(null); + const [scanMsg, setScanMsg] = useState(''); + const [scanning, setScanning] = useState(false); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [authLoading, setAuthLoading] = useState(false); + const [authError, setAuthError] = useState(''); + const pendingScanStartedRef = useRef(false); + + const loadDashboard = useCallback(() => { + return request>('SHOP_H5', '/shop/dashboard') + .then((d) => { + setDash(d); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + void loadDashboard(); + }, [loadDashboard]); + + useEffect(() => { + function onResume() { + setScanning(false); + void loadDashboard(); + } + function onVisibility() { + if (document.visibilityState === 'visible') onResume(); + } + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('pageshow', onResume); + window.addEventListener('focus', onResume); + return () => { + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('pageshow', onResume); + window.removeEventListener('focus', onResume); + }; + }, [loadDashboard]); + + const runScan = useCallback( + async (opts?: { postAuthWarmup?: boolean }) => { + trackStore('store_redeem_scan_start'); + if (!isWechatEnv()) { + setScanMsg('请在微信内打开门店端进行扫码核销'); + return; + } + setScanning(true); + if (!opts?.postAuthWarmup) { + setScanMsg(''); + } + try { + if (opts?.postAuthWarmup) { + weixinSdk.reset(); + } + await weixinSdk.init(); + const raw = await weixinSdk.scanQrCode( + opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined, + ); + if (!raw) { + void loadDashboard(); + return; + } + const token = parseRedeemTokenFromScan(raw); + if (!token) { + setScanMsg('无法识别核销码,请扫描用户出示的核销二维码'); + return; + } + navigate(`/redeem?token=${encodeURIComponent(token)}`); + } catch (e) { + setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup })); + } finally { + setScanning(false); + } + }, + [loadDashboard, navigate], + ); + + // OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫 + useEffect(() => { + if (!ready || !authenticated || !isWechatEnv()) return; + if (searchParams.get('code')) return; + if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return; + + pendingScanStartedRef.current = true; + clearPendingScanAfterAuth(); + setAuthModalOpen(false); + setAuthLoading(false); + setAuthError(''); + setScanMsg('微信授权成功,正在准备扫码…'); + + const timer = window.setTimeout(() => { + void runScan({ postAuthWarmup: true }); + }, getPostAuthScanDelayMs()); + + return () => window.clearTimeout(timer); + }, [ready, authenticated, searchParams, runScan]); + + async function handleScan() { + setScanMsg(''); + if (!isWechatEnv()) { + setScanMsg('请在微信内打开门店端进行扫码核销'); + return; + } + try { + const profile = await fetchShopAccount(); + if (await checkNeedsWechatAuth(profile)) { + pendingScanStartedRef.current = false; + setAuthModalOpen(true); + return; + } + await runScan(); + } catch (e) { + setScanMsg(e instanceof Error ? e.message : '无法发起扫码'); + } + } + + async function startWechatAuth() { + setAuthLoading(true); + setAuthError(''); + try { + pendingScanStartedRef.current = false; + markPendingScanAfterAuth(); + await authorizeShopWechat(); + } catch (e) { + clearPendingScanAfterAuth(); + setAuthError(e instanceof Error ? e.message : '微信授权失败'); + setAuthLoading(false); + } + } + + const store = dash?.store as Record | undefined; + const recent = (dash?.recentRecords as Array>) || []; + const status = String(store?.status || ''); + const open = status === 'OPEN'; + const hoursParts: string[] = []; + if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`); + if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`); + const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00'; + const statusText = + status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店'; + + return ( + +
+

门店管理中心

+
+ +
+
+
+ store +

{String(store?.name || '门店')}

+
+
+
+

今日核销笔数

+

{Number(dash?.todayCount || 0)}

+

+ 扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)} +

+
+
+

今日到账金额

+

+ ¥ + {formatMoney(Number(dash?.todayAmount || 0))} +

+
+
+
+ +
+ +

{scanning ? '正在打开相机…' : '扫码核销'}

+ {scanMsg &&

{scanMsg}

} + + smartphone + 手机号核销 + +
+ +
+
+
+ schedule +
+
+

营业状态

+

{statusText}

+

营业时间: {hoursText}

+
+
+ +
+ +
+
+

核销记录

+ + 查看全部 + chevron_right + +
+
+ {recent.length === 0 && ( +

暂无核销记录

+ )} + {recent.map((r) => ( +
+
+

核销时间

+

+ {new Date(String(r.createdAt)).toLocaleString('zh-CN')} +

+
+

¥{formatMoney(Number(r.amount))}

+
+ ))} +
+
+
+ + void startWechatAuth()} + onCancel={() => { + setAuthModalOpen(false); + setAuthError(''); + clearPendingScanAfterAuth(); + pendingScanStartedRef.current = false; + }} + /> +
+ ); +} + \ No newline at end of file diff --git a/apps/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index cee9945..0aa5212 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -18,7 +18,7 @@ import { consumeShopWechatLoginHint, fetchClientConfig, formatShopWechatError, - handleShopWechatCallback, + handleShopWechatCallbackOnce, handleShopWechatLoginResult, loginShopWithWechat, } from '../lib/wechat-auth'; @@ -88,7 +88,7 @@ export default function LoginPage() { useEffect(() => { if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return; - void handleShopWechatCallback() + void handleShopWechatCallbackOnce() .then((result) => { if (!result) return; const session = handleShopWechatLoginResult(result); diff --git a/apps/h5-shop/src/pages/MinePage.tsx b/apps/h5-shop/src/pages/MinePage.tsx index cc4d41a..1fe75f0 100644 --- a/apps/h5-shop/src/pages/MinePage.tsx +++ b/apps/h5-shop/src/pages/MinePage.tsx @@ -9,7 +9,7 @@ import { authorizeShopWechat, fetchClientConfig, fetchShopAccount, - handleShopWechatCallback, + handleShopWechatCallbackOnce, handleShopWechatLoginResult, } from '../lib/wechat-auth'; import { isWechatEnv } from '../lib/weixin'; @@ -45,7 +45,7 @@ export default function MinePage() { useEffect(() => { if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return; - void handleShopWechatCallback() + void handleShopWechatCallbackOnce() .then((result) => { if (!result) return; const session = handleShopWechatLoginResult(result); diff --git a/apps/mini-user/src/components/ProductCarousel.tsx b/apps/mini-user/src/components/ProductCarousel.tsx index 438f3e5..eb3ca12 100644 --- a/apps/mini-user/src/components/ProductCarousel.tsx +++ b/apps/mini-user/src/components/ProductCarousel.tsx @@ -20,7 +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'; + const imageMode = 'aspectFill'; function previewAt(index: number) { const urls = slides.filter(Boolean); diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index dc9d59d..cdaf89a 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { View, Text, Image } from '@tarojs/components'; +import { View, Text, Image, ScrollView } from '@tarojs/components'; import Taro, { useDidShow, useLoad, @@ -156,10 +156,14 @@ export default function StoreDetailPage() { const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [headerSolid, setHeaderSolid] = useState(false); - const [expandedPackages, setExpandedPackages] = useState>({}); + const [activePackageIndex, setActivePackageIndex] = useState(0); const storeRef = useRef(null); storeRef.current = store; + useEffect(() => { + setActivePackageIndex(0); + }, [store?.id]); + usePageScroll(({ scrollTop }) => { setHeaderSolid(scrollTop > 100); }); @@ -321,6 +325,8 @@ export default function StoreDetailPage() { const envPhotos = envPhotoUrls(store); const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]); + const packages = store.packages ?? []; + const activePackage = packages[activePackageIndex] ?? packages[0]; const intro = store.intro?.trim() || ''; const benefitRuleRaw = store.benefitUsageRule?.trim() || ''; @@ -402,44 +408,49 @@ export default function StoreDetailPage() { ) : null} - {store.packages && store.packages.length > 0 ? ( - + {packages.length > 0 && activePackage ? ( + 门店套餐 - {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} - {longBody ? ( - - setExpandedPackages((prev) => ({ ...prev, [index]: !prev[index] })) - } + {packages.length > 1 ? ( + + + {packages.map((pkg, index) => ( + setActivePackageIndex(index)} > - {expanded ? '收起' : '展开'} - - ) : null} + {pkg.name} + + ))} - {bodyText} - {pkg.usableTime ? ( - 使用时间:{pkg.usableTime} + + ) : null} + + {activePackage.imageUrl ? ( + + ) : null} + + {packages.length === 1 ? ( + {activePackage.name} ) : null} - {pkg.otherNotes ? ( - 说明:{pkg.otherNotes} + + {formatRedeemAmountYuan(activePackage.price)} 元 · {activePackage.dishes} + + {activePackage.usableTime ? ( + 使用时间:{activePackage.usableTime} + ) : null} + {activePackage.otherNotes ? ( + 说明:{activePackage.otherNotes} ) : null} - ); - })} + ) : null} diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx index 35be022..0b00f41 100644 --- a/apps/mini-user/src/pages/stores/index.tsx +++ b/apps/mini-user/src/pages/stores/index.tsx @@ -343,11 +343,10 @@ export default function StoresPage() { } function formatHours(store: Store) { - // 列表只展示第一段营业时间,避免挤占一行 - if (store.openTime && store.closeTime) { - return `营业时间: ${store.openTime}-${store.closeTime}`; - } - return '营业时间: 10:00-22:00'; + const parts: string[] = []; + if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`); + if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`); + return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00'; } function formatStatus(store: Store) { @@ -447,12 +446,10 @@ export default function StoresPage() { {formatDistanceMeters(s.distanceMeters)} - {/* 第2行:状态 + 营业时间(仅第一段) */} + {/* 第2行:状态 + 营业时间(含第二段) */} {formatStatus(s)} - - {formatHours(s)} - + {formatHours(s)} {/* 第3行:地址 + 去核销 */} diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css index 8e4157b..e1882f7 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -10,15 +10,14 @@ .store-detail-carousel-wrap { position: relative; width: 100%; - min-height: 200px; - max-height: 360px; + aspect-ratio: 4 / 3; overflow: hidden; background: var(--color-surface-container); } .store-detail-carousel { width: 100%; - height: 280px; + height: 100%; } .store-detail-carousel-item, @@ -28,6 +27,11 @@ height: 100%; } +.store-detail-carousel-image { + object-fit: cover; + display: block; +} + .store-detail-carousel-placeholder { background: var(--color-surface-container); } @@ -238,62 +242,102 @@ margin-right: 0; } -.store-detail-package-card { - padding: 12px 0; - border-bottom: 1px solid rgba(0, 0, 0, 0.06); +.store-detail-section--packages { + overflow: hidden; } -.store-detail-package-card--collapsed .store-detail-package-body, -.store-detail-package-card--collapsed .store-detail-package-meta { +.store-detail-package-tabs { + width: 100%; + margin-bottom: 10px; + white-space: nowrap; +} + +.store-detail-package-tabs-inner { + display: inline-flex; + flex-wrap: nowrap; + gap: 8px; + padding: 2px 0; +} + +.store-detail-package-tab { + display: inline-flex; + align-items: center; + flex-shrink: 0; + max-width: 132px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.04); + box-sizing: border-box; +} + +.store-detail-package-tab--active { + background: rgba(166, 29, 36, 0.1); +} + +.store-detail-package-tab-text { + font-size: 12px; + line-height: 18px; + color: var(--color-text-secondary, #666); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.store-detail-package-tab--active .store-detail-package-tab-text { + color: var(--color-heritage-red, #a61d24); + font-weight: 600; +} + +.store-detail-package-panel { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px; + border-radius: var(--radius-md, 8px); + background: var(--color-surface-container, #f7f7f7); + box-sizing: border-box; +} + +.store-detail-package-thumb { + width: 72px; + height: 72px; + border-radius: 6px; + flex-shrink: 0; + background: rgba(0, 0, 0, 0.04); +} + +.store-detail-package-panel-body { + flex: 1; + min-width: 0; +} + +.store-detail-package-body, +.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; - border-radius: var(--radius-md, 8px); - margin-bottom: 8px; - display: block; -} - -.store-detail-package-card:last-of-type { - border-bottom: none; -} - .store-detail-package-name { display: block; - font-size: 16px; + font-size: 15px; font-weight: 700; color: var(--color-text-primary, #1a1a1a); - margin-bottom: 6px; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .store-detail-package-body { - display: block; - font-size: 14px; + font-size: 13px; color: var(--color-text-secondary, #666); - line-height: 1.5; + line-height: 1.45; } .store-detail-package-meta { - display: block; - font-size: 13px; + font-size: 12px; color: var(--color-text-tertiary, #999); margin-top: 4px; } diff --git a/apps/mini-user/src/styles/stores.css b/apps/mini-user/src/styles/stores.css index 4e12d7f..383db4d 100644 --- a/apps/mini-user/src/styles/stores.css +++ b/apps/mini-user/src/styles/stores.css @@ -218,15 +218,17 @@ white-space: nowrap; } -/* 第2行:营业状态 + 营业时间(单行) */ +/* 第2行:营业状态 + 营业时间(可含两段) */ .store-card-row--meta { gap: 6px; - height: 20px; + min-height: 20px; + align-items: flex-start; } .store-card-status { flex-shrink: 0; padding: 0 6px; + margin-top: 1px; border-radius: 4px; background: rgba(45, 106, 79, 0.12); color: #2d6a4f; @@ -241,11 +243,8 @@ min-width: 0; font-size: 11px; font-weight: 400; - line-height: 20px; + line-height: 16px; color: #999; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } /* 第3行:地址(单行截断)+ 右对齐去核销 */ diff --git a/packages/domain/src/support-ticket.test.ts b/packages/domain/src/support-ticket.test.ts index a6f31e4..3110c5e 100644 --- a/packages/domain/src/support-ticket.test.ts +++ b/packages/domain/src/support-ticket.test.ts @@ -29,6 +29,15 @@ describe('validateSupportTicketStatusTransition', () => { ).toBe(false); }); + it('allows PASSED -> PUBLISHED', () => { + expect( + validateSupportTicketStatusTransition('PASSED', 'PUBLISHED', { + linkedTaskCount: 1, + isSuperAdmin: true, + }).ok, + ).toBe(true); + }); + it('requires super admin and reason for REJECTED', () => { expect( validateSupportTicketStatusTransition('DEVELOPING', 'REJECTED', { diff --git a/packages/domain/src/support-ticket.ts b/packages/domain/src/support-ticket.ts index 5c0ad2a..a298100 100644 --- a/packages/domain/src/support-ticket.ts +++ b/packages/domain/src/support-ticket.ts @@ -3,7 +3,8 @@ export type SupportTicketStatus = | 'REJECTED' | 'DEVELOPING' | 'TESTING' - | 'PASSED'; + | 'PASSED' + | 'PUBLISHED'; export type SupportTicketStatusTransitionContext = { linkedTaskCount: number; @@ -16,7 +17,8 @@ const FORWARD_TRANSITIONS: Record = DEVELOPING: ['TESTING', 'REJECTED'], TESTING: ['PASSED', 'REJECTED'], REJECTED: [], - PASSED: [], + PASSED: ['PUBLISHED'], + PUBLISHED: [], }; /** 技术支持工单批量/人工改状态校验(禁止回退) */ diff --git a/packages/shared-types/src/support-ticket.ts b/packages/shared-types/src/support-ticket.ts index 35a5133..99a1e0d 100644 --- a/packages/shared-types/src/support-ticket.ts +++ b/packages/shared-types/src/support-ticket.ts @@ -15,7 +15,8 @@ export type SupportTicketStatusDto = | 'REJECTED' | 'DEVELOPING' | 'TESTING' - | 'PASSED'; + | 'PASSED' + | 'PUBLISHED'; export const SUPPORT_TICKET_STATUSES = [ 'PENDING_REVIEW', @@ -23,6 +24,7 @@ export const SUPPORT_TICKET_STATUSES = [ 'DEVELOPING', 'TESTING', 'PASSED', + 'PUBLISHED', ] as const; export const SUPPORT_TICKET_STATUS_LABELS: Record = { @@ -31,6 +33,7 @@ export const SUPPORT_TICKET_STATUS_LABELS: Record 0) { @@ -151,7 +149,8 @@ export async function scanQrCode( await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() }); await checkJsApi('scanQRCode'); } - await delay(700 + attempt * 500); + const retryDelay = options.postAuthWarmup && ios ? 900 + attempt * 700 : 700 + attempt * 500; + await delay(retryDelay); } try { return await invokeScanQrCode(); diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index c405e9e..4331f73 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -107,6 +107,7 @@ enum SupportTicketStatus { DEVELOPING TESTING PASSED + PUBLISHED } enum SupportTicketPriority { @@ -671,6 +672,8 @@ model CommonSupportTicket { reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3) remark String? @db.VarChar(512) attachmentUrls Json? @map("attachment_urls") + releasedVersionNo String? @map("released_version_no") @db.VarChar(32) + publishedAt DateTime? @map("published_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) completedAt DateTime? @map("completed_at") @db.DateTime(3) @@ -678,6 +681,7 @@ model CommonSupportTicket { @@index([status, createdAt]) @@index([ticketType, status]) @@index([creatorId]) + @@index([releasedVersionNo]) @@map("common_support_ticket") devPlanTasks DevPlanTask[] diff --git a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts index ee32bc8..69102e5 100644 --- a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts +++ b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts @@ -671,6 +671,10 @@ export class DevPlanService { } + if (status === 'RELEASED') { + await this.cascadeVersionReleased(row.id, row.versionNo); + } + return this.getVersion(row.id); } @@ -739,6 +743,14 @@ export class DevPlanService { await this.prisma.devPlanVersion.update({ where: { id }, data }); + if ( + dto.status === 'RELEASED' && + existing.status !== 'RELEASED' + ) { + const versionNo = (dto.versionNo ?? existing.versionNo).trim(); + await this.cascadeVersionReleased(id, versionNo); + } + if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds); return this.getVersion(id); @@ -775,6 +787,46 @@ export class DevPlanService { } + private async cascadeVersionReleased(versionId: bigint, versionNo: string) { + const links = await this.prisma.devPlanVersionTask.findMany({ + where: { versionId }, + select: { taskId: true }, + }); + if (!links.length) return; + + const taskIds = links.map((l) => l.taskId); + const now = new Date(); + + await this.prisma.$transaction(async (tx) => { + await tx.devPlanTask.updateMany({ + where: { id: { in: taskIds }, status: { not: 'RELEASED' } }, + data: { status: 'RELEASED', completedAt: now }, + }); + + const tasks = await tx.devPlanTask.findMany({ + where: { id: { in: taskIds }, supportTicketId: { not: null } }, + select: { supportTicketId: true }, + }); + const ticketIds = [ + ...new Set( + tasks + .map((t) => t.supportTicketId) + .filter((id): id is bigint => id != null), + ), + ]; + if (!ticketIds.length) return; + + await tx.commonSupportTicket.updateMany({ + where: { id: { in: ticketIds }, status: { not: 'REJECTED' } }, + data: { + status: 'PUBLISHED', + releasedVersionNo: versionNo, + publishedAt: now, + }, + }); + }); + } + private async addVersionTasks(versionId: bigint, taskIds: string[]) { if (!taskIds.length) return; diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index f9bce57..df7b6b9 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -74,7 +74,7 @@ export class AdminStoresService { take: pageSize, include: { cityRef: { select: { id: true, name: true, code: true } }, - partnerAccount: { select: { id: true, companyName: true } }, + partnerAccount: { select: { id: true, companyName: true, name: true, phone: true } }, category: { select: { id: true, name: true, parentId: true } }, bindings: { where: { storeAccount: { isPrimary: 1 } }, diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md index 3fedb13..64029c7 100644 --- a/杜康好客-v3-现状对照.md +++ b/杜康好客-v3-现状对照.md @@ -339,7 +339,7 @@ C2~C7、C14 见 §1.3。 | 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI | | 合伙人微信暂停禁登 | ✅ | loginPartnerWechat | | H5 登录前端校验 | ✅ | partner/shop LoginPage | -| mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 | +| mini-user 门店体验 | ✅ | 电话脱敏/埋点、**列表两段营业时间**、门头 aspectFill 铺满、**套餐页签切换** | | mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 | | mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** | | OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 | diff --git a/杜康好客-v3.4.13-体验优化开发文档.md b/杜康好客-v3.4.13-体验优化开发文档.md index 9bcccb2..6282e70 100644 --- a/杜康好客-v3.4.13-体验优化开发文档.md +++ b/杜康好客-v3.4.13-体验优化开发文档.md @@ -7,10 +7,10 @@ | 模块 | 内容 | |------|------| -| admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势**;**权益券详情加宽**;**核销记录详情增强(对齐权益券内核销详情)**;技术支持工单优先级;**大图上传前压缩** | -| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;**我的页版本号 v3.4.x**;**低于 minClientVersion 强制更新/点我知道了退出**;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** | -| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** | -| 后端 | 工单 priority;client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器**;**log_promo_event 推广码指标日志** | +| admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势**;**权益券详情加宽**;**核销记录详情增强(对齐权益券内核销详情)**;技术支持工单优先级;**版本发布联动任务/工单已发布**;**大图上传前压缩** | +| mini-user | 门店电话脱敏+拨打埋点;**门店列表展示两段营业时间**;**门店详情门头 aspectFill 铺满无留白**;**门店套餐页签切换**;商品去分享+首图 preview;提货确认弹框;**我的页版本号 v3.4.x**;**低于 minClientVersion 强制更新/点我知道了退出**;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** | +| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**门店端 iOS 首次扫码 OAuth 单例 + JSSDK 预热重试**;**OSS 图片超 10MB 自动 canvas 压缩后上传** | +| 后端 | 工单 priority;**工单 PUBLISHED + releasedVersionNo**;**版本 RELEASED 联动任务/工单**;client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器**;**log_promo_event 推广码指标日志** | ## 2. ST 映射 @@ -19,21 +19,21 @@ | ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** | | ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call | | ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI | -| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ 已有(v3.4.12 前) | +| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ **OAuth 单例回调 + iOS JSSDK 预热/重试 + 授权后续扫** | | ST1785907536648201 | 版本不对提示更新 | ✅ 我的页 v3.4.x + UpdateManager + **低于 min 强制更新/退出** | -| ST1785906800359657 | 合伙人暂停后禁登 | ✅ loginPartnerWechat status | +| ST1785906800359657 | 合伙人暂停后禁登 | ✅ **发码前 phone/check + 微信/SMS 登录均拒 DISABLED** | | ST1785906592340255 | PARTNER_H5 login 校验 | ✅ 前端空字段拦截 | | ST1785906390321653 | 现场提货提交确认弹框 | ✅ showModal | | ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情;**详情含门店/合伙人/券分摊/结算/评价** | | ST1785904849234806 | 工单中心 | ✅ 已有 | -| ST1785904076632841 | 门店列表开城合伙人 | ✅ 已有 | +| ST1785904076632841 | 门店列表开城合伙人 | ✅ **列表展示 companyName/name/phone(同 partnerOptionLabel)** | | ST1785902173093977 | 去掉商品详情分享按钮 | ✅ 移除 ShareNavButton | | ST1785902141731113 | 商品首图大图 | ✅ previewable | -| ST1785901870913349 | 门店列表营业时间 | ✅ 已有 | +| ST1785901870913349 | 门店列表营业时间 | ✅ **列表展示两段营业时间** | | ST1785901838948572 | SHOP 未绑定门店 | ✅ 已有 | -| ST1785901775231811 | 门店套餐遮挡 | ✅ 折叠/行数限制 | +| ST1785901775231811 | 门店套餐遮挡 | ✅ **页签切换(可横滑)+ 紧凑内容区** | | ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 | -| ST1785901314893145 | 门头照裁剪 | ✅ aspectFit + preview | +| ST1785901314893145 | 门头照裁剪 | ✅ aspectFill 铺满无留白 + preview | | ST1785939375449985 | 订单物流追踪页 | ✅ 签收照 + 拨号 + 时间线 + ETA + 路由回调 | | — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload | @@ -43,6 +43,8 @@ |------|-------------|------| | GET | `/common/client-config` | `minClientVersion`(`MINI_USER_MIN_VERSION`);客户端 semver 比对,低于则强制更新或退出 | | — | `CommonSupportTicket.priority` | `LOW \| NORMAL \| HIGH \| URGENT`,默认 NORMAL | +| — | `CommonSupportTicket.status` | 新增 `PUBLISHED`(已发布);`releasedVersionNo` + `publishedAt` | +| — | `DevPlanVersion` → `RELEASED` | 关联任务 → `RELEASED`;关联工单 → `PUBLISHED` 并写入版本号 | | — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 | | GET | `/trade/orders/:id/track` | 聚合:`nodes`(旧→新)、`signPhotoUrls`、`estimatedArrival`;经 `CourierService` 适配小飞侠 cmd 100102/100108/100301 | | POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) | @@ -135,11 +137,12 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track ## 5. 数据表 - `common_support_ticket.priority` ENUM,默认 `NORMAL` +- `common_support_ticket.status` 新增 `PUBLISHED`;`released_version_no`、`published_at` - `log_promo_event`:推广码指标事件(`promo_code_id`, `event_type`, `user_id`, `order_id`, `session_id`, `client_ip`, 地点, `created_at`) ## 6. HQ 开发计划 -在 admin-web **开发计划 → 版本列表** 创建 `v3.4.13`(状态 `IN_PROGRESS`),审批 ST 后关联 `dev_plan_task`。 +在 admin-web **开发计划 → 版本列表** 创建 `v3.4.13`(状态 `IN_PROGRESS`),审批 ST 后关联 `dev_plan_task`。**版本标记为「已发布」后**,该版本下任务自动 `RELEASED`,关联技术支持工单自动 `PUBLISHED` 并写入 `releasedVersionNo`。 ## 7. 验收 ACC @@ -147,11 +150,13 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track - [ ] 推广码 SCAN/ATTRIBUTION/REGISTER/ORDER 事件含 time + IP;幂等(重复 touch 不计 SCAN) - [ ] 核销记录含 userNo/nickname/phone;**详情抽屉展示门店/地址/合伙人/权益券号/关联订单/券分摊/结算单/评价** - [ ] **权益券详情抽屉加宽(980px),内嵌核销列表无横向滚动条** -- [ ] 技术支持可创建/筛选/编辑优先级 -- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒 +- [ ] 技术支持可创建/筛选/编辑优先级;**版本 RELEASED 后关联工单为「已发布」并带版本号** +- [ ] **门店列表「开城合伙人」列展示 companyName / name / phone(与下拉选项一致)** +- [ ] 合伙人/门店登录空字段前端提示;**暂停合伙人发码前即拦截,短信/微信登录均禁止** - [ ] mini-user:我的页左下角显示 `杜康好客 v3.4.x`;低于 `minClientVersion` 时强制更新,无新包则点「我知道了」退出小程序 -- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常 +- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、**门店列表两段营业时间**、**门店详情门头铺满无留白**、**门店套餐页签切换** - [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达 - [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`:status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等 - [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错 +- [ ] **门店 H5(iPhone 微信)**:首次扫码 OAuth 后自动续扫;offline verifying 时提示再点一次扫码 - [ ] `pnpm lint` 无新增错误