微信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-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">