微信小程序登录页面调整
This commit is contained in:
@@ -21,6 +21,27 @@ pnpm --filter @dukang/mini-user dev:weapp
|
|||||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 默认 `https://dkapi.runxian.top`;可用环境变量 `VITE_API_TARGET` 覆盖 |
|
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 默认 `https://dkapi.runxian.top`;可用环境变量 `VITE_API_TARGET` 覆盖 |
|
||||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||||
|
|
||||||
|
### 微信登录 `invalid code`
|
||||||
|
|
||||||
|
`wx.login` 的 code 只能由**与小程序 appid 匹配**的后端 `jscode2session` 兑换。
|
||||||
|
|
||||||
|
| 项 | 当前值 |
|
||||||
|
|----|--------|
|
||||||
|
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
||||||
|
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
||||||
|
|
||||||
|
**本地联调(不接真实微信)**:API 指到本机且开启 Mock:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 终端 1
|
||||||
|
pnpm dev:api # server/.env 保持 MOCK_WECHAT=true
|
||||||
|
|
||||||
|
# 终端 2
|
||||||
|
$env:VITE_API_TARGET="http://localhost:3000"; pnpm dev:mini-user:weapp
|
||||||
|
```
|
||||||
|
|
||||||
|
**连远程 API**:在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret,并部署含 `code2Session` 小程序凭证逻辑的后端。
|
||||||
|
|
||||||
## 页面结构(18 页)
|
## 页面结构(18 页)
|
||||||
|
|
||||||
**Tab**:首页 / 门店 / 好客权益 / 我的
|
**Tab**:首页 / 门店 / 好客权益 / 我的
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
|
||||||
|
type WechatLoginButtonProps = {
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function WechatIcon() {
|
||||||
|
return (
|
||||||
|
<View className="wechat-login-icon" aria-hidden>
|
||||||
|
<View className="wechat-login-icon__big" />
|
||||||
|
<View className="wechat-login-icon__small" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 微信授权一键登录按钮(对齐 h5-user login-wechat-btn) */
|
||||||
|
export default function WechatLoginButton({
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
onClick,
|
||||||
|
}: WechatLoginButtonProps) {
|
||||||
|
const inactive = loading || disabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
|
||||||
|
onClick={inactive ? undefined : onClick}
|
||||||
|
>
|
||||||
|
<WechatIcon />
|
||||||
|
<Text className="login-wechat-btn__text">
|
||||||
|
{loading ? '授权中...' : '微信一键授权'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type WechatLoginResult,
|
type WechatLoginResult,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
|
import WechatLoginButton from '../../components/WechatLoginButton';
|
||||||
import { finishLoginNavigate } from '../../lib/auth-nav';
|
import { finishLoginNavigate } from '../../lib/auth-nav';
|
||||||
import { request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
import { request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||||
import { loginWithWechat } from '../../lib/wechat-auth';
|
import { loginWithWechat } from '../../lib/wechat-auth';
|
||||||
@@ -154,7 +155,11 @@ export default function LoginPage() {
|
|||||||
const result = await loginWithWechat();
|
const result = await loginWithWechat();
|
||||||
handleWechatLoginResult(result);
|
handleWechatLoginResult(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '微信登录失败');
|
const raw = e instanceof Error ? e.message : '微信登录失败';
|
||||||
|
const hint = /invalid code/i.test(raw)
|
||||||
|
? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT'
|
||||||
|
: raw;
|
||||||
|
setMsg(hint);
|
||||||
} finally {
|
} finally {
|
||||||
setWxLoading(false);
|
setWxLoading(false);
|
||||||
}
|
}
|
||||||
@@ -162,6 +167,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const displayMsg = msg || sentHint;
|
const displayMsg = msg || sentHint;
|
||||||
const codeDisabled = cooldown > 0 || sending;
|
const codeDisabled = cooldown > 0 || sending;
|
||||||
|
const showWechatLogin =
|
||||||
|
!bindMode && (process.env.TARO_ENV === 'weapp' || wxAuthorize);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="plain" className="login-page">
|
<PageShell variant="plain" className="login-page">
|
||||||
@@ -233,22 +240,17 @@ export default function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!bindMode && wxAuthorize ? (
|
{showWechatLogin ? (
|
||||||
<>
|
<>
|
||||||
<View className="login-divider">
|
<View className="login-divider">
|
||||||
<View className="login-divider-line" />
|
<View className="login-divider-line" />
|
||||||
<Text className="login-divider-text">或者</Text>
|
<Text className="login-divider-text">或者</Text>
|
||||||
<View className="login-divider-line" />
|
<View className="login-divider-line" />
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<WechatLoginButton
|
||||||
className="login-wechat-btn"
|
|
||||||
loading={wxLoading}
|
loading={wxLoading}
|
||||||
disabled={wxLoading}
|
|
||||||
onClick={() => void wechatLogin()}
|
onClick={() => void wechatLogin()}
|
||||||
>
|
/>
|
||||||
<Text className="login-wechat-icon">微</Text>
|
|
||||||
<Text>微信一键授权</Text>
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -221,20 +221,52 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1);
|
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-wechat-btn::after {
|
.login-wechat-btn:active {
|
||||||
border: none;
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-wechat-btn[disabled] {
|
.login-wechat-btn--disabled {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-wechat-icon {
|
.login-wechat-btn__text {
|
||||||
font-size: 22px;
|
color: #fff;
|
||||||
line-height: 1;
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-login-icon {
|
||||||
|
position: relative;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-login-icon__big,
|
||||||
|
.wechat-login-icon__small {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-login-icon__big {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
left: 0;
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-login-icon__small {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-footer {
|
.login-footer {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface AppConfig {
|
|||||||
wechatAuthEnabled: boolean;
|
wechatAuthEnabled: boolean;
|
||||||
wechatPayEnabled: boolean;
|
wechatPayEnabled: boolean;
|
||||||
wxAppId: string;
|
wxAppId: string;
|
||||||
|
/** 小程序 AppID(code2session 与小程序支付;未配置时回退 wxAppId) */
|
||||||
|
wxMiniAppId: string;
|
||||||
wxMchId: string;
|
wxMchId: string;
|
||||||
/** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
|
/** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
|
||||||
ossEnabled: boolean;
|
ossEnabled: boolean;
|
||||||
@@ -50,6 +52,7 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
|||||||
wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true',
|
wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true',
|
||||||
wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false',
|
wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false',
|
||||||
wxAppId: e.WX_APP_ID ?? '',
|
wxAppId: e.WX_APP_ID ?? '',
|
||||||
|
wxMiniAppId: e.WX_MINI_APP_ID ?? e.WX_APP_ID ?? '',
|
||||||
wxMchId: e.WX_MCH_ID ?? '',
|
wxMchId: e.WX_MCH_ID ?? '',
|
||||||
ossEnabled: e.OSS_ENABLED === 'true',
|
ossEnabled: e.OSS_ENABLED === 'true',
|
||||||
aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '',
|
aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '',
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ TRUST_PROXY=true
|
|||||||
# 微信服务号「网页授权域名」「JS 接口安全域名」均配置 user.runxian.top(仅 1 个名额)。
|
# 微信服务号「网页授权域名」「JS 接口安全域名」均配置 user.runxian.top(仅 1 个名额)。
|
||||||
WX_APP_ID=
|
WX_APP_ID=
|
||||||
WX_APP_SECRET=
|
WX_APP_SECRET=
|
||||||
|
# 小程序(code2session / 小程序支付 appid;须与 apps/mini-user/project.config.json 的 appid 一致)
|
||||||
|
# 未配置时回退 WX_APP_ID,若与小程序 appid 不同会导致 invalid code
|
||||||
|
WX_MINI_APP_ID=
|
||||||
|
WX_MINI_APP_SECRET=
|
||||||
WECHAT_AUTH_ENABLED=false
|
WECHAT_AUTH_ENABLED=false
|
||||||
WECHAT_PAY_ENABLED=false
|
WECHAT_PAY_ENABLED=false
|
||||||
WX_MCH_ID=
|
WX_MCH_ID=
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
private readonly config = loadAppConfig();
|
private readonly config = loadAppConfig();
|
||||||
private readonly appId = process.env.WX_APP_ID ?? '';
|
private readonly appId = process.env.WX_APP_ID ?? '';
|
||||||
private readonly appSecret = process.env.WX_APP_SECRET ?? '';
|
private readonly appSecret = process.env.WX_APP_SECRET ?? '';
|
||||||
|
/** 小程序独立凭证;未配置时回退公众号/H5 的 WX_APP_ID(须与开发者工具 appid 一致) */
|
||||||
|
private readonly miniAppId = (process.env.WX_MINI_APP_ID ?? this.appId).trim();
|
||||||
|
private readonly miniAppSecret = (process.env.WX_MINI_APP_SECRET ?? this.appSecret).trim();
|
||||||
private readonly mchId = process.env.WX_MCH_ID ?? '';
|
private readonly mchId = process.env.WX_MCH_ID ?? '';
|
||||||
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
|
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
|
||||||
private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
|
private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
|
||||||
@@ -70,14 +73,21 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async code2Session(code: string, actorRef?: WechatActorRef): Promise<WechatCodeSession> {
|
async code2Session(code: string, actorRef?: WechatActorRef): Promise<WechatCodeSession> {
|
||||||
|
const appId = this.miniAppId;
|
||||||
|
const appSecret = this.miniAppSecret;
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'小程序微信登录未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET(或与 project.config.json appid 一致)',
|
||||||
|
);
|
||||||
|
}
|
||||||
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||||
url.searchParams.set('appid', this.appId);
|
url.searchParams.set('appid', appId);
|
||||||
url.searchParams.set('secret', '***');
|
url.searchParams.set('secret', '***');
|
||||||
url.searchParams.set('js_code', code);
|
url.searchParams.set('js_code', code);
|
||||||
url.searchParams.set('grant_type', 'authorization_code');
|
url.searchParams.set('grant_type', 'authorization_code');
|
||||||
const apiUrl = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
const apiUrl = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||||
apiUrl.searchParams.set('appid', this.appId);
|
apiUrl.searchParams.set('appid', appId);
|
||||||
apiUrl.searchParams.set('secret', this.appSecret);
|
apiUrl.searchParams.set('secret', appSecret);
|
||||||
apiUrl.searchParams.set('js_code', code);
|
apiUrl.searchParams.set('js_code', code);
|
||||||
apiUrl.searchParams.set('grant_type', 'authorization_code');
|
apiUrl.searchParams.set('grant_type', 'authorization_code');
|
||||||
const data = await this.fetchJson<{
|
const data = await this.fetchJson<{
|
||||||
@@ -91,7 +101,7 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
await logWechatAuth(this.prisma, {
|
await logWechatAuth(this.prisma, {
|
||||||
scene: 'LOGIN',
|
scene: 'LOGIN',
|
||||||
requestUrl: url.toString(),
|
requestUrl: url.toString(),
|
||||||
requestBody: { grant_type: 'authorization_code', platform: 'mini' },
|
requestBody: { grant_type: 'authorization_code', platform: 'mini', appId },
|
||||||
responseBody: ok
|
responseBody: ok
|
||||||
? { openid: data.openid, unionid: data.unionid }
|
? { openid: data.openid, unionid: data.unionid }
|
||||||
: { errcode: data.errcode, errmsg: data.errmsg },
|
: { errcode: data.errcode, errmsg: data.errmsg },
|
||||||
@@ -101,7 +111,11 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
actorRef,
|
actorRef,
|
||||||
});
|
});
|
||||||
if (!data.openid) {
|
if (!data.openid) {
|
||||||
throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败');
|
const invalidCode = data.errcode === 40029 || /invalid code/i.test(data.errmsg ?? '');
|
||||||
|
const hint = invalidCode
|
||||||
|
? `(后端 appid=${appId},请确认 WX_MINI_APP_ID/SECRET 与小程序 project.config.json 一致;本地可 MOCK_WECHAT=true)`
|
||||||
|
: '';
|
||||||
|
throw new InternalServerErrorException((data.errmsg || '微信 code2session 失败') + hint);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
openId: data.openid,
|
openId: data.openid,
|
||||||
@@ -284,7 +298,7 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL');
|
throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL');
|
||||||
}
|
}
|
||||||
const body = {
|
const body = {
|
||||||
appid: this.appId,
|
appid: this.miniAppId || this.appId,
|
||||||
mchid: this.mchId,
|
mchid: this.mchId,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
out_trade_no: params.orderNo,
|
out_trade_no: params.orderNo,
|
||||||
|
|||||||
Reference in New Issue
Block a user