微信SDK接通

This commit is contained in:
2026-07-01 19:56:44 +08:00
parent a1cbfc7241
commit aea1513836
38 changed files with 1610 additions and 92 deletions
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@dukang/shared-ui": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
+9
View File
@@ -0,0 +1,9 @@
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
export const weixinSdk = createWeixinSdk({
apiBase: '/api/v1',
clientApp: 'SHOP_H5',
getAccessToken: () => localStorage.getItem('accessToken'),
});
export { isWechatEnv };
+18 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
@@ -27,6 +28,22 @@ export default function HomePage() {
const openTime = String(store?.openTime || '10:00');
const closeTime = String(store?.closeTime || '22:00');
async function handleScan() {
if (isWechatEnv()) {
try {
await weixinSdk.init();
const token = await weixinSdk.scanQrCode();
if (token) {
navigate(`/redeem?token=${encodeURIComponent(token)}`);
return;
}
} catch {
/* fall through to manual redeem page */
}
}
navigate('/redeem');
}
return (
<div className="shop-home-page">
<header className="shop-home-header">
@@ -55,7 +72,7 @@ export default function HomePage() {
</section>
<section className="shop-home-scan">
<button type="button" className="shop-home-scan-btn" onClick={() => navigate('/redeem')}>
<button type="button" className="shop-home-scan-btn" onClick={handleScan}>
<span className="material-symbols-outlined">qr_code_scanner</span>
</button>
<p className="shop-home-scan-label"></p>
+7 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { request } from '../lib/api';
function formatAmount(n: number) {
@@ -8,6 +8,7 @@ function formatAmount(n: number) {
export default function RedeemConfirmPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [token, setToken] = useState('');
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
@@ -20,6 +21,11 @@ export default function RedeemConfirmPage() {
.catch(() => setStoreName('当前门店'));
}, []);
useEffect(() => {
const scanned = searchParams.get('token');
if (scanned) setToken(scanned);
}, [searchParams]);
async function confirm() {
if (!token.trim()) {
setMsg('请在开发者选项中输入核销码');
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
"element-china-area-data": "^6.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+12 -49
View File
@@ -1,3 +1,6 @@
import { getWechatLocation } from '@dukang/weixin-sdk';
import { weixinSdk } from './weixin';
export type ClientGpsLocation = {
province?: string;
city?: string;
@@ -7,55 +10,15 @@ export type ClientGpsLocation = {
address?: string;
};
type WxLocationResult = {
latitude: number;
longitude: number;
};
declare global {
interface Window {
wx?: {
getLocation?: (options: {
type?: string;
success?: (res: WxLocationResult) => void;
fail?: () => void;
}) => void;
};
}
}
/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
if (typeof window !== 'undefined' && window.wx?.getLocation) {
const wxResult = await new Promise<WxLocationResult | null>((resolve) => {
window.wx!.getLocation!({
type: 'gcj02',
success: (res) => resolve(res),
fail: () => resolve(null),
});
});
if (wxResult) {
return {
latitude: wxResult.latitude,
longitude: wxResult.longitude,
};
}
}
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return null;
}
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
});
},
() => resolve(null),
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60_000 },
);
});
const loc = await weixinSdk.getLocation();
if (!loc) return null;
return {
latitude: loc.latitude,
longitude: loc.longitude,
};
}
/** @deprecated 使用 tryGetClientGpsLocation */
export { getWechatLocation };
+11
View File
@@ -0,0 +1,11 @@
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
const CLIENT_APP = 'USER_H5';
export const weixinSdk = createWeixinSdk({
apiBase: '/api/v1',
clientApp: CLIENT_APP,
getAccessToken: () => localStorage.getItem('accessToken'),
});
export { isWechatEnv };
+71 -15
View File
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import type { WechatLoginResult } from '@dukang/shared-types';
import { request, saveSession } from '../lib/api';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
export default function LoginPage() {
const navigate = useNavigate();
@@ -12,6 +14,38 @@ export default function LoginPage() {
const [agreed, setAgreed] = useState(true);
const [codeCooldown, setCodeCooldown] = useState(0);
const [msg, setMsg] = useState('');
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [bindMode, setBindMode] = useState(false);
useEffect(() => {
if (!isWechatEnv()) return;
weixinSdk
.handleOAuthCallback()
.then((result) => {
if (!result) return;
handleWechatLoginResult(result);
})
.catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败'));
}, []);
function handleWechatLoginResult(result: WechatLoginResult) {
if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setMsg('微信授权成功,请绑定手机号完成登录');
return;
}
if (result.accessToken) {
saveSession({
accessToken: result.accessToken,
refreshToken: result.refreshToken ?? '',
deviceKey: result.deviceKey,
phoneVerified: !!result.phoneVerified,
user: result.user as never,
});
navigate('/');
}
}
function ensureAgreed() {
if (!agreed) {
@@ -31,7 +65,7 @@ export default function LoginPage() {
setMsg('');
await request('USER_H5', '/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'USER_LOGIN' }),
body: JSON.stringify({ phone, scene: bindMode ? 'BIND_PHONE' : 'USER_LOGIN' }),
});
setMsg('验证码已发送(Mock: 123456');
setCodeCooldown(60);
@@ -60,6 +94,14 @@ export default function LoginPage() {
setLoading(true);
setMsg('');
try {
if (bindMode && wxSessionKey) {
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
method: 'POST',
body: JSON.stringify({ wxSessionKey, phone, code }),
});
handleWechatLoginResult(data);
return;
}
const data = await request<{
accessToken: string;
refreshToken: string;
@@ -77,9 +119,19 @@ export default function LoginPage() {
}
}
function wechatLogin() {
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('preV1:微信一键授权暂未开放,请使用手机验证码登录');
setMsg('');
try {
if (!isWechatEnv()) {
setMsg('请在微信内打开以使用微信一键授权');
return;
}
const result = await weixinSdk.login();
if (result) handleWechatLoginResult(result);
} catch (e) {
setMsg(e instanceof Error ? e.message : '微信登录失败');
}
}
return (
@@ -97,7 +149,7 @@ export default function LoginPage() {
<main className="login-main">
<div className="login-card">
<h3 className="login-card-title"></h3>
<h3 className="login-card-title">{bindMode ? '绑定手机号' : '手机验证码登录'}</h3>
<div className="login-field">
<span className="login-field-prefix">+86</span>
<input
@@ -138,20 +190,24 @@ export default function LoginPage() {
disabled={loading}
onClick={login}
>
{loading ? '登录中...' : '登录'}
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
</button>
</div>
<div className="login-divider">
<span className="login-divider-line" />
<span className="login-divider-text"></span>
<span className="login-divider-line" />
</div>
{!bindMode && (
<>
<div className="login-divider">
<span className="login-divider-line" />
<span className="login-divider-text"></span>
<span className="login-divider-line" />
</div>
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
<span className="material-symbols-outlined login-wechat-icon">chat</span>
<span></span>
</button>
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
<span className="material-symbols-outlined login-wechat-icon">chat</span>
<span></span>
</button>
</>
)}
</main>
<footer className="login-footer">
+24 -3
View File
@@ -1,14 +1,17 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import type { WechatPayOrderResult } from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { buildOrderConfirmUrl } from '../lib/navigation';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
export default function PayPage() {
const [params] = useSearchParams();
const orderId = params.get('orderId') || '';
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [mockMode, setMockMode] = useState(true);
function goBackConfirm() {
navigate(
@@ -20,10 +23,22 @@ export default function PayPage() {
}),
);
}
async function pay() {
setLoading(true);
try {
await request('USER_H5', `/trade/orders/${orderId}/pay`, { method: 'POST' });
const result = await request<WechatPayOrderResult>('USER_H5', `/trade/orders/${orderId}/pay`, {
method: 'POST',
});
if (result.mode === 'jsapi' && result.prepay) {
setMockMode(false);
await weixinSdk.init();
await weixinSdk.pay(result.prepay);
navigate('/orders?tab=pending_ship');
return;
}
navigate('/orders?tab=pending_ship');
} catch (e) {
alert(e instanceof Error ? e.message : '支付失败');
@@ -39,8 +54,14 @@ export default function PayPage() {
<div className="pay-icon">
<span className="material-symbols-outlined">account_balance_wallet</span>
</div>
<p className="headline-lg text-primary">Mock </p>
<p className="text-muted body-md" style={{ marginTop: 8 }}>preV1 </p>
<p className="headline-lg text-primary">
{mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
</p>
<p className="text-muted body-md" style={{ marginTop: 8 }}>
{mockMode
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
: '请在微信内完成支付'}
</p>
<p className="label-md text-muted" style={{ marginTop: 24 }}> {orderId}</p>
</div>
<div className="page-actions">