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:
@@ -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) => (
|
||||||
|
<Space size={4} direction="vertical" style={{ lineHeight: 1.2 }}>
|
||||||
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
<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 }]),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 = 已跳转授权页等待回调。
|
||||||
|
|||||||
@@ -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 { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
|
||||||
import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
||||||
@@ -8,27 +9,31 @@ import {
|
|||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
handleShopWechatCallback,
|
|
||||||
handleShopWechatLoginResult,
|
|
||||||
|
|
||||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
|
||||||
|
import {
|
||||||
|
|
||||||
|
authorizeShopWechat,
|
||||||
|
|
||||||
|
checkNeedsWechatAuth,
|
||||||
|
|
||||||
fetchShopAccount,
|
fetchShopAccount,
|
||||||
|
|
||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
|
||||||
|
|
||||||
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
function formatScanError(e: unknown): string {
|
|
||||||
clearPendingScanAfterAuth,
|
clearPendingScanAfterAuth,
|
||||||
|
|
||||||
getPostAuthScanDelayMs,
|
getPostAuthScanDelayMs,
|
||||||
|
|
||||||
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
markPendingScanAfterAuth,
|
||||||
|
|
||||||
|
peekPendingScanAfterAuth,
|
||||||
|
|
||||||
} from '../lib/shop-scan-auth';
|
} from '../lib/shop-scan-auth';
|
||||||
|
|
||||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||||
@@ -37,14 +42,15 @@ function formatScanError(e: unknown): string {
|
|||||||
|
|
||||||
import { trackStore } from '../lib/analytics';
|
import { trackStore } from '../lib/analytics';
|
||||||
|
|
||||||
const { applySession } = useStoreSession();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
function formatMoney(n: number) {
|
function formatMoney(n: number) {
|
||||||
|
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||||
|
|
||||||
@@ -76,16 +82,18 @@ export default function HomePage() {
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
|
||||||
async function runScan(opts?: { postAuthWarmup?: boolean }) {
|
useStorePageView('store_home_view');
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { ready, authenticated } = useStoreSession();
|
const { ready, authenticated } = useStoreSession();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
const [scanMsg, setScanMsg] = useState('');
|
||||||
|
|
||||||
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
|
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
@@ -104,37 +112,33 @@ export default function HomePage() {
|
|||||||
|
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
|
|
||||||
setScanMsg(formatScanError(e));
|
setDash(d);
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
.catch(() => {});
|
||||||
|
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!isWechatEnv() || !searchParams.get('code')) return;
|
useEffect(() => {
|
||||||
void handleShopWechatCallback()
|
|
||||||
.then((result) => {
|
void loadDashboard();
|
||||||
if (!result) return;
|
|
||||||
const session = handleShopWechatLoginResult(result);
|
}, [loadDashboard]);
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
|
|
||||||
stripOAuthParamsFromLocation();
|
function onResume() {
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
setScanning(false);
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
if (shouldScan) {
|
void loadDashboard();
|
||||||
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
|
|
||||||
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400);
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
|
||||||
});
|
|
||||||
}, [searchParams, applySession, setSearchParams]);
|
|
||||||
|
|
||||||
function onVisibility() {
|
function onVisibility() {
|
||||||
|
|
||||||
@@ -145,6 +149,7 @@ export default function HomePage() {
|
|||||||
document.addEventListener('visibilitychange', onVisibility);
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
|
||||||
window.addEventListener('pageshow', onResume);
|
window.addEventListener('pageshow', onResume);
|
||||||
|
|
||||||
window.addEventListener('focus', onResume);
|
window.addEventListener('focus', onResume);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -158,10 +163,11 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
}, [loadDashboard]);
|
}, [loadDashboard]);
|
||||||
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
|
||||||
|
|
||||||
|
|
||||||
const runScan = useCallback(
|
const runScan = useCallback(
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
async (opts?: { postAuthWarmup?: boolean }) => {
|
async (opts?: { postAuthWarmup?: boolean }) => {
|
||||||
|
|
||||||
trackStore('store_redeem_scan_start');
|
trackStore('store_redeem_scan_start');
|
||||||
@@ -277,9 +283,11 @@ export default function HomePage() {
|
|||||||
|
|
||||||
|
|
||||||
async function handleScan() {
|
async function handleScan() {
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
|
setScanMsg('');
|
||||||
|
|
||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
|
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||||
|
|
||||||
|
return;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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-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 ? (
|
<Text className="store-detail-package-tab-text">{pkg.name}</Text>
|
||||||
<Image className="store-detail-package-img" src={pkg.imageUrl} mode="aspectFill" />
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
) : null}
|
) : null}
|
||||||
<View className="store-detail-package-head">
|
<View className="store-detail-package-panel">
|
||||||
<Text className="store-detail-package-name">{pkg.name}</Text>
|
{activePackage.imageUrl ? (
|
||||||
{longBody ? (
|
<Image
|
||||||
<Text
|
className="store-detail-package-thumb"
|
||||||
className="store-detail-package-toggle"
|
src={activePackage.imageUrl}
|
||||||
onClick={() =>
|
mode="aspectFill"
|
||||||
setExpandedPackages((prev) => ({ ...prev, [index]: !prev[index] }))
|
/>
|
||||||
}
|
) : null}
|
||||||
>
|
<View className="store-detail-package-panel-body">
|
||||||
{expanded ? '收起' : '展开'}
|
{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>
|
</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>
|
||||||
<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>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 技术支持工单批量/人工改状态校验(禁止回退) */
|
/** 技术支持工单批量/人工改状态校验(禁止回退) */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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 头像 |
|
||||||
|
|||||||
+19
-14
@@ -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 压缩后上传** |
|
||||||
| 后端 | 工单 priority;client-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 图片自动压缩后可上传;仍超限有明确报错
|
||||||
|
- [ ] **门店 H5(iPhone 微信)**:首次扫码 OAuth 后自动续扫;offline verifying 时提示再点一次扫码
|
||||||
- [ ] `pnpm lint` 无新增错误
|
- [ ] `pnpm lint` 无新增错误
|
||||||
|
|||||||
Reference in New Issue
Block a user