完善 H5 鉴权与登录流程

This commit is contained in:
inman
2026-07-24 14:55:13 +08:00
parent 160e82197e
commit c820a56e79
18 changed files with 1130 additions and 82 deletions

View File

@@ -1,6 +1,6 @@
# 万趣微信小程序
这是唯一的用户端前台,基于 Taro + React + TypeScript覆盖探索、搜索、线路详情、收藏、最近浏览、微信登录、手机号授权、我的需求和原生企微“联系我”页。
这是唯一的用户端前台,基于 Taro + React + TypeScript覆盖探索、搜索、线路详情、收藏、最近浏览、微信手机号一键登录、H5 登录态同步、我的需求、退出登录和原生企微“联系我”页。
小程序已声明企微官方插件 `wx104a1a20c3f81ec2`(版本 `1.4.3`)。`pages/contact/index` 内置企业微信后台创建的联系我 `config_id`,直接渲染 `plugin://contactPlugin/cell`H5 中的“企微联系”入口只负责跳转到该原生页。后端不参与联系我插件的配置、同步或回调。
@@ -30,6 +30,8 @@ TARO_APP_API_BASE_URL="https://biz.wanderqtrip.com/api"
npm run build:miniapp
```
生产 API 需要配置 `WECHAT_APP_ID``WECHAT_APP_SECRET``DATABASE_URL``JWT_SECRET`,并将 API 域名与 OSS/CDN 域名加入微信小程序的 `request``downloadFile` 合法域名。联系我插件的 `config_id` 已随小程序代码发布,不需要后端企微密钥。图片统一使用 API 返回的 OSS WebP 公共 URL不打包本地图片素材
小程序H5 登录通过 `pages/login/index` 原生手机号授权页完成;授权后由首页生成一次性 H5 票据H5 兑换为 HttpOnly Cookie。直接打开 H5 且未携带票据时保持游客浏览
生产 API 需要配置 `WECHAT_APP_ID``WECHAT_APP_SECRET``DATABASE_URL``JWT_SECRET` 和允许携带 H5 登录 Cookie 的 `CORS_ORIGINS`(例如 `https://wanderq.nianxx.com`),并将 API 域名与 OSS/CDN 域名加入微信小程序的 `request``downloadFile` 合法域名。联系我插件的 `config_id` 已随小程序代码发布,不需要后端企微密钥。图片统一使用 API 返回的 OSS WebP 公共 URL不打包本地图片素材。
数据库首次部署执行 `npm run db:migrate`,初始化内容执行 `npm run db:seed`

View File

@@ -1,5 +1,5 @@
export default defineAppConfig({
pages: ["pages/index/index", "pages/contact/index"],
pages: ["pages/index/index", "pages/contact/index", "pages/login/index"],
plugins: {
contactPlugin: {
version: "1.4.3",

View File

@@ -127,6 +127,15 @@ page, view, text, image, input, button, scroll-view, swiper, swiper-item {
.contact-fallback { background: #e8f1e9; }
.contact-back { width: 100%; color: #19766a; background: transparent; border: 1px solid #bcd3c5; border-radius: 999px; font-size: 22px; }
.login-page { display: grid; align-content: start; gap: 22px; min-height: 100vh; padding: 64px 32px 100px; background: #f6f4ee; }
.login-hero { display: grid; gap: 14px; padding: 16px 4px 30px; }
.login-title { display: block; color: #182522; font-size: 42px; font-weight: 800; line-height: 1.3; }
.login-note { display: block; color: #718079; font-size: 22px; line-height: 1.6; }
.phone-login-button { width: 100%; color: #fff; background: #19766a; border-radius: 999px; font-size: 25px; }
.phone-login-button[disabled] { opacity: .6; }
.login-message { display: block; color: #b15842; text-align: center; font-size: 20px; line-height: 1.5; }
.login-privacy { display: block; color: #87938c; text-align: center; font-size: 17px; line-height: 1.5; }
@media (max-width: 680px) {
.product-grid { grid-template-columns: 1fr; }
.hero-copy > text:nth-child(2), .hero-fallback > text:nth-of-type(1) { font-size: 34px; }

View File

@@ -1,9 +1,40 @@
import { WebView } from "@tarojs/components";
import { useState } from "react";
import { useDidShow } from "@tarojs/taro";
import { createH5Ticket, getToken } from "../../services/api";
const H5_URL = String(process.env.TARO_APP_H5_URL ?? "https://wanderq.nianxx.com");
const H5_CACHE_VERSION = "ui-20260723-tab-seam-v7";
const H5_SRC = `${H5_URL}${H5_URL.includes("?") ? "&" : "?"}v=${H5_CACHE_VERSION}`;
const H5_CACHE_VERSION = "auth-20260724-v1";
function buildH5Src(ticket?: string) {
const separator = H5_URL.includes("?") ? "&" : "?";
return H5_URL
+ separator
+ "v="
+ encodeURIComponent(H5_CACHE_VERSION)
+ (ticket ? "&h5_ticket=" + encodeURIComponent(ticket) : "");
}
export default function Index() {
return <WebView src={H5_SRC} />;
const [h5Src, setH5Src] = useState(buildH5Src);
const [initialized, setInitialized] = useState(false);
const [lastToken, setLastToken] = useState<string | null>(null);
useDidShow(() => {
const token = getToken();
if (initialized && token === lastToken) return;
setInitialized(true);
setLastToken(token);
if (!token) {
setH5Src(buildH5Src());
return;
}
createH5Ticket()
.then(({ ticket }) => setH5Src(buildH5Src(ticket)))
.catch(() => setH5Src(buildH5Src()));
});
return <WebView src={h5Src} />;
}

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: "微信手机号登录",
backgroundTextStyle: "light",
});

View File

@@ -0,0 +1,63 @@
import { Button, Text, View } from "@tarojs/components";
import { useState } from "react";
import Taro, { useLoad } from "@tarojs/taro";
import { clearToken, loginMiniProgramWithPhone } from "../../services/api";
type LoginPageParams = {
mode?: string;
};
export default function LoginPage() {
const [mode, setMode] = useState<"login" | "logout">("login");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
useLoad<LoginPageParams>((params) => {
if (params.mode === "logout") {
clearToken();
setMode("logout");
void Taro.navigateBack();
}
});
const handlePhoneNumber = async (event: { detail: { code?: string; errMsg?: string } }) => {
const phoneCode = event.detail.code;
if (!phoneCode) {
setMessage(event.detail.errMsg?.includes("deny") ? "你取消了手机号授权" : "未获取到手机号授权,请重试");
return;
}
setLoading(true);
setMessage("");
try {
await loginMiniProgramWithPhone(phoneCode);
await Taro.navigateBack();
} catch (error) {
setMessage(error instanceof Error ? error.message : "登录失败,请稍后重试");
} finally {
setLoading(false);
}
};
if (mode === "logout") return null;
return (
<View className="login-page">
<View className="login-hero">
<Text className="eyebrow"></Text>
<Text className="login-title"> H5 </Text>
<Text className="login-note"></Text>
</View>
<Button
className="phone-login-button"
openType="getPhoneNumber"
onGetPhoneNumber={handlePhoneNumber}
disabled={loading}
>
{loading ? "登录中..." : "微信手机号一键登录"}
</Button>
{message ? <Text className="login-message">{message}</Text> : null}
<Text className="login-privacy"></Text>
</View>
);
}

View File

@@ -165,6 +165,33 @@ export async function loginMiniProgram() {
return response.user;
}
export async function loginMiniProgramWithPhone(phoneCode: string) {
const result = await Taro.login();
const response = await request<{
token: string;
h5Ticket: string;
h5TicketExpiresAt: string;
linkedLeadCount: number;
user: UserProfile;
}>("/api/public/auth/wechat-phone-login", {
method: "POST",
data: {
loginCode: result.code,
phoneCode,
source: "mini_program",
},
});
setToken(response.token);
return response;
}
export async function createH5Ticket() {
return request<{ ticket: string; expiresAt: string }>("/api/public/auth/h5-ticket", {
method: "POST",
data: {},
});
}
export async function fetchContent() {
const [site, products] = await Promise.all([
request<SiteConfig>("/api/public/site-config"),