微信SDK接通
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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('请在开发者选项中输入核销码');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user