Merge #22 into dev from dev_jacy
Merge commit '336116fbf21cbb33ddbaf00685f93b18ea84d5d6' into dev_jacy * dev_jacy: (8 commits) 微信分享 合伙人端开店选照片 首页定位问题 确定订单页修改 右上角位置信息仅展示不做交互 临时闭店UI修改 门店端微信号自动登录 Merge commit '336116fbf21cbb33ddbaf00685f93b18ea84d5d6' into dev_jacy Signed-off-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/22
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
needsWechatAuth,
|
||||
type PartnerProfile,
|
||||
} from '../lib/wechat-auth';
|
||||
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
|
||||
@@ -29,12 +30,11 @@ const DEFAULT_MAX_MB = 10;
|
||||
|
||||
function formatWechatUploadError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
const formatted = formatChooseImageFailMessage(msg);
|
||||
if (formatted) return formatted;
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
||||
}
|
||||
if (/permission|denied|拒绝/i.test(msg)) {
|
||||
return '微信选图权限被拒绝,请在微信设置中允许相册/相机访问后重试';
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -121,9 +121,10 @@ export default function OssUploadField({
|
||||
}
|
||||
|
||||
async function pickWechatImage() {
|
||||
await weixinSdk.init();
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
sourceType: ['album'],
|
||||
});
|
||||
if (files?.[0]) {
|
||||
await uploadSelectedFile(files[0]);
|
||||
@@ -150,7 +151,6 @@ export default function OssUploadField({
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (/cancel/i.test(msg)) return;
|
||||
setError(formatWechatUploadError(e));
|
||||
openNativeFilePicker();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login']);
|
||||
@@ -20,6 +21,10 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
const profile = getStoreProfile();
|
||||
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||
}
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,18 @@ const ACCESS_TOKEN = 'accessToken';
|
||||
const REFRESH_TOKEN = 'refreshToken';
|
||||
const LAST_PHONE = 'shopLastPhone';
|
||||
const STORE_PROFILE = 'shopStoreProfile';
|
||||
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
|
||||
export const SHOP_WX_BOUND = 'shopWxBound';
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = ['/shop/auth/token/refresh', '/shop/auth/sms/send', '/shop/auth/login/sms'];
|
||||
/** 微信验证通过后的免登录时长 */
|
||||
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||
'/shop/auth/token/refresh',
|
||||
'/shop/auth/sms/send',
|
||||
'/shop/auth/login/sms',
|
||||
'/shop/auth/login/wechat',
|
||||
];
|
||||
|
||||
export function getLastPhone() {
|
||||
return localStorage.getItem(LAST_PHONE) ?? '';
|
||||
@@ -44,6 +54,21 @@ export function getStoreProfile(): StoreSessionStore | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function hasShopWxSession() {
|
||||
return localStorage.getItem(SHOP_WX_BOUND) === '1';
|
||||
}
|
||||
|
||||
export function isShopSessionExpired() {
|
||||
const raw = localStorage.getItem(SESSION_EXPIRES_AT);
|
||||
if (!raw) return false;
|
||||
return Date.now() > Number(raw);
|
||||
}
|
||||
|
||||
export function touchShopSession() {
|
||||
if (!hasShopWxSession()) return;
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
export function saveAuth(data: ShopSessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
@@ -53,10 +78,22 @@ export function saveAuth(data: ShopSessionPayload) {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||
export function saveWechatSession(data: ShopSessionPayload) {
|
||||
saveAuth(data);
|
||||
localStorage.setItem(SHOP_WX_BOUND, '1');
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||
localStorage.removeItem(ACCESS_TOKEN);
|
||||
localStorage.removeItem(REFRESH_TOKEN);
|
||||
localStorage.removeItem(STORE_PROFILE);
|
||||
localStorage.removeItem(SESSION_EXPIRES_AT);
|
||||
localStorage.removeItem(SHOP_WX_BOUND);
|
||||
if (!options?.keepProfile) {
|
||||
localStorage.removeItem(STORE_PROFILE);
|
||||
localStorage.removeItem(LAST_PHONE);
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
@@ -109,6 +146,7 @@ async function refreshSession(): Promise<ShopSessionPayload | null> {
|
||||
null,
|
||||
);
|
||||
saveAuth(data);
|
||||
touchShopSession();
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -147,6 +185,10 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store:
|
||||
if (!isLoggedIn()) {
|
||||
return { authenticated: false, store: null };
|
||||
}
|
||||
if (isShopSessionExpired()) {
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile() };
|
||||
}
|
||||
try {
|
||||
const me = await rawRequest<StoreProfile>('/shop/auth/me');
|
||||
const store = profileFromMe(me);
|
||||
@@ -155,6 +197,7 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store:
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
store,
|
||||
});
|
||||
touchShopSession();
|
||||
return { authenticated: true, store };
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
@@ -163,8 +206,8 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store:
|
||||
if (refreshed?.store) {
|
||||
return { authenticated: true, store: refreshed.store };
|
||||
}
|
||||
clearAuth();
|
||||
return { authenticated: false, store: null };
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile() };
|
||||
}
|
||||
const cached = getStoreProfile();
|
||||
if (cached) return { authenticated: true, store: cached };
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { request, saveAuth, type ShopSessionPayload } from './api';
|
||||
import { request, saveWechatSession, type ShopSessionPayload } from './api';
|
||||
|
||||
export type ShopAccountProfile = {
|
||||
id: string;
|
||||
@@ -51,20 +52,17 @@ export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPa
|
||||
};
|
||||
}
|
||||
|
||||
export function saveShopWechatAuth(result: WechatLoginResult): boolean {
|
||||
/** 处理微信登录/绑定结果,写入 7 天免登录 session */
|
||||
export function handleShopWechatLoginResult(result: WechatLoginResult): ShopSessionPayload | null {
|
||||
const session = sessionFromWechatLogin(result);
|
||||
if (!session) return false;
|
||||
saveAuth(session);
|
||||
return true;
|
||||
if (!session) return null;
|
||||
saveWechatSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开以完成授权');
|
||||
}
|
||||
return weixinSdk.login();
|
||||
/** @deprecated 使用 handleShopWechatLoginResult */
|
||||
export function saveShopWechatAuth(result: WechatLoginResult): boolean {
|
||||
return !!handleShopWechatLoginResult(result);
|
||||
}
|
||||
|
||||
export async function handleShopWechatCallback(): Promise<WechatLoginResult | null> {
|
||||
@@ -73,3 +71,34 @@ export async function handleShopWechatCallback(): Promise<WechatLoginResult | nu
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
return weixinSdk.handleOAuthCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信一键登录(已绑定微信的门店账号免验证码)。
|
||||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||
*/
|
||||
export async function loginShopWithWechat(): Promise<ShopSessionPayload | null | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
const result = await weixinSdk.login();
|
||||
if (result) return handleShopWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) return;
|
||||
await weixinSdk.login();
|
||||
}
|
||||
|
||||
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
checkNeedsWechatAuth,
|
||||
fetchShopAccount,
|
||||
handleShopWechatCallback,
|
||||
saveShopWechatAuth,
|
||||
sessionFromWechatLogin,
|
||||
handleShopWechatLoginResult,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
@@ -56,9 +55,8 @@ export default function HomePage() {
|
||||
void handleShopWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = sessionFromWechatLogin(result);
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
saveShopWechatAuth(result);
|
||||
applySession(session);
|
||||
}
|
||||
setAuthModalOpen(false);
|
||||
|
||||
@@ -2,25 +2,42 @@ import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getLastPhone, getStoreProfile, request, type ShopSessionPayload } from '../lib/api';
|
||||
import { fetchClientConfig } from '../lib/wechat-auth';
|
||||
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
|
||||
import {
|
||||
bindShopWechatAfterSmsLogin,
|
||||
fetchClientConfig,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatLoginResult,
|
||||
loginShopWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
if (phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatWechatError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
||||
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession } = useStoreSession();
|
||||
const [params] = useSearchParams();
|
||||
const [params, setSearchParams] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const savedProfile = getStoreProfile();
|
||||
const [phone, setPhone] = useState(getLastPhone());
|
||||
const [code, setCode] = useState('');
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wxLoading, setWxLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||
@@ -31,6 +48,22 @@ export default function LoginPage() {
|
||||
.catch(() => setWxAuthorize(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
applySession(session);
|
||||
stripOAuthParamsFromLocation();
|
||||
setSearchParams({}, { replace: true });
|
||||
navigate('/');
|
||||
}
|
||||
})
|
||||
.catch((e) => setMsg(formatWechatError(e)));
|
||||
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
|
||||
|
||||
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
|
||||
const quickPhone = savedProfile?.phone || phone;
|
||||
|
||||
@@ -66,22 +99,22 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function login(options?: { quick?: boolean }) {
|
||||
if (!options?.quick && !ensureAgreed()) return;
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
if (options?.quick) {
|
||||
await request('SHOP_H5', '/shop/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: quickPhone, scene: 'STORE_LOGIN' }),
|
||||
});
|
||||
}
|
||||
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: options?.quick ? quickPhone : phone, code }),
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveAuth(data);
|
||||
applySession(data);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindShopWechatAfterSmsLogin();
|
||||
return;
|
||||
}
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
@@ -90,13 +123,30 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function wechatLogin() {
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
if (!wxAuthorize) return;
|
||||
setMsg('preV1:微信一键授权暂未开放,请使用手机验证码登录');
|
||||
setMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setMsg('请在微信内打开以使用微信一键登录');
|
||||
return;
|
||||
}
|
||||
setWxLoading(true);
|
||||
try {
|
||||
const session = await loginShopWithWechat();
|
||||
if (session) {
|
||||
applySession(session);
|
||||
navigate('/');
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(formatWechatError(e));
|
||||
} finally {
|
||||
setWxLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (quick) {
|
||||
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
|
||||
|
||||
return (
|
||||
<div className="shop-quick-login-page">
|
||||
<header className="shop-quick-header">
|
||||
@@ -129,18 +179,31 @@ export default function LoginPage() {
|
||||
|
||||
<div className="shop-quick-actions">
|
||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||
<button
|
||||
type="button"
|
||||
className="shop-quick-login-btn"
|
||||
disabled={loading}
|
||||
onClick={() => void login({ quick: true })}
|
||||
>
|
||||
<span>{loading ? '登录中...' : '一键登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
{canWechatQuick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-quick-login-btn shop-quick-login-btn--wechat"
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
|
||||
{wxAuthorize && !isWechatEnv()
|
||||
? '请在微信内打开以使用一键登录'
|
||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
||||
</p>
|
||||
)}
|
||||
{!canWechatQuick && (
|
||||
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
验证码登录
|
||||
</Link>
|
||||
)}
|
||||
<div className="shop-quick-secure">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
|
||||
<span>加密环境安全登录中</span>
|
||||
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,16 +281,21 @@ export default function LoginPage() {
|
||||
|
||||
{wxAuthorize && (
|
||||
<>
|
||||
<div className="shop-login-divider">
|
||||
<span className="shop-login-divider-line" />
|
||||
<span className="shop-login-divider-text">或者</span>
|
||||
<span className="shop-login-divider-line" />
|
||||
</div>
|
||||
<div className="shop-login-divider">
|
||||
<span className="shop-login-divider-line" />
|
||||
<span className="shop-login-divider-text">或者</span>
|
||||
<span className="shop-login-divider-line" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="shop-login-wechat" onClick={wechatLogin}>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>微信一键授权</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-login-wechat"
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -252,9 +320,11 @@ export default function LoginPage() {
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
|
||||
security
|
||||
</span>
|
||||
<p style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Link to="/login?quick=1" className="text-primary body-md">一键登录</Link>
|
||||
</p>
|
||||
{hasShopWxSession() && savedProfile && (
|
||||
<p style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Link to="/login?quick=1" className="text-primary body-md">微信快捷登录</Link>
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -86,13 +86,17 @@ export default function StatusPage() {
|
||||
{open ? '营业中' : '临时闭店'}
|
||||
</h2>
|
||||
|
||||
<label className="shop-status-switch">
|
||||
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={open}
|
||||
onChange={(e) => requestToggle(e.target.checked)}
|
||||
aria-label={open ? '切换为临时闭店' : '切换为营业中'}
|
||||
/>
|
||||
<span className="shop-status-switch-track" />
|
||||
<span className="shop-status-switch-caption">
|
||||
{open ? '点击可临时闭店' : '点击恢复营业'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p className="shop-status-hours-label">营业时间</p>
|
||||
|
||||
@@ -473,6 +473,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-quick-login-btn--wechat {
|
||||
background: #07c160;
|
||||
box-shadow: 0 8px 24px rgba(7, 193, 96, 0.25);
|
||||
}
|
||||
|
||||
.shop-quick-secure {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1745,9 +1750,24 @@
|
||||
.shop-status-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.shop-status-switch-caption {
|
||||
font-family: var(--font-label);
|
||||
font-size: 12px;
|
||||
color: var(--color-subtle-gray);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shop-status-switch.closed .shop-status-switch-caption {
|
||||
color: var(--color-on-surface-variant);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shop-status-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
@@ -1758,11 +1778,13 @@
|
||||
.shop-status-switch-track {
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
background: var(--color-surface-variant);
|
||||
background: var(--color-surface-container-highest);
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.shop-status-switch-track::after {
|
||||
@@ -1772,18 +1794,31 @@
|
||||
left: 4px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--color-card);
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.2s;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.shop-status-switch input:not(:checked) + .shop-status-switch-track {
|
||||
background: #b5b5b5;
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.shop-status-switch input:not(:checked) + .shop-status-switch-track::after {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.shop-status-switch input:checked + .shop-status-switch-track {
|
||||
background: var(--color-success-green);
|
||||
border-color: var(--color-success-green);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.shop-status-switch input:checked + .shop-status-switch-track::after {
|
||||
transform: translateX(40px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.shop-status-hours-label {
|
||||
@@ -1825,7 +1860,7 @@
|
||||
|
||||
.shop-status-hint.closed {
|
||||
background: var(--color-surface-container);
|
||||
border: 1px solid var(--color-surface-variant);
|
||||
border: 1px solid var(--color-surface-container-highest);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import PayPage from './pages/PayPage';
|
||||
import CustomerServicePage from './pages/CustomerServicePage';
|
||||
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||
import { capturePromoFromUrl, touchPromoIfNeeded } from './lib/promo';
|
||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||
|
||||
function PromoBootstrap() {
|
||||
useEffect(() => {
|
||||
@@ -34,6 +35,7 @@ export default function App() {
|
||||
return (
|
||||
<UserSessionProvider>
|
||||
<PromoBootstrap />
|
||||
<WechatShareBootstrap />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getDistrictsForPicker,
|
||||
getProvincesForPicker,
|
||||
normalizeRegionSelection,
|
||||
toCityLevelRegion,
|
||||
type RegionSelection,
|
||||
} from '../lib/region-data';
|
||||
|
||||
@@ -15,26 +16,57 @@ type RegionPickerProps = {
|
||||
value: RegionSelection;
|
||||
onClose: () => void;
|
||||
onConfirm: (region: RegionSelection) => void;
|
||||
/** 2 = 仅省/市(门店列表);3 = 省/市/区(地址等) */
|
||||
levels?: 2 | 3;
|
||||
};
|
||||
|
||||
type PickerLevel = 'province' | 'city' | 'district';
|
||||
|
||||
const TABS: Array<{ key: PickerLevel; label: string }> = [
|
||||
const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [
|
||||
{ key: 'province', label: '省份' },
|
||||
{ key: 'city', label: '城市' },
|
||||
{ key: 'district', label: '区县' },
|
||||
];
|
||||
|
||||
export default function RegionPicker({ open, value, onClose, onConfirm }: RegionPickerProps) {
|
||||
function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel {
|
||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
||||
if (levels === 2) {
|
||||
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
|
||||
}
|
||||
if (normalized.district && normalized.district !== REGION_ALL) return 'district';
|
||||
if (normalized.city && normalized.city !== REGION_ALL) return 'city';
|
||||
return 'province';
|
||||
}
|
||||
|
||||
function tabLabel(tab: PickerLevel, draft: RegionSelection, fallback: string) {
|
||||
if (tab === 'province') {
|
||||
return draft.province && draft.province !== REGION_ALL ? draft.province : fallback;
|
||||
}
|
||||
if (tab === 'city') {
|
||||
return draft.city && draft.city !== REGION_ALL ? draft.city : fallback;
|
||||
}
|
||||
return draft.district && draft.district !== REGION_ALL ? draft.district : fallback;
|
||||
}
|
||||
|
||||
export default function RegionPicker({
|
||||
open,
|
||||
value,
|
||||
onClose,
|
||||
onConfirm,
|
||||
levels = 3,
|
||||
}: RegionPickerProps) {
|
||||
const [draft, setDraft] = useState<RegionSelection>(value);
|
||||
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(normalizeRegionSelection(value));
|
||||
setActiveTab('province');
|
||||
}, [open, value]);
|
||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
||||
setDraft(normalized);
|
||||
setActiveTab(initialTab(value, levels));
|
||||
}, [open, value, levels]);
|
||||
|
||||
const listItems = useMemo(() => {
|
||||
if (activeTab === 'province') return getProvincesForPicker();
|
||||
@@ -45,7 +77,10 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region
|
||||
const selectedValue =
|
||||
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
|
||||
|
||||
const canConfirm = Boolean(draft.province && draft.city && draft.district);
|
||||
const canConfirm =
|
||||
levels === 2
|
||||
? Boolean(draft.province && draft.city)
|
||||
: Boolean(draft.province && draft.city && draft.district);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -68,6 +103,15 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region
|
||||
}
|
||||
const nextCities = getCities(province);
|
||||
const city = nextCities[0] ?? '';
|
||||
if (levels === 2) {
|
||||
setDraft({
|
||||
province,
|
||||
city,
|
||||
district: REGION_ALL,
|
||||
});
|
||||
setActiveTab('city');
|
||||
return;
|
||||
}
|
||||
const nextDistricts = getDistricts(province, city);
|
||||
setDraft({
|
||||
province,
|
||||
@@ -80,7 +124,15 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region
|
||||
function selectCity(city: string) {
|
||||
if (city === REGION_ALL) {
|
||||
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
|
||||
setActiveTab('district');
|
||||
if (levels === 3) setActiveTab('district');
|
||||
return;
|
||||
}
|
||||
if (levels === 2) {
|
||||
setDraft({
|
||||
...draft,
|
||||
city,
|
||||
district: REGION_ALL,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nextDistricts = getDistricts(draft.province, city);
|
||||
@@ -110,7 +162,8 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region
|
||||
|
||||
function handleConfirm() {
|
||||
if (!canConfirm) return;
|
||||
onConfirm(normalizeRegionSelection(draft));
|
||||
const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft);
|
||||
onConfirm(next);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -123,7 +176,7 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region
|
||||
>
|
||||
<div className="region-picker-toolbar">
|
||||
<div className="region-picker-tabs" role="tablist">
|
||||
{TABS.map((tab) => {
|
||||
{tabs.map((tab) => {
|
||||
const disabled =
|
||||
(tab.key === 'city' && !draft.province) ||
|
||||
(tab.key === 'district' && (!draft.province || !draft.city));
|
||||
@@ -137,7 +190,7 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region
|
||||
className={`region-picker-tab${activeTab === tab.key ? ' active' : ''}`}
|
||||
onClick={() => onTabClick(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
{tabLabel(tab.key, draft, tab.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { applyDefaultWechatShare } from '../lib/wechat-share';
|
||||
|
||||
/** 路由变化时刷新微信右上角分享卡片 */
|
||||
export default function WechatShareBootstrap() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
void applyDefaultWechatShare().catch(() => {});
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -58,6 +58,25 @@ export function formatRegion(province: string, city: string, district: string):
|
||||
return `${province} ${city} ${district}`;
|
||||
}
|
||||
|
||||
/** 仅展示省、市两级(门店列表等场景) */
|
||||
export function formatRegionCity(province: string, city: string): string {
|
||||
if (!province) return '';
|
||||
if (province === REGION_ALL) return REGION_ALL;
|
||||
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
|
||||
if (!city) return province;
|
||||
return `${province} ${city}`;
|
||||
}
|
||||
|
||||
/** 门店筛选:固定为市级,不按区县过滤 */
|
||||
export function toCityLevelRegion(selection: RegionSelection): RegionSelection {
|
||||
const normalized = normalizeRegionSelection(selection);
|
||||
return {
|
||||
province: normalized.province,
|
||||
city: normalized.city,
|
||||
district: REGION_ALL,
|
||||
};
|
||||
}
|
||||
|
||||
export type RegionSelection = {
|
||||
province: string;
|
||||
city: string;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { WechatShareData } from '@dukang/weixin-sdk';
|
||||
import { getWechatShareLink, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
|
||||
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
|
||||
export const DEFAULT_SHARE_DESC = '杜康好客 · 买酒享权益,全城门店可用';
|
||||
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
|
||||
|
||||
export function getDefaultShareImageUrl(): string {
|
||||
if (typeof window === 'undefined') return toAppPath('/logo.png');
|
||||
return new URL(toAppPath('/logo.png'), window.location.origin).href;
|
||||
}
|
||||
|
||||
export function buildDefaultShareData(
|
||||
overrides?: Partial<WechatShareData>,
|
||||
): WechatShareData {
|
||||
return {
|
||||
title: overrides?.title ?? DEFAULT_SHARE_TITLE,
|
||||
desc: overrides?.desc ?? DEFAULT_SHARE_DESC,
|
||||
link: overrides?.link ?? getWechatShareLink(),
|
||||
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyDefaultWechatShare(
|
||||
overrides?: Partial<WechatShareData>,
|
||||
): Promise<void> {
|
||||
if (!isWechatEnv()) return;
|
||||
await weixinSdk.setShare(buildDefaultShareData(overrides));
|
||||
}
|
||||
|
||||
export function handleShareButtonClick(onHint: (message: string) => void): void {
|
||||
const showHint = (message: string) => {
|
||||
onHint(message);
|
||||
if (message) {
|
||||
window.setTimeout(() => onHint(''), 2500);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
showHint('请在微信内打开后分享');
|
||||
return;
|
||||
}
|
||||
void applyDefaultWechatShare()
|
||||
.then(() => showHint(WECHAT_SHARE_HINT))
|
||||
.catch(() => showHint('分享配置失败,请刷新页面后重试'));
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FALLBACK_CITY_CODE,
|
||||
resolveUserCity,
|
||||
} from '../lib/wechat-location';
|
||||
import { formatRegionCity } from '../lib/region-data';
|
||||
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
||||
|
||||
type Product = {
|
||||
@@ -44,8 +45,7 @@ export default function HomePage() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || FALLBACK_CITY_CODE);
|
||||
const [locatedCityLabel, setLocatedCityLabel] = useState('');
|
||||
const [citySource, setCitySource] = useState<'auto' | 'manual'>('auto');
|
||||
const [locationLabel, setLocationLabel] = useState('定位中...');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,10 +62,13 @@ export default function HomePage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (citySource !== 'auto') return;
|
||||
resolveUserCity().then((resolved) => {
|
||||
if (!resolved) return;
|
||||
setLocatedCityLabel(resolved.displayCity);
|
||||
if (!resolved) {
|
||||
setLocationLabel('郑州市');
|
||||
setCityCode(FALLBACK_CITY_CODE);
|
||||
return;
|
||||
}
|
||||
setLocationLabel(formatRegionCity(resolved.province, resolved.city));
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
setCityCode(resolved.cityCode);
|
||||
} else {
|
||||
@@ -74,7 +77,7 @@ export default function HomePage() {
|
||||
window.setTimeout(() => setToast(''), 2200);
|
||||
}
|
||||
});
|
||||
}, [citySource]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cityCode) return;
|
||||
@@ -95,8 +98,6 @@ export default function HomePage() {
|
||||
setTab(key);
|
||||
}
|
||||
|
||||
const selectedCity = cities.find((c) => c.code === cityCode);
|
||||
const headerCityLabel = locatedCityLabel || selectedCity?.name || '郑州市';
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
@@ -105,23 +106,9 @@ export default function HomePage() {
|
||||
<TabMainHeader
|
||||
title="杜康好客"
|
||||
extra={(
|
||||
<div className="tab-main-city">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span className="tab-main-city-label">{headerCityLabel}</span>
|
||||
<select
|
||||
value={cityCode}
|
||||
onChange={(e) => {
|
||||
setCitySource('manual');
|
||||
setCityCode(e.target.value);
|
||||
}}
|
||||
className="tab-main-city-select"
|
||||
aria-label="选择开城城市"
|
||||
>
|
||||
{cities.map((c) => (
|
||||
<option key={c.code} value={c.code}>{c.name}</option>
|
||||
))}
|
||||
{!cities.length && <option value={cityCode}>{selectedCity?.name ?? '郑州市'}</option>}
|
||||
</select>
|
||||
<div className="tab-main-city tab-main-city--readonly" aria-label={`当前位置 ${locationLabel}`}>
|
||||
<span className="material-symbols-outlined" aria-hidden>location_on</span>
|
||||
<span className="tab-main-city-label">{locationLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -70,6 +70,7 @@ export default function OrderConfirmPage() {
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [addressId, setAddressId] = useState(params.get('addressId') || '');
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
@@ -104,13 +105,35 @@ export default function OrderConfirmPage() {
|
||||
}, [refreshProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId || !addressId) return;
|
||||
if (!productId) return;
|
||||
let cancelled = false;
|
||||
setPreviewLoading(true);
|
||||
const body: { productId: string; quantity: number; addressId?: string } = {
|
||||
productId,
|
||||
quantity,
|
||||
};
|
||||
if (addressId) body.addressId = addressId;
|
||||
|
||||
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ productId, quantity, addressId }),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : String(e)));
|
||||
.then((data) => {
|
||||
if (!cancelled) setPreview(data);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId, quantity, addressId]);
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
@@ -331,9 +354,13 @@ export default function OrderConfirmPage() {
|
||||
<span className="order-confirm-row-label">配送方式</span>
|
||||
<div className="order-confirm-delivery-value">
|
||||
<p className="order-confirm-row-value">
|
||||
{isCross ? '物流配送' : '小飞侠配送'}
|
||||
{!addressId
|
||||
? '选择地址后确认'
|
||||
: isCross
|
||||
? '物流配送'
|
||||
: '小飞侠配送'}
|
||||
</p>
|
||||
{!isCross && (
|
||||
{addressId && !isCross && (
|
||||
<p className="order-confirm-delivery-hint">预计24小时内送达</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -358,10 +385,14 @@ export default function OrderConfirmPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{!preview && productId && addressId && (
|
||||
{previewLoading && !preview && productId && (
|
||||
<div className="order-confirm-loading">加载订单信息...</div>
|
||||
)}
|
||||
|
||||
{!previewLoading && !preview && productId && (
|
||||
<div className="order-confirm-loading">无法加载商品信息</div>
|
||||
)}
|
||||
|
||||
{msg && <p className="order-confirm-msg">{msg}</p>}
|
||||
</main>
|
||||
|
||||
@@ -376,10 +407,10 @@ export default function OrderConfirmPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="order-confirm-pay-btn"
|
||||
disabled={loading || !preview}
|
||||
disabled={loading || !preview || !addressId}
|
||||
onClick={submit}
|
||||
>
|
||||
{loading ? '支付中...' : '微信支付'}
|
||||
{loading ? '支付中...' : !addressId ? '请选择地址' : '微信支付'}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { request } from '../lib/api';
|
||||
import { buildOrderAddressSelectUrl } from '../lib/navigation';
|
||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||
import { handleShareButtonClick } from '../lib/wechat-share';
|
||||
import ContactCustomerSheet from '../components/ContactCustomerSheet';
|
||||
|
||||
type OrderItem = {
|
||||
@@ -121,6 +123,7 @@ export default function OrderDetailPage() {
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [showCs, setShowCs] = useState(false);
|
||||
const [copyHint, setCopyHint] = useState('');
|
||||
const [shareToast, setShareToast] = useState('');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const isReship = order?.orderType === 'RESHIPMENT' || params.get('type') === 'reship';
|
||||
@@ -206,7 +209,7 @@ export default function OrderDetailPage() {
|
||||
</button>
|
||||
<h1 className="order-detail-topbar-title">杜康好客</h1>
|
||||
<div className="order-detail-topbar-actions">
|
||||
<button type="button" className="order-detail-topbar-btn" aria-label="分享" onClick={() => {}}>
|
||||
<button type="button" className="order-detail-topbar-btn" aria-label="分享" onClick={() => handleShareButtonClick(setShareToast)}>
|
||||
<span className="material-symbols-outlined">share</span>
|
||||
</button>
|
||||
<button type="button" className="order-detail-topbar-btn" aria-label="更多" onClick={() => {}}>
|
||||
@@ -215,6 +218,8 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AppToast message={shareToast} />
|
||||
|
||||
<main className="order-detail-main order-detail-main--stitch">
|
||||
<section className="order-detail-status-card">
|
||||
<div className="order-detail-status-deco" aria-hidden>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { request } from '../lib/api';
|
||||
import { track } from '../lib/analytics';
|
||||
import { handleShareButtonClick } from '../lib/wechat-share';
|
||||
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
||||
import type { ProductImageSource } from '../lib/product-images';
|
||||
|
||||
@@ -21,6 +23,7 @@ export default function ProductDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
||||
@@ -66,12 +69,14 @@ export default function ProductDetailPage() {
|
||||
type="button"
|
||||
className="product-detail-header-btn"
|
||||
aria-label="分享"
|
||||
onClick={() => {}}
|
||||
onClick={() => handleShareButtonClick(setToast)}
|
||||
>
|
||||
<span className="material-symbols-outlined">share</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<AppToast message={toast} />
|
||||
|
||||
<main className="product-detail-main">
|
||||
<section className="product-detail-hero">
|
||||
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { request } from '../lib/api';
|
||||
import { track } from '../lib/analytics';
|
||||
import { handleShareButtonClick } from '../lib/wechat-share';
|
||||
import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images';
|
||||
|
||||
type StoreMedia = { url: string; mediaType?: string; sortOrder?: number };
|
||||
@@ -54,6 +56,7 @@ export default function StoreDetailPage() {
|
||||
const [store, setStore] = useState<StoreDetail | null>(null);
|
||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -114,11 +117,13 @@ export default function StoreDetailPage() {
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className={`app-page-title store-detail-header-title${headerSolid ? ' visible' : ''}`}>门店详情</h1>
|
||||
<button type="button" className="store-detail-header-btn" aria-label="分享" onClick={() => {}}>
|
||||
<button type="button" className="store-detail-header-btn" aria-label="分享" onClick={() => handleShareButtonClick(setToast)}>
|
||||
<span className="material-symbols-outlined">share</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<AppToast message={toast} />
|
||||
|
||||
<main className="store-detail-main">
|
||||
<section className="store-detail-hero">
|
||||
<ProductCarousel images={galleryImages} alt={store.name} variant="store" />
|
||||
|
||||
@@ -8,8 +8,9 @@ import RegionPicker from '../components/RegionPicker';
|
||||
import {
|
||||
DEFAULT_REGION,
|
||||
FALLBACK_CITY_REGION,
|
||||
formatRegion,
|
||||
formatRegionCity,
|
||||
REGION_ALL,
|
||||
toCityLevelRegion,
|
||||
type RegionSelection,
|
||||
} from '../lib/region-data';
|
||||
import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location';
|
||||
@@ -62,7 +63,7 @@ export default function StoreListPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||
const [region, setRegion] = useState<RegionSelection>(() => toCityLevelRegion(DEFAULT_REGION));
|
||||
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<'auto' | 'manual'>('auto');
|
||||
const [usedFallback, setUsedFallback] = useState(false);
|
||||
@@ -82,7 +83,7 @@ export default function StoreListPage() {
|
||||
if (filterMode !== 'auto' || geoReady) return;
|
||||
resolveUserCity().then((resolved) => {
|
||||
if (resolved) {
|
||||
setRegion(resolved.region);
|
||||
setRegion(toCityLevelRegion(resolved.region));
|
||||
}
|
||||
setGeoReady(true);
|
||||
});
|
||||
@@ -103,7 +104,7 @@ export default function StoreListPage() {
|
||||
code !== FALLBACK_CITY_CODE
|
||||
) {
|
||||
setUsedFallback(true);
|
||||
setRegion(FALLBACK_CITY_REGION);
|
||||
setRegion(toCityLevelRegion(FALLBACK_CITY_REGION));
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
@@ -138,11 +139,8 @@ export default function StoreListPage() {
|
||||
const city = s.cityName ?? '郑州市';
|
||||
if (region.province !== REGION_ALL && province !== region.province) return false;
|
||||
if (region.city !== REGION_ALL && city !== region.city) return false;
|
||||
if (region.district !== REGION_ALL && s.district !== region.district) return false;
|
||||
return true;
|
||||
});
|
||||
} else if (region.district !== REGION_ALL) {
|
||||
list = list.filter((s) => s.district === region.district);
|
||||
}
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (q) {
|
||||
@@ -156,7 +154,7 @@ export default function StoreListPage() {
|
||||
return list;
|
||||
}, [stores, categoryTab, keyword, region, cityCode]);
|
||||
|
||||
const regionLabel = formatRegion(region.province, region.city, region.district);
|
||||
const regionLabel = formatRegionCity(region.province, region.city);
|
||||
const emptyMessage =
|
||||
filterMode === 'manual' && filtered.length === 0
|
||||
? '未找到匹配门店'
|
||||
@@ -271,10 +269,11 @@ export default function StoreListPage() {
|
||||
<RegionPicker
|
||||
open={regionPickerOpen}
|
||||
value={region}
|
||||
levels={2}
|
||||
onClose={() => setRegionPickerOpen(false)}
|
||||
onConfirm={(next) => {
|
||||
setFilterMode('manual');
|
||||
setRegion(next);
|
||||
setRegion(toCityLevelRegion(next));
|
||||
setRegionPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -606,21 +606,16 @@
|
||||
max-width: 46vw;
|
||||
}
|
||||
|
||||
.tab-main-city--readonly {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-main-city-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 72px;
|
||||
}
|
||||
|
||||
.tab-main-city-select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
max-width: 72px;
|
||||
opacity: 0.85;
|
||||
font-size: 11px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.tab-main-city .material-symbols-outlined {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getRuntimePlatform } from './env';
|
||||
import { ensureJssdkReady, normalizeJssdkPageUrl, getJssdkSignUrl } from './jssdk';
|
||||
import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env';
|
||||
import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
export type ChooseWechatImageOptions = {
|
||||
@@ -7,6 +7,38 @@ export type ChooseWechatImageOptions = {
|
||||
sourceType?: Array<'album' | 'camera'>;
|
||||
};
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 将微信 chooseImage / getLocalImgData fail 的 errMsg 转为用户可读文案 */
|
||||
export function formatChooseImageFailMessage(errMsg: string): string {
|
||||
const msg = errMsg.trim() || '无法打开相册';
|
||||
if (/cancel/i.test(msg)) return '';
|
||||
|
||||
if (/offline verifying|permission value is offline/i.test(msg)) {
|
||||
return '微信权限验证中,请稍候再试或刷新页面后重新选择图片';
|
||||
}
|
||||
|
||||
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败,请刷新页面后重试';
|
||||
}
|
||||
|
||||
if (/photo.*denied|photos.*denied|相册.*权限|无法访问相册|无相册权限/i.test(msg)) {
|
||||
return '相册权限未开启,请在 iPhone「设置 → 隐私与安全性 → 照片」中允许微信访问相册';
|
||||
}
|
||||
|
||||
if (/system.*camera|camera.*not.*allowed|无法访问相机|无相机权限/i.test(msg)) {
|
||||
return '相机权限未开启,请在 iPhone「设置 → 隐私与安全性 → 相机」中允许微信使用摄像头';
|
||||
}
|
||||
|
||||
if (/permission|auth|denied|授权|拒绝/i.test(msg)) {
|
||||
return `微信选图接口未就绪(${msg}),请刷新页面后重试`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function reportChooseImageEvent(
|
||||
config: WeixinSdkConfig,
|
||||
payload: {
|
||||
@@ -67,7 +99,34 @@ function localIdToFile(localId: string): Promise<File> {
|
||||
reject(e instanceof Error ? e : new Error('图片解析失败'));
|
||||
}
|
||||
},
|
||||
fail: (err) => reject(new Error(err.errMsg || '读取图片失败')),
|
||||
fail: (err) => {
|
||||
const raw = err.errMsg || '读取图片失败';
|
||||
const formatted = formatChooseImageFailMessage(raw);
|
||||
reject(new Error(formatted || raw));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function invokeChooseImage(
|
||||
count: number,
|
||||
sourceType: Array<'album' | 'camera'>,
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.wx!.chooseImage!({
|
||||
count,
|
||||
sizeType: ['compressed'],
|
||||
sourceType,
|
||||
success: (res) => resolve(res.localIds ?? []),
|
||||
fail: (err) => {
|
||||
const raw = err.errMsg || '无法打开相册';
|
||||
if (/cancel/i.test(raw)) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const formatted = formatChooseImageFailMessage(raw);
|
||||
reject(new Error(formatted || raw));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -84,7 +143,6 @@ export async function chooseWechatImages(
|
||||
const sourceType = options.sourceType ?? ['album', 'camera'];
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
const pageUrl = typeof window !== 'undefined' ? getJssdkSignUrl() : '';
|
||||
const sourceTypeKey = sourceType.join(',');
|
||||
try {
|
||||
await ensureJssdkReady({
|
||||
@@ -104,26 +162,38 @@ export async function chooseWechatImages(
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
let localIds: string[];
|
||||
try {
|
||||
localIds = await new Promise<string[]>((resolve, reject) => {
|
||||
window.wx!.chooseImage!({
|
||||
count,
|
||||
sizeType: ['compressed'],
|
||||
sourceType,
|
||||
success: (res) => resolve(res.localIds ?? []),
|
||||
fail: (err) => reject(new Error(err.errMsg || '无法打开相册')),
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : '无法打开相册';
|
||||
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'choose' });
|
||||
throw e;
|
||||
if (isIosDevice() && !isWechatDevTools()) {
|
||||
await delay(500);
|
||||
}
|
||||
|
||||
let localIds: string[] = [];
|
||||
const maxAttempts = 2;
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await delay(800);
|
||||
}
|
||||
try {
|
||||
localIds = await invokeChooseImage(count, sourceType);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e : new Error('无法打开相册');
|
||||
const raw = lastError.message;
|
||||
const retryable = /offline verifying|权限验证中|接口未就绪/i.test(raw);
|
||||
if (attempt < maxAttempts - 1 && retryable) continue;
|
||||
void reportChooseImageEvent(config, {
|
||||
status: 'fail',
|
||||
errMsg: raw,
|
||||
sourceType: sourceTypeKey,
|
||||
stage: 'choose',
|
||||
});
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
|
||||
if (!localIds.length) {
|
||||
const errMsg = '未选择图片';
|
||||
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'empty' });
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,14 @@ export {
|
||||
export type { WechatLocationOutcome } from './location';
|
||||
export { scanQrCode } from './scan';
|
||||
export { invokeWechatPay } from './pay';
|
||||
export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage';
|
||||
export { chooseWechatImages, canUseWechatChooseImage, formatChooseImageFailMessage } from './chooseImage';
|
||||
export type { ChooseWechatImageOptions } from './chooseImage';
|
||||
export {
|
||||
setWechatShareData,
|
||||
canUseWechatShare,
|
||||
getWechatShareLink,
|
||||
} from './share';
|
||||
export type { WechatShareData } from './share';
|
||||
export {
|
||||
getWechatOAuthUrl,
|
||||
startWechatOAuthLogin,
|
||||
@@ -41,6 +47,7 @@ import { getWechatLocation, getWechatLocationDetailed } from './location';
|
||||
import { scanQrCode } from './scan';
|
||||
import { invokeWechatPay } from './pay';
|
||||
import { chooseWechatImages } from './chooseImage';
|
||||
import { setWechatShareData } from './share';
|
||||
import {
|
||||
wechatLogin,
|
||||
handleWechatOAuthCallback,
|
||||
@@ -68,5 +75,7 @@ export function createWeixinSdk(config: WeixinSdkConfig) {
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
}),
|
||||
setShare: (data: Parameters<typeof setWechatShareData>[1]) =>
|
||||
setWechatShareData(config, data),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk';
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
export type WechatShareData = {
|
||||
title: string;
|
||||
desc: string;
|
||||
link: string;
|
||||
imgUrl: string;
|
||||
};
|
||||
|
||||
export function canUseWechatShare(): boolean {
|
||||
return isWechatBrowser() || getRuntimePlatform() === 'mini';
|
||||
}
|
||||
|
||||
function applyShareData(data: WechatShareData): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wx = window.wx;
|
||||
if (!wx?.updateAppMessageShareData || !wx.updateTimelineShareData) {
|
||||
reject(new Error('当前微信版本不支持分享,请升级微信后重试'));
|
||||
return;
|
||||
}
|
||||
|
||||
let pending = 2;
|
||||
let failed = false;
|
||||
const done = (err?: Error) => {
|
||||
if (err && !failed) {
|
||||
failed = true;
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
pending -= 1;
|
||||
if (pending === 0 && !failed) resolve();
|
||||
};
|
||||
|
||||
wx.updateAppMessageShareData({
|
||||
title: data.title,
|
||||
desc: data.desc,
|
||||
link: data.link,
|
||||
imgUrl: data.imgUrl,
|
||||
success: () => done(),
|
||||
fail: (res) => done(new Error(res.errMsg || '设置分享给朋友失败')),
|
||||
});
|
||||
|
||||
wx.updateTimelineShareData({
|
||||
title: data.title,
|
||||
link: data.link,
|
||||
imgUrl: data.imgUrl,
|
||||
success: () => done(),
|
||||
fail: (res) => done(new Error(res.errMsg || '设置分享到朋友圈失败')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 配置微信内分享卡片(好友 / 朋友圈) */
|
||||
export async function setWechatShareData(
|
||||
config: WeixinSdkConfig,
|
||||
data: WechatShareData,
|
||||
): Promise<void> {
|
||||
if (!canUseWechatShare()) return;
|
||||
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
});
|
||||
|
||||
await applyShareData(data);
|
||||
}
|
||||
|
||||
/** 获取当前页分享链接(去掉 hash、OAuth 回跳参数) */
|
||||
export function getWechatShareLink(rawUrl?: string): string {
|
||||
if (typeof window === 'undefined') return rawUrl ?? '';
|
||||
return normalizeJssdkPageUrl(rawUrl ?? window.location.href);
|
||||
}
|
||||
@@ -53,6 +53,21 @@ export type WxApi = {
|
||||
success?: (res: { localData: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
updateAppMessageShareData: (options: {
|
||||
title: string;
|
||||
desc: string;
|
||||
link: string;
|
||||
imgUrl: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
updateTimelineShareData: (options: {
|
||||
title: string;
|
||||
link: string;
|
||||
imgUrl: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export type MiniProgramWx = {
|
||||
|
||||
@@ -1393,7 +1393,8 @@ export class AuthService {
|
||||
phoneVerified,
|
||||
};
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' });
|
||||
const refreshExpiresIn = actorType === 'STORE' ? '7d' : '30d';
|
||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: refreshExpiresIn });
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
|
||||
Reference in New Issue
Block a user