完善 H5 鉴权与登录流程
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
4
apps/miniprogram/src/pages/login/index.config.ts
Normal file
4
apps/miniprogram/src/pages/login/index.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: "微信手机号登录",
|
||||
backgroundTextStyle: "light",
|
||||
});
|
||||
63
apps/miniprogram/src/pages/login/index.tsx
Normal file
63
apps/miniprogram/src/pages/login/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user