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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 01:02:07 +08:00
parent 3ef4432b2f
commit 4f363fa9e7
25 changed files with 658 additions and 421 deletions
+11 -4
View File
@@ -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',
@@ -56,6 +56,7 @@ const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
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) => (
render: (s: SupportTicketStatusDto, row) => (
<Space size={4} direction="vertical" style={{ lineHeight: 1.2 }}>
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
{row.releasedVersionNo ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{row.releasedVersionNo}
</Typography.Text>
) : null}
</Space>
),
},
{ title: '标题', dataIndex: 'title', ellipsis: true },
@@ -666,6 +674,11 @@ export default function SupportTicketsPage() {
{detail.status === 'PASSED' && (
<Typography.Text type="success"></Typography.Text>
)}
{detail.status === 'PUBLISHED' && (
<Typography.Text type="success">
{detail.releasedVersionNo ? ` · ${detail.releasedVersionNo}` : ''}
</Typography.Text>
)}
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{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 }]),
];
+13
View File
@@ -48,6 +48,19 @@ export async function canPartnerUseWechatLogin(options: {
}
}
/** 短信登录/发码前:校验手机号对应合伙人存在且未暂停 */
export async function checkPartnerPhoneForLogin(phone: string): Promise<PartnerPhoneCheckResponse> {
const normalized = phone.trim();
if (!/^1\d{10}$/.test(normalized)) {
throw new Error('请输入正确的手机号');
}
return request<PartnerPhoneCheckResponse>('PARTNER_H5', '/partner/auth/phone/check', {
method: 'POST',
body: JSON.stringify({ phone: normalized }),
silent: true,
});
}
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
}
+25 -5
View File
@@ -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<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
@@ -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);
+33
View File
@@ -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 回跳后延迟再调 scanQRCodeiOS JSSDK 离线校验更慢) */
export function getPostAuthScanDelayMs(): number {
return isIosDevice() ? 1200 : 600;
}
+14
View File
@@ -138,6 +138,20 @@ export async function handleShopWechatCallback(): Promise<WechatLoginResult | nu
return weixinSdk.handleOAuthCallback();
}
let oauthCallbackInflight: Promise<WechatLoginResult | null> | null = null;
/** 全局单例:避免 Context 与页面 effect 重复消费 OAuth code */
export async function handleShopWechatCallbackOnce(): Promise<WechatLoginResult | null> {
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 = 已跳转授权页等待回调。
+47 -39
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
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';
@@ -8,27 +9,31 @@ import {
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api';
handleShopWechatCallback,
handleShopWechatLoginResult,
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import {
authorizeShopWechat,
checkNeedsWechatAuth,
fetchShopAccount,
} from '../lib/wechat-auth';
const PENDING_SCAN_KEY = 'shop_pending_scan';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import {
function formatScanError(e: unknown): string {
clearPendingScanAfterAuth,
getPostAuthScanDelayMs,
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
markPendingScanAfterAuth,
peekPendingScanAfterAuth,
} from '../lib/shop-scan-auth';
import WechatScanAuthModal from '../components/WechatScanAuthModal';
@@ -37,14 +42,15 @@ function formatScanError(e: unknown): string {
import { trackStore } from '../lib/analytics';
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
@@ -76,16 +82,18 @@ export default function HomePage() {
export default function HomePage() {
async function runScan(opts?: { postAuthWarmup?: boolean }) {
useStorePageView('store_home_view');
const navigate = useNavigate();
const { ready, authenticated } = useStoreSession();
const [searchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [scanMsg, setScanMsg] = useState('');
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
@@ -104,37 +112,33 @@ export default function HomePage() {
.then((d) => {
setScanMsg(formatScanError(e));
setDash(d);
})
}
.catch(() => {});
}, []);
if (!isWechatEnv() || !searchParams.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
}
useEffect(() => {
void loadDashboard();
}, [loadDashboard]);
useEffect(() => {
// 先清 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]);
function onResume() {
setScanning(false);
void loadDashboard();
}
function onVisibility() {
@@ -145,6 +149,7 @@ export default function HomePage() {
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pageshow', onResume);
window.addEventListener('focus', onResume);
return () => {
@@ -158,10 +163,11 @@ export default function HomePage() {
};
}, [loadDashboard]);
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
const runScan = useCallback(
sessionStorage.removeItem(PENDING_SCAN_KEY);
async (opts?: { postAuthWarmup?: boolean }) => {
trackStore('store_redeem_scan_start');
@@ -277,9 +283,11 @@ export default function HomePage() {
async function handleScan() {
sessionStorage.removeItem(PENDING_SCAN_KEY);
setScanMsg('');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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);
@@ -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);
+42 -31
View File
@@ -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<Record<number, boolean>>({});
const [activePackageIndex, setActivePackageIndex] = useState(0);
const storeRef = useRef<Store | null>(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() {
</View>
) : null}
{store.packages && store.packages.length > 0 ? (
<View className="store-detail-section">
{packages.length > 0 && activePackage ? (
<View className="store-detail-section store-detail-section--packages">
<Text className="store-detail-section-title"></Text>
{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 (
{packages.length > 1 ? (
<ScrollView className="store-detail-package-tabs" scrollX showScrollbar={false} enhanced>
<View className="store-detail-package-tabs-inner">
{packages.map((pkg, index) => (
<View
key={`${pkg.name}-${index}`}
className={`store-detail-package-card${expanded || !longBody ? '' : ' store-detail-package-card--collapsed'}`}
className={`store-detail-package-tab${
index === activePackageIndex ? ' store-detail-package-tab--active' : ''
}`}
onClick={() => setActivePackageIndex(index)}
>
{pkg.imageUrl ? (
<Image className="store-detail-package-img" src={pkg.imageUrl} mode="aspectFill" />
<Text className="store-detail-package-tab-text">{pkg.name}</Text>
</View>
))}
</View>
</ScrollView>
) : null}
<View className="store-detail-package-head">
<Text className="store-detail-package-name">{pkg.name}</Text>
{longBody ? (
<Text
className="store-detail-package-toggle"
onClick={() =>
setExpandedPackages((prev) => ({ ...prev, [index]: !prev[index] }))
}
>
{expanded ? '收起' : '展开'}
<View className="store-detail-package-panel">
{activePackage.imageUrl ? (
<Image
className="store-detail-package-thumb"
src={activePackage.imageUrl}
mode="aspectFill"
/>
) : null}
<View className="store-detail-package-panel-body">
{packages.length === 1 ? (
<Text className="store-detail-package-name">{activePackage.name}</Text>
) : null}
<Text className="store-detail-package-body">
{formatRedeemAmountYuan(activePackage.price)} · {activePackage.dishes}
</Text>
{activePackage.usableTime ? (
<Text className="store-detail-package-meta">使{activePackage.usableTime}</Text>
) : null}
{activePackage.otherNotes ? (
<Text className="store-detail-package-meta">{activePackage.otherNotes}</Text>
) : null}
</View>
<Text className="store-detail-package-body">{bodyText}</Text>
{pkg.usableTime ? (
<Text className="store-detail-package-meta">使{pkg.usableTime}</Text>
) : null}
{pkg.otherNotes ? (
<Text className="store-detail-package-meta">{pkg.otherNotes}</Text>
) : null}
</View>
);
})}
</View>
) : null}
+6 -9
View File
@@ -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)}
</Text>
</View>
{/* 第2行:状态 + 营业时间(仅第一段) */}
{/* 第2行:状态 + 营业时间(含第二段) */}
<View className="store-card-row store-card-row--meta">
<Text className="store-card-status">{formatStatus(s)}</Text>
<Text className="store-card-hours" numberOfLines={1}>
{formatHours(s)}
</Text>
<Text className="store-card-hours">{formatHours(s)}</Text>
</View>
{/* 第3行:地址 + 去核销 */}
<View className="store-card-row store-card-row--foot">
+84 -40
View File
@@ -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;
}
+5 -6
View File
@@ -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行:地址(单行截断)+ 右对齐去核销 */
@@ -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', {
+4 -2
View File
@@ -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<SupportTicketStatus, SupportTicketStatus[]> =
DEVELOPING: ['TESTING', 'REJECTED'],
TESTING: ['PASSED', 'REJECTED'],
REJECTED: [],
PASSED: [],
PASSED: ['PUBLISHED'],
PUBLISHED: [],
};
/** 技术支持工单批量/人工改状态校验(禁止回退) */
+6 -1
View File
@@ -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<SupportTicketStatusDto, string> = {
@@ -31,6 +33,7 @@ export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string
DEVELOPING: '开发',
TESTING: '测试',
PASSED: '通过',
PUBLISHED: '已发布',
};
/** 技术支持工单优先级 */
@@ -64,6 +67,8 @@ export interface SupportTicketDto {
createdAt: string;
updatedAt: string;
completedAt?: string | null;
releasedVersionNo?: string | null;
publishedAt?: string | null;
}
export interface CreateSupportTicketRequest {
+2 -2
View File
@@ -1,4 +1,4 @@
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
export { isWechatBrowser, isMiniProgram, getRuntimePlatform, isIosDevice, isWechatDevTools } from './env';
export { getRouterBasename, toAppPath, isOnAppPath } from './app-path';
export {
initWechatJssdk,
@@ -10,7 +10,7 @@ export {
resetJssdkConfig,
stripOAuthParamsFromLocation,
} from './jssdk';
export { formatScanFailMessage } from './scan';
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
export {
getWechatLocation,
getWechatLocationDetailed,
+8 -9
View File
@@ -38,7 +38,7 @@ export function formatScanFailMessage(errMsg: string): string {
return msg;
}
function isScanPermissionWarmupError(msg: string): boolean {
export function isScanPermissionWarmupError(msg: string): boolean {
return /offline verifying|permission value is offline|权限验证中|接口未就绪|invalid signature|config:fail|signature/i.test(
msg,
);
@@ -134,15 +134,13 @@ export async function scanQrCode(
throw new Error('当前微信版本不支持扫码,请升级微信后重试');
}
// wx.ready ≠ 权限离线校验完成;授权回跳后更明显
const warmupMs = options.postAuthWarmup
? 1200
: isIosDevice() && !isWechatDevTools()
? 800
: 300;
const ios = isIosDevice() && !isWechatDevTools();
// wx.ready ≠ 权限离线校验完成;OAuth 回跳后 iOS 更明显
const warmupMs = options.postAuthWarmup ? (ios ? 1600 : 1000) : ios ? 800 : 300;
await delay(warmupMs);
const maxAttempts = options.postAuthWarmup ? 3 : 2;
const maxAttempts = options.postAuthWarmup ? (ios ? 4 : 3) : 2;
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (attempt > 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();
+4
View File
@@ -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[]
@@ -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;
@@ -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 } },
+1 -1
View File
@@ -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 头像 |
@@ -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 压缩后上传** |
| 后端 | 工单 priorityclient-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 图片自动压缩后可上传;仍超限有明确报错
- [ ] **门店 H5iPhone 微信)**:首次扫码 OAuth 后自动续扫;offline verifying 时提示再点一次扫码
- [ ] `pnpm lint` 无新增错误