merge(dev): v3.4.13 partner column, release cascade, client UX

This commit is contained in:
2026-08-06 01:02:29 +08:00
25 changed files with 658 additions and 421 deletions
+11 -4
View File
@@ -376,7 +376,7 @@ type StoreRow = {
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[]; visibilityPhones?: string[];
cityRef?: { name: string; code: string }; cityRef?: { name: string; code: string };
partner?: { companyName: string }; partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
account?: { account?: {
phone: string; phone: string;
name: string; name: string;
@@ -394,13 +394,14 @@ type StoreRow = {
category?: { id: string; name: string; parentId?: string | null } | null; 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 { function partnerOptionLabel(p: PartnerOption): string {
const company = (p.companyName || '').trim(); const company = (p.companyName || '').trim();
const person = (p.name || '').trim(); const person = (p.name || '').trim();
const phone = (p.phone || '').trim();
if (company && person) return `${company}-${person}`; if (company && person) return `${company}-${person}`;
return company || person || p.id; return company || person || phone || p.id;
} }
type CityOption = { type CityOption = {
id: string; 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: '可见', title: '可见',
dataIndex: 'visibilityWhitelistEnabled', dataIndex: 'visibilityWhitelistEnabled',
@@ -56,6 +56,7 @@ const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
DEVELOPING: 'blue', DEVELOPING: 'blue',
TESTING: 'purple', TESTING: 'purple',
PASSED: 'green', PASSED: 'green',
PUBLISHED: 'cyan',
}; };
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map( const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
@@ -482,8 +483,15 @@ export default function SupportTicketsPage() {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 100,
render: (s: SupportTicketStatusDto) => ( render: (s: SupportTicketStatusDto, row) => (
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag> <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 }, { title: '标题', dataIndex: 'title', ellipsis: true },
@@ -666,6 +674,11 @@ export default function SupportTicketsPage() {
{detail.status === 'PASSED' && ( {detail.status === 'PASSED' && (
<Typography.Text type="success"></Typography.Text> <Typography.Text type="success"></Typography.Text>
)} )}
{detail.status === 'PUBLISHED' && (
<Typography.Text type="success">
{detail.releasedVersionNo ? ` · ${detail.releasedVersionNo}` : ''}
</Typography.Text>
)}
</Space> </Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{fmtTime(detail.createdAt)} {fmtTime(detail.createdAt)}
@@ -689,6 +702,7 @@ export default function SupportTicketsPage() {
{ key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt }, { key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt },
{ key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null }, { key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null },
{ key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt }, { key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt },
{ key: 'PUBLISHED' as SupportTicketStatusDto, label: '已发布', time: detail.publishedAt },
] ]
: [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]), : [{ 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> { export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config'); return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
} }
+25 -5
View File
@@ -12,8 +12,10 @@ import {
} from '../lib/api'; } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; import { partnerHomePath } from '../lib/partnerAccess';
import type { PartnerPhoneCheckResponse } from '@dukang/shared-types';
import { import {
bindPartnerWechatAfterSmsLogin, bindPartnerWechatAfterSmsLogin,
checkPartnerPhoneForLogin,
fetchClientConfig, fetchClientConfig,
loginPartnerWithWechat, loginPartnerWithWechat,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
@@ -69,14 +71,17 @@ function formatPartnerError(e: unknown): string {
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) { if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
return '未找到合伙人账号'; return '未找到合伙人账号';
} }
if (text.includes('合伙人账号已停用')) { if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
return '合伙人账号已停用'; return '合伙人账号已暂停,无法登录';
} }
return text; return text;
} }
function formatWechatError(e: unknown): string { function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败'; const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
return '合伙人账号已暂停,无法登录';
}
if (text.includes('首次登录') || text.includes('手机验证码')) { if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信'; return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
} }
@@ -138,11 +143,21 @@ export default function LoginPage() {
async function sendCode() { async function sendCode() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
const trimmedPhone = phone.trim();
if (!trimmedPhone) {
setMsg('请输入手机号');
return;
}
if (!/^1\d{10}$/.test(trimmedPhone)) {
setMsg('请输入正确的手机号');
return;
}
setMsg(''); setMsg('');
try { try {
await checkPartnerPhoneForLogin(trimmedPhone);
await request('PARTNER_H5', '/partner/auth/sms/send', { await request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }), body: JSON.stringify({ phone: trimmedPhone, scene: 'PARTNER_LOGIN' }),
silent: true, silent: true,
}); });
setMsg('验证码已发送'); setMsg('验证码已发送');
@@ -171,16 +186,21 @@ export default function LoginPage() {
const trimmedPhone = phone.trim(); const trimmedPhone = phone.trim();
const trimmedCode = code.trim(); const trimmedCode = code.trim();
if (!trimmedPhone) { if (!trimmedPhone) {
setMsg('phone should not be empty'); setMsg('请输入手机号');
return;
}
if (!/^1\d{10}$/.test(trimmedPhone)) {
setMsg('请输入正确的手机号');
return; return;
} }
if (!trimmedCode) { if (!trimmedCode) {
setMsg('code should not be empty'); setMsg('请输入验证码');
return; return;
} }
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
await checkPartnerPhoneForLogin(trimmedPhone);
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', { const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }), body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
@@ -19,6 +19,7 @@ import {
import { import {
fetchClientConfig, fetchClientConfig,
handleShopWechatCallback, handleShopWechatCallback,
handleShopWechatCallbackOnce,
handleShopWechatLoginResult, handleShopWechatLoginResult,
isShopWechatUnboundError, isShopWechatUnboundError,
SHOP_WX_NEED_PHONE_LOGIN_MSG, SHOP_WX_NEED_PHONE_LOGIN_MSG,
@@ -79,7 +80,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
try { try {
const config = await fetchClientConfig(); const config = await fetchClientConfig();
if (isWxAuthorizeEnabled(config)) { if (isWxAuthorizeEnabled(config)) {
const result = await handleShopWechatCallback(); const result = await handleShopWechatCallbackOnce();
if (result && !cancelled) { if (result && !cancelled) {
const session = handleShopWechatLoginResult(result); const session = handleShopWechatLoginResult(result);
if (session) applySession(session); 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(); 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 = 已跳转授权页等待回调。 * 返回 session = 已登录;void = 已跳转授权页等待回调。
+293 -285
View File
@@ -1,285 +1,293 @@
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 { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import { import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
authorizeShopWechat,
checkNeedsWechatAuth, import { useStoreSession } from '../contexts/StoreSessionContext';
fetchShopAccount,
handleShopWechatCallback, import { request } from '../lib/api';
handleShopWechatLoginResult,
} from '../lib/wechat-auth'; import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; import {
import WechatScanAuthModal from '../components/WechatScanAuthModal';
import { useStorePageView } from '../lib/usePageView'; authorizeShopWechat,
import { trackStore } from '../lib/analytics';
checkNeedsWechatAuth,
const PENDING_SCAN_KEY = 'shop_pending_scan';
fetchShopAccount,
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } from '../lib/wechat-auth';
}
import { isWechatEnv, weixinSdk } from '../lib/weixin';
function formatScanError(e: unknown): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试'; import {
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; clearPendingScanAfterAuth,
}
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) { getPostAuthScanDelayMs,
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
} markPendingScanAfterAuth,
return msg;
} peekPendingScanAfterAuth,
export default function HomePage() { } from '../lib/shop-scan-auth';
useStorePageView('store_home_view');
const navigate = useNavigate(); import WechatScanAuthModal from '../components/WechatScanAuthModal';
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams(); import { useStorePageView } from '../lib/usePageView';
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [scanMsg, setScanMsg] = useState(''); import { trackStore } from '../lib/analytics';
const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState(''); function formatMoney(n: number) {
const loadDashboard = useCallback(() => { return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => { }
setDash(d);
})
.catch(() => {});
}, []); function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
useEffect(() => { const msg = e instanceof Error ? e.message : '扫码失败,请重试';
void loadDashboard();
}, [loadDashboard]); if (/invalid signature/i.test(msg)) {
useEffect(() => { return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
function onResume() {
setScanning(false); }
void loadDashboard();
} if (isScanPermissionWarmupError(msg)) {
function onVisibility() {
if (document.visibilityState === 'visible') onResume(); if (opts?.afterAuth) {
}
document.addEventListener('visibilitychange', onVisibility); return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
window.addEventListener('pageshow', onResume);
window.addEventListener('focus', onResume); }
return () => {
document.removeEventListener('visibilitychange', onVisibility); return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
window.removeEventListener('pageshow', onResume);
window.removeEventListener('focus', onResume); }
};
}, [loadDashboard]); return msg;
async function runScan(opts?: { postAuthWarmup?: boolean }) { }
trackStore('store_redeem_scan_start');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return; export default function HomePage() {
}
setScanning(true); useStorePageView('store_home_view');
setScanMsg('');
try { const navigate = useNavigate();
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
if (opts?.postAuthWarmup) { const { ready, authenticated } = useStoreSession();
weixinSdk.reset();
} const [searchParams] = useSearchParams();
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode( const [dash, setDash] = useState<Record<string, unknown> | null>(null);
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
); const [scanMsg, setScanMsg] = useState('');
if (!raw) {
void loadDashboard(); const [scanning, setScanning] = useState(false);
return;
} const [authModalOpen, setAuthModalOpen] = useState(false);
const token = parseRedeemTokenFromScan(raw);
if (!token) { const [authLoading, setAuthLoading] = useState(false);
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return; const [authError, setAuthError] = useState('');
}
navigate(`/redeem?token=${encodeURIComponent(token)}`); const pendingScanStartedRef = useRef(false);
} catch (e) {
setScanMsg(formatScanError(e));
} finally {
setScanning(false); const loadDashboard = useCallback(() => {
}
} return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
useEffect(() => { .then((d) => {
if (!isWechatEnv() || !searchParams.get('code')) return;
void handleShopWechatCallback() setDash(d);
.then((result) => {
if (!result) return; })
const session = handleShopWechatLoginResult(result);
if (session) { .catch(() => {});
applySession(session);
} }, []);
setAuthModalOpen(false);
setAuthError('');
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
stripOAuthParamsFromLocation(); useEffect(() => {
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1'; void loadDashboard();
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) { }, [loadDashboard]);
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400);
}
}) useEffect(() => {
.catch((e) => {
setAuthError(e instanceof Error ? e.message : '微信授权失败'); function onResume() {
});
}, [searchParams, applySession, setSearchParams]); setScanning(false);
async function handleScan() { void loadDashboard();
setScanMsg('');
if (!isWechatEnv()) { }
setScanMsg('请在微信内打开门店端进行扫码核销');
return; function onVisibility() {
}
try { if (document.visibilityState === 'visible') onResume();
const profile = await fetchShopAccount();
if (await checkNeedsWechatAuth(profile)) { }
setAuthModalOpen(true);
return; document.addEventListener('visibilitychange', onVisibility);
}
await runScan(); window.addEventListener('pageshow', onResume);
} catch (e) {
setScanMsg(e instanceof Error ? e.message : '无法发起扫码'); window.addEventListener('focus', onResume);
}
} return () => {
async function startWechatAuth() { document.removeEventListener('visibilitychange', onVisibility);
setAuthLoading(true);
setAuthError(''); window.removeEventListener('pageshow', onResume);
try {
sessionStorage.setItem(PENDING_SCAN_KEY, '1'); window.removeEventListener('focus', onResume);
await authorizeShopWechat();
} catch (e) { };
sessionStorage.removeItem(PENDING_SCAN_KEY);
setAuthError(e instanceof Error ? e.message : '微信授权失败'); }, [loadDashboard]);
setAuthLoading(false);
}
}
const runScan = useCallback(
const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || []; async (opts?: { postAuthWarmup?: boolean }) => {
const status = String(store?.status || '');
const open = status === 'OPEN'; trackStore('store_redeem_scan_start');
const hoursParts: string[] = [];
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`); if (!isWechatEnv()) {
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
const hoursText = hoursParts.length ? hoursParts.join('') : '10:00 - 22:00'; setScanMsg('请在微信内打开门店端进行扫码核销');
const statusText =
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店'; return;
return ( }
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
<header className="shop-home-header"> setScanning(true);
<h1 className="app-page-title"></h1>
</header> if (!opts?.postAuthWarmup) {
<div className="shop-home-content"> setScanMsg('');
<section className="shop-home-hero">
<div className="shop-home-hero-store"> }
<span className="material-symbols-outlined shop-fill-icon">store</span>
<h2>{String(store?.name || '门店')}</h2> try {
</div>
<div className="shop-home-stats"> if (opts?.postAuthWarmup) {
<div className="shop-home-stat">
<p className="shop-home-stat-label"></p> weixinSdk.reset();
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
<p className="shop-home-stat-sub"> }
{Number(dash?.todayScanCount || 0)} · {Number(dash?.todayPhoneCount || 0)}
</p> await weixinSdk.init();
</div>
<div className="shop-home-stat"> const raw = await weixinSdk.scanQrCode(
<p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value"> opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
<span style={{ fontSize: 18 }}>¥</span>
{formatMoney(Number(dash?.todayAmount || 0))} );
</p>
</div> if (!raw) {
</div>
</section> void loadDashboard();
<section className="shop-home-scan"> return;
<button
type="button" }
className="shop-home-scan-btn"
disabled={scanning} const token = parseRedeemTokenFromScan(raw);
onClick={() => void handleScan()}
> if (!token) {
<span className="material-symbols-outlined">qr_code_scanner</span>
</button> setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>} return;
<Link to="/redeem/phone" className="shop-home-phone-link">
<span className="material-symbols-outlined">smartphone</span> }
</Link> navigate(`/redeem?token=${encodeURIComponent(token)}`);
</section>
} catch (e) {
<section className="shop-home-status">
<div className="shop-home-status-left"> setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup }));
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
<span className="material-symbols-outlined shop-fill-icon">schedule</span> } finally {
</div>
<div> setScanning(false);
<p className="shop-home-status-title"></p>
<p className="shop-home-status-sub">{statusText}</p> }
<p className="shop-home-status-sub">: {hoursText}</p>
</div> },
</div>
<label className="shop-home-switch" onClick={() => navigate('/status')}> [loadDashboard, navigate],
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
<span className="shop-home-switch-track" /> );
</label>
</section>
<section> // OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
<div className="shop-home-records-head">
<h3 className="shop-home-records-title"></h3> useEffect(() => {
<Link to="/records" className="shop-home-records-link">
if (!ready || !authenticated || !isWechatEnv()) return;
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
</Link> if (searchParams.get('code')) return;
</div>
<div className="shop-home-record-list"> if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
{recent.length === 0 && (
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}></p>
)}
{recent.map((r) => ( pendingScanStartedRef.current = true;
<div key={String(r.id)} className="shop-home-record-item">
<div> clearPendingScanAfterAuth();
<p className="shop-home-record-time"></p>
<p className="shop-home-record-value"> setAuthModalOpen(false);
{new Date(String(r.createdAt)).toLocaleString('zh-CN')}
</p> setAuthLoading(false);
</div>
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p> setAuthError('');
</div>
))} setScanMsg('微信授权成功,正在准备扫码…');
</div>
</section>
</div>
const timer = window.setTimeout(() => {
<WechatScanAuthModal
open={authModalOpen} void runScan({ postAuthWarmup: true });
loading={authLoading}
error={authError} }, getPostAuthScanDelayMs());
onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false);
setAuthError(''); return () => window.clearTimeout(timer);
sessionStorage.removeItem(PENDING_SCAN_KEY);
}} }, [ready, authenticated, searchParams, runScan]);
/>
</PullToRefresh>
);
} async function handleScan() {
setScanMsg('');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
+2 -2
View File
@@ -18,7 +18,7 @@ import {
consumeShopWechatLoginHint, consumeShopWechatLoginHint,
fetchClientConfig, fetchClientConfig,
formatShopWechatError, formatShopWechatError,
handleShopWechatCallback, handleShopWechatCallbackOnce,
handleShopWechatLoginResult, handleShopWechatLoginResult,
loginShopWithWechat, loginShopWithWechat,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
@@ -88,7 +88,7 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return; if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
void handleShopWechatCallback() void handleShopWechatCallbackOnce()
.then((result) => { .then((result) => {
if (!result) return; if (!result) return;
const session = handleShopWechatLoginResult(result); const session = handleShopWechatLoginResult(result);
+2 -2
View File
@@ -9,7 +9,7 @@ import {
authorizeShopWechat, authorizeShopWechat,
fetchClientConfig, fetchClientConfig,
fetchShopAccount, fetchShopAccount,
handleShopWechatCallback, handleShopWechatCallbackOnce,
handleShopWechatLoginResult, handleShopWechatLoginResult,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin'; import { isWechatEnv } from '../lib/weixin';
@@ -45,7 +45,7 @@ export default function MinePage() {
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return; if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
void handleShopWechatCallback() void handleShopWechatCallbackOnce()
.then((result) => { .then((result) => {
if (!result) return; if (!result) return;
const session = handleShopWechatLoginResult(result); const session = handleShopWechatLoginResult(result);
@@ -20,7 +20,7 @@ export default function ProductCarousel({
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const prefix = const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel'; variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
const imageMode = variant === 'store' ? 'aspectFit' : 'aspectFill'; const imageMode = 'aspectFill';
function previewAt(index: number) { function previewAt(index: number) {
const urls = slides.filter(Boolean); const urls = slides.filter(Boolean);
+45 -34
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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, { import Taro, {
useDidShow, useDidShow,
useLoad, useLoad,
@@ -156,10 +156,14 @@ export default function StoreDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false); const [headerSolid, setHeaderSolid] = useState(false);
const [expandedPackages, setExpandedPackages] = useState<Record<number, boolean>>({}); const [activePackageIndex, setActivePackageIndex] = useState(0);
const storeRef = useRef<Store | null>(null); const storeRef = useRef<Store | null>(null);
storeRef.current = store; storeRef.current = store;
useEffect(() => {
setActivePackageIndex(0);
}, [store?.id]);
usePageScroll(({ scrollTop }) => { usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100); setHeaderSolid(scrollTop > 100);
}); });
@@ -321,6 +325,8 @@ export default function StoreDetailPage() {
const envPhotos = envPhotoUrls(store); const envPhotos = envPhotoUrls(store);
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]); const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
const packages = store.packages ?? [];
const activePackage = packages[activePackageIndex] ?? packages[0];
const intro = store.intro?.trim() || ''; const intro = store.intro?.trim() || '';
const benefitRuleRaw = store.benefitUsageRule?.trim() || ''; const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
@@ -402,44 +408,49 @@ export default function StoreDetailPage() {
</View> </View>
) : null} ) : null}
{store.packages && store.packages.length > 0 ? ( {packages.length > 0 && activePackage ? (
<View className="store-detail-section"> <View className="store-detail-section store-detail-section--packages">
<Text className="store-detail-section-title"></Text> <Text className="store-detail-section-title"></Text>
{store.packages.map((pkg, index) => { {packages.length > 1 ? (
const expanded = !!expandedPackages[index]; <ScrollView className="store-detail-package-tabs" scrollX showScrollbar={false} enhanced>
const bodyText = `${formatRedeemAmountYuan(pkg.price)} 元 · ${pkg.dishes}`; <View className="store-detail-package-tabs-inner">
const longBody = bodyText.length > 48 || (pkg.otherNotes?.length ?? 0) > 40; {packages.map((pkg, index) => (
return ( <View
<View key={`${pkg.name}-${index}`}
key={`${pkg.name}-${index}`} className={`store-detail-package-tab${
className={`store-detail-package-card${expanded || !longBody ? '' : ' store-detail-package-card--collapsed'}`} index === activePackageIndex ? ' store-detail-package-tab--active' : ''
> }`}
{pkg.imageUrl ? ( onClick={() => setActivePackageIndex(index)}
<Image className="store-detail-package-img" src={pkg.imageUrl} mode="aspectFill" />
) : 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 ? '收起' : '展开'} <Text className="store-detail-package-tab-text">{pkg.name}</Text>
</Text> </View>
) : null} ))}
</View> </View>
<Text className="store-detail-package-body">{bodyText}</Text> </ScrollView>
{pkg.usableTime ? ( ) : null}
<Text className="store-detail-package-meta">使{pkg.usableTime}</Text> <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} ) : null}
{pkg.otherNotes ? ( <Text className="store-detail-package-body">
<Text className="store-detail-package-meta">{pkg.otherNotes}</Text> {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} ) : null}
</View> </View>
); </View>
})}
</View> </View>
) : null} ) : null}
+6 -9
View File
@@ -343,11 +343,10 @@ export default function StoresPage() {
} }
function formatHours(store: Store) { function formatHours(store: Store) {
// 列表只展示第一段营业时间,避免挤占一行 const parts: string[] = [];
if (store.openTime && store.closeTime) { if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
return `营业时间: ${store.openTime}-${store.closeTime}`; if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
} return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00';
return '营业时间: 10:00-22:00';
} }
function formatStatus(store: Store) { function formatStatus(store: Store) {
@@ -447,12 +446,10 @@ export default function StoresPage() {
{formatDistanceMeters(s.distanceMeters)} {formatDistanceMeters(s.distanceMeters)}
</Text> </Text>
</View> </View>
{/* 第2行:状态 + 营业时间(仅第一段) */} {/* 第2行:状态 + 营业时间(含第二段) */}
<View className="store-card-row store-card-row--meta"> <View className="store-card-row store-card-row--meta">
<Text className="store-card-status">{formatStatus(s)}</Text> <Text className="store-card-status">{formatStatus(s)}</Text>
<Text className="store-card-hours" numberOfLines={1}> <Text className="store-card-hours">{formatHours(s)}</Text>
{formatHours(s)}
</Text>
</View> </View>
{/* 第3行:地址 + 去核销 */} {/* 第3行:地址 + 去核销 */}
<View className="store-card-row store-card-row--foot"> <View className="store-card-row store-card-row--foot">
+84 -40
View File
@@ -10,15 +10,14 @@
.store-detail-carousel-wrap { .store-detail-carousel-wrap {
position: relative; position: relative;
width: 100%; width: 100%;
min-height: 200px; aspect-ratio: 4 / 3;
max-height: 360px;
overflow: hidden; overflow: hidden;
background: var(--color-surface-container); background: var(--color-surface-container);
} }
.store-detail-carousel { .store-detail-carousel {
width: 100%; width: 100%;
height: 280px; height: 100%;
} }
.store-detail-carousel-item, .store-detail-carousel-item,
@@ -28,6 +27,11 @@
height: 100%; height: 100%;
} }
.store-detail-carousel-image {
object-fit: cover;
display: block;
}
.store-detail-carousel-placeholder { .store-detail-carousel-placeholder {
background: var(--color-surface-container); background: var(--color-surface-container);
} }
@@ -238,62 +242,102 @@
margin-right: 0; margin-right: 0;
} }
.store-detail-package-card { .store-detail-section--packages {
padding: 12px 0; overflow: hidden;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
} }
.store-detail-package-card--collapsed .store-detail-package-body, .store-detail-package-tabs {
.store-detail-package-card--collapsed .store-detail-package-meta { 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; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; 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 { .store-detail-package-name {
display: block; display: block;
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: var(--color-text-primary, #1a1a1a); color: var(--color-text-primary, #1a1a1a);
margin-bottom: 6px; margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.store-detail-package-body { .store-detail-package-body {
display: block; font-size: 13px;
font-size: 14px;
color: var(--color-text-secondary, #666); color: var(--color-text-secondary, #666);
line-height: 1.5; line-height: 1.45;
} }
.store-detail-package-meta { .store-detail-package-meta {
display: block; font-size: 12px;
font-size: 13px;
color: var(--color-text-tertiary, #999); color: var(--color-text-tertiary, #999);
margin-top: 4px; margin-top: 4px;
} }
+5 -6
View File
@@ -218,15 +218,17 @@
white-space: nowrap; white-space: nowrap;
} }
/* 第2行:营业状态 + 营业时间(单行 */ /* 第2行:营业状态 + 营业时间(可含两段 */
.store-card-row--meta { .store-card-row--meta {
gap: 6px; gap: 6px;
height: 20px; min-height: 20px;
align-items: flex-start;
} }
.store-card-status { .store-card-status {
flex-shrink: 0; flex-shrink: 0;
padding: 0 6px; padding: 0 6px;
margin-top: 1px;
border-radius: 4px; border-radius: 4px;
background: rgba(45, 106, 79, 0.12); background: rgba(45, 106, 79, 0.12);
color: #2d6a4f; color: #2d6a4f;
@@ -241,11 +243,8 @@
min-width: 0; min-width: 0;
font-size: 11px; font-size: 11px;
font-weight: 400; font-weight: 400;
line-height: 20px; line-height: 16px;
color: #999; color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* 第3行:地址(单行截断)+ 右对齐去核销 */ /* 第3行:地址(单行截断)+ 右对齐去核销 */
@@ -29,6 +29,15 @@ describe('validateSupportTicketStatusTransition', () => {
).toBe(false); ).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', () => { it('requires super admin and reason for REJECTED', () => {
expect( expect(
validateSupportTicketStatusTransition('DEVELOPING', 'REJECTED', { validateSupportTicketStatusTransition('DEVELOPING', 'REJECTED', {
+4 -2
View File
@@ -3,7 +3,8 @@ export type SupportTicketStatus =
| 'REJECTED' | 'REJECTED'
| 'DEVELOPING' | 'DEVELOPING'
| 'TESTING' | 'TESTING'
| 'PASSED'; | 'PASSED'
| 'PUBLISHED';
export type SupportTicketStatusTransitionContext = { export type SupportTicketStatusTransitionContext = {
linkedTaskCount: number; linkedTaskCount: number;
@@ -16,7 +17,8 @@ const FORWARD_TRANSITIONS: Record<SupportTicketStatus, SupportTicketStatus[]> =
DEVELOPING: ['TESTING', 'REJECTED'], DEVELOPING: ['TESTING', 'REJECTED'],
TESTING: ['PASSED', 'REJECTED'], TESTING: ['PASSED', 'REJECTED'],
REJECTED: [], REJECTED: [],
PASSED: [], PASSED: ['PUBLISHED'],
PUBLISHED: [],
}; };
/** 技术支持工单批量/人工改状态校验(禁止回退) */ /** 技术支持工单批量/人工改状态校验(禁止回退) */
+6 -1
View File
@@ -15,7 +15,8 @@ export type SupportTicketStatusDto =
| 'REJECTED' | 'REJECTED'
| 'DEVELOPING' | 'DEVELOPING'
| 'TESTING' | 'TESTING'
| 'PASSED'; | 'PASSED'
| 'PUBLISHED';
export const SUPPORT_TICKET_STATUSES = [ export const SUPPORT_TICKET_STATUSES = [
'PENDING_REVIEW', 'PENDING_REVIEW',
@@ -23,6 +24,7 @@ export const SUPPORT_TICKET_STATUSES = [
'DEVELOPING', 'DEVELOPING',
'TESTING', 'TESTING',
'PASSED', 'PASSED',
'PUBLISHED',
] as const; ] as const;
export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string> = { export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string> = {
@@ -31,6 +33,7 @@ export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string
DEVELOPING: '开发', DEVELOPING: '开发',
TESTING: '测试', TESTING: '测试',
PASSED: '通过', PASSED: '通过',
PUBLISHED: '已发布',
}; };
/** 技术支持工单优先级 */ /** 技术支持工单优先级 */
@@ -64,6 +67,8 @@ export interface SupportTicketDto {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
completedAt?: string | null; completedAt?: string | null;
releasedVersionNo?: string | null;
publishedAt?: string | null;
} }
export interface CreateSupportTicketRequest { 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 { getRouterBasename, toAppPath, isOnAppPath } from './app-path';
export { export {
initWechatJssdk, initWechatJssdk,
@@ -10,7 +10,7 @@ export {
resetJssdkConfig, resetJssdkConfig,
stripOAuthParamsFromLocation, stripOAuthParamsFromLocation,
} from './jssdk'; } from './jssdk';
export { formatScanFailMessage } from './scan'; export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
export { export {
getWechatLocation, getWechatLocation,
getWechatLocationDetailed, getWechatLocationDetailed,
+8 -9
View File
@@ -38,7 +38,7 @@ export function formatScanFailMessage(errMsg: string): string {
return msg; 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( return /offline verifying|permission value is offline|权限验证中|接口未就绪|invalid signature|config:fail|signature/i.test(
msg, msg,
); );
@@ -134,15 +134,13 @@ export async function scanQrCode(
throw new Error('当前微信版本不支持扫码,请升级微信后重试'); throw new Error('当前微信版本不支持扫码,请升级微信后重试');
} }
// wx.ready ≠ 权限离线校验完成;授权回跳后更明显 const ios = isIosDevice() && !isWechatDevTools();
const warmupMs = options.postAuthWarmup
? 1200 // wx.ready ≠ 权限离线校验完成;OAuth 回跳后 iOS 更明显
: isIosDevice() && !isWechatDevTools() const warmupMs = options.postAuthWarmup ? (ios ? 1600 : 1000) : ios ? 800 : 300;
? 800
: 300;
await delay(warmupMs); await delay(warmupMs);
const maxAttempts = options.postAuthWarmup ? 3 : 2; const maxAttempts = options.postAuthWarmup ? (ios ? 4 : 3) : 2;
let lastError: Error | null = null; let lastError: Error | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (attempt > 0) { if (attempt > 0) {
@@ -151,7 +149,8 @@ export async function scanQrCode(
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() }); await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
await checkJsApi('scanQRCode'); await checkJsApi('scanQRCode');
} }
await delay(700 + attempt * 500); const retryDelay = options.postAuthWarmup && ios ? 900 + attempt * 700 : 700 + attempt * 500;
await delay(retryDelay);
} }
try { try {
return await invokeScanQrCode(); return await invokeScanQrCode();
+4
View File
@@ -107,6 +107,7 @@ enum SupportTicketStatus {
DEVELOPING DEVELOPING
TESTING TESTING
PASSED PASSED
PUBLISHED
} }
enum SupportTicketPriority { enum SupportTicketPriority {
@@ -671,6 +672,8 @@ model CommonSupportTicket {
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3) reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
remark String? @db.VarChar(512) remark String? @db.VarChar(512)
attachmentUrls Json? @map("attachment_urls") 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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3) completedAt DateTime? @map("completed_at") @db.DateTime(3)
@@ -678,6 +681,7 @@ model CommonSupportTicket {
@@index([status, createdAt]) @@index([status, createdAt])
@@index([ticketType, status]) @@index([ticketType, status])
@@index([creatorId]) @@index([creatorId])
@@index([releasedVersionNo])
@@map("common_support_ticket") @@map("common_support_ticket")
devPlanTasks DevPlanTask[] devPlanTasks DevPlanTask[]
@@ -671,6 +671,10 @@ export class DevPlanService {
} }
if (status === 'RELEASED') {
await this.cascadeVersionReleased(row.id, row.versionNo);
}
return this.getVersion(row.id); return this.getVersion(row.id);
} }
@@ -739,6 +743,14 @@ export class DevPlanService {
await this.prisma.devPlanVersion.update({ where: { id }, data }); 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); if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds);
return this.getVersion(id); 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[]) { private async addVersionTasks(versionId: bigint, taskIds: string[]) {
if (!taskIds.length) return; if (!taskIds.length) return;
@@ -74,7 +74,7 @@ export class AdminStoresService {
take: pageSize, take: pageSize,
include: { include: {
cityRef: { select: { id: true, name: true, code: true } }, 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 } }, category: { select: { id: true, name: true, parentId: true } },
bindings: { bindings: {
where: { storeAccount: { isPrimary: 1 } }, where: { storeAccount: { isPrimary: 1 } },
+1 -1
View File
@@ -339,7 +339,7 @@ C2~C7、C14 见 §1.3。
| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI | | 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI |
| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat | | 合伙人微信暂停禁登 | ✅ | loginPartnerWechat |
| H5 登录前端校验 | ✅ | partner/shop LoginPage | | H5 登录前端校验 | ✅ | partner/shop LoginPage |
| mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 | | mini-user 门店体验 | ✅ | 电话脱敏/埋点、**列表两段营业时间**、门头 aspectFill 铺满、**套餐页签切换** |
| mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 | | mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 |
| mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** | | mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** |
| OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 | | OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 |
@@ -7,10 +7,10 @@
| 模块 | 内容 | | 模块 | 内容 |
|------|------| |------|------|
| admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势**;**权益券详情加宽**;**核销记录详情增强(对齐权益券内核销详情)**;技术支持工单优先级;**大图上传前压缩** | | admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势**;**权益券详情加宽**;**核销记录详情增强(对齐权益券内核销详情)**;技术支持工单优先级;**版本发布联动任务/工单已发布****大图上传前压缩** |
| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;**我的页版本号 v3.4.x****低于 minClientVersion 强制更新/点我知道了退出**;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** | | mini-user | 门店电话脱敏+拨打埋点;**门店列表展示两段营业时间**;**门店详情门头 aspectFill 铺满无留白****门店套餐页签切换**;商品去分享+首图 preview;提货确认弹框;**我的页版本号 v3.4.x****低于 minClientVersion 强制更新/点我知道了退出**;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** | | h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**门店端 iOS 首次扫码 OAuth 单例 + JSSDK 预热重试****OSS 图片超 10MB 自动 canvas 压缩后上传** |
| 后端 | 工单 priorityclient-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器****log_promo_event 推广码指标日志** | | 后端 | 工单 priority**工单 PUBLISHED + releasedVersionNo****版本 RELEASED 联动任务/工单**client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器****log_promo_event 推广码指标日志** |
## 2. ST 映射 ## 2. ST 映射
@@ -19,21 +19,21 @@
| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** | | ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** |
| ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call | | ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call |
| ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI | | ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI |
| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ 已有(v3.4.12 前) | | ST1785921585900725 | 门店端扫一扫授权异常 | ✅ **OAuth 单例回调 + iOS JSSDK 预热/重试 + 授权后续扫** |
| ST1785907536648201 | 版本不对提示更新 | ✅ 我的页 v3.4.x + UpdateManager + **低于 min 强制更新/退出** | | ST1785907536648201 | 版本不对提示更新 | ✅ 我的页 v3.4.x + UpdateManager + **低于 min 强制更新/退出** |
| ST1785906800359657 | 合伙人暂停后禁登 | ✅ loginPartnerWechat status | | ST1785906800359657 | 合伙人暂停后禁登 | ✅ **发码前 phone/check + 微信/SMS 登录均拒 DISABLED** |
| ST1785906592340255 | PARTNER_H5 login 校验 | ✅ 前端空字段拦截 | | ST1785906592340255 | PARTNER_H5 login 校验 | ✅ 前端空字段拦截 |
| ST1785906390321653 | 现场提货提交确认弹框 | ✅ showModal | | ST1785906390321653 | 现场提货提交确认弹框 | ✅ showModal |
| ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情;**详情含门店/合伙人/券分摊/结算/评价** | | ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情;**详情含门店/合伙人/券分摊/结算/评价** |
| ST1785904849234806 | 工单中心 | ✅ 已有 | | ST1785904849234806 | 工单中心 | ✅ 已有 |
| ST1785904076632841 | 门店列表开城合伙人 | ✅ 已有 | | ST1785904076632841 | 门店列表开城合伙人 | ✅ **列表展示 companyName/name/phone(同 partnerOptionLabel** |
| ST1785902173093977 | 去掉商品详情分享按钮 | ✅ 移除 ShareNavButton | | ST1785902173093977 | 去掉商品详情分享按钮 | ✅ 移除 ShareNavButton |
| ST1785902141731113 | 商品首图大图 | ✅ previewable | | ST1785902141731113 | 商品首图大图 | ✅ previewable |
| ST1785901870913349 | 门店列表营业时间 | ✅ 已有 | | ST1785901870913349 | 门店列表营业时间 | ✅ **列表展示两段营业时间** |
| ST1785901838948572 | SHOP 未绑定门店 | ✅ 已有 | | ST1785901838948572 | SHOP 未绑定门店 | ✅ 已有 |
| ST1785901775231811 | 门店套餐遮挡 | ✅ 折叠/行数限制 | | ST1785901775231811 | 门店套餐遮挡 | ✅ **页签切换(可横滑)+ 紧凑内容区** |
| ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 | | ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 |
| ST1785901314893145 | 门头照裁剪 | ✅ aspectFit + preview | | ST1785901314893145 | 门头照裁剪 | ✅ aspectFill 铺满无留白 + preview |
| ST1785939375449985 | 订单物流追踪页 | ✅ 签收照 + 拨号 + 时间线 + ETA + 路由回调 | | ST1785939375449985 | 订单物流追踪页 | ✅ 签收照 + 拨号 + 时间线 + ETA + 路由回调 |
| — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload | | — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload |
@@ -43,6 +43,8 @@
|------|-------------|------| |------|-------------|------|
| GET | `/common/client-config` | `minClientVersion``MINI_USER_MIN_VERSION`);客户端 semver 比对,低于则强制更新或退出 | | GET | `/common/client-config` | `minClientVersion``MINI_USER_MIN_VERSION`);客户端 semver 比对,低于则强制更新或退出 |
| — | `CommonSupportTicket.priority` | `LOW \| NORMAL \| HIGH \| URGENT`,默认 NORMAL | | — | `CommonSupportTicket.priority` | `LOW \| NORMAL \| HIGH \| URGENT`,默认 NORMAL |
| — | `CommonSupportTicket.status` | 新增 `PUBLISHED`(已发布);`releasedVersionNo` + `publishedAt` |
| — | `DevPlanVersion``RELEASED` | 关联任务 → `RELEASED`;关联工单 → `PUBLISHED` 并写入版本号 |
| — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 | | — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 |
| GET | `/trade/orders/:id/track` | 聚合:`nodes`(旧→新)、`signPhotoUrls``estimatedArrival`;经 `CourierService` 适配小飞侠 cmd 100102/100108/100301 | | GET | `/trade/orders/:id/track` | 聚合:`nodes`(旧→新)、`signPhotoUrls``estimatedArrival`;经 `CourierService` 适配小飞侠 cmd 100102/100108/100301 |
| POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) | | POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) |
@@ -135,11 +137,12 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
## 5. 数据表 ## 5. 数据表
- `common_support_ticket.priority` ENUM,默认 `NORMAL` - `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` - `log_promo_event`:推广码指标事件(`promo_code_id`, `event_type`, `user_id`, `order_id`, `session_id`, `client_ip`, 地点, `created_at`
## 6. HQ 开发计划 ## 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 ## 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 - [ ] 推广码 SCAN/ATTRIBUTION/REGISTER/ORDER 事件含 time + IP;幂等(重复 touch 不计 SCAN
- [ ] 核销记录含 userNo/nickname/phone**详情抽屉展示门店/地址/合伙人/权益券号/关联订单/券分摊/结算单/评价** - [ ] 核销记录含 userNo/nickname/phone**详情抽屉展示门店/地址/合伙人/权益券号/关联订单/券分摊/结算单/评价**
- [ ] **权益券详情抽屉加宽(980px),内嵌核销列表无横向滚动条** - [ ] **权益券详情抽屉加宽(980px),内嵌核销列表无横向滚动条**
- [ ] 技术支持可创建/筛选/编辑优先级 - [ ] 技术支持可创建/筛选/编辑优先级**版本 RELEASED 后关联工单为「已发布」并带版本号**
- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒 - [ ] **门店列表「开城合伙人」列展示 companyName / name / phone(与下拉选项一致)**
- [ ] 合伙人/门店登录空字段前端提示;**暂停合伙人发码前即拦截,短信/微信登录均禁止**
- [ ] mini-user:我的页左下角显示 `杜康好客 v3.4.x`;低于 `minClientVersion` 时强制更新,无新包则点「我知道了」退出小程序 - [ ] mini-user:我的页左下角显示 `杜康好客 v3.4.x`;低于 `minClientVersion` 时强制更新,无新包则点「我知道了」退出小程序
- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常 - [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、**门店列表两段营业时间**、**门店详情门头铺满无留白**、**门店套餐页签切换**
- [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达 - [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达
- [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等 - [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等
- [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错 - [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错
- [ ] **门店 H5iPhone 微信)**:首次扫码 OAuth 后自动续扫;offline verifying 时提示再点一次扫码
- [ ] `pnpm lint` 无新增错误 - [ ] `pnpm lint` 无新增错误