简化H5应用用户授权

This commit is contained in:
2026-05-23 01:59:52 +08:00
parent 53128a6f02
commit 08df1825a7
8 changed files with 168 additions and 423 deletions

View File

@@ -51,6 +51,13 @@ const DEFAULT_PROFILE: UserProfile = {
};
const STORAGE_KEY = "pregnancy_user_profile";
const USER_BASIC_KEY = "user_basic";
export interface BasicUserInfo {
id: string;
openid: string;
phone: string;
}
function loadProfile(): UserProfile {
try {
@@ -169,6 +176,7 @@ interface UserContextValue {
updateUser: (data: Partial<UserProfile>) => void;
updateUserRemote: (data: Partial<UserProfile>) => Promise<void>;
setToken: (token: string) => void;
setTokenAndUser: (token: string, info: BasicUserInfo) => void;
fetchProfile: () => Promise<void>;
logout: () => void;
}
@@ -224,17 +232,28 @@ export function UserProvider({ children }: { children: ReactNode }) {
}
}, [updateUser]);
const fetchProfile = useCallback(async () => {
const fetchProfile = useCallback(async (): Promise<UserProfileDTO | null> => {
try {
const dto = await getUserProfile();
const profile = mapDtoToProfile(dto);
setUser(profile);
saveProfile(profile);
return dto;
} catch {
// Token 失效会被 api.ts 自动处理(跳转登录页)
return null;
}
}, []);
const setTokenAndUser = useCallback((newToken: string, info: BasicUserInfo) => {
// 清理前一个用户的本地数据
localStorage.removeItem(STORAGE_KEY);
setUser(DEFAULT_PROFILE);
saveToken(newToken);
localStorage.setItem(USER_BASIC_KEY, JSON.stringify(info));
setTokenState(newToken);
}, []);
const logout = useCallback(() => {
clearToken();
localStorage.removeItem(STORAGE_KEY);
@@ -250,7 +269,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<UserContext.Provider value={{ user, token, updateUser, updateUserRemote, setToken, fetchProfile, logout }}>
<UserContext.Provider value={{ user, token, updateUser, updateUserRemote, setToken, setTokenAndUser, fetchProfile, logout }}>
{children}
</UserContext.Provider>
);

View File

@@ -1,10 +1,9 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { motion, AnimatePresence } from "motion/react";
import { Heart, Loader2, Phone, ArrowRight, ChevronLeft } from "lucide-react";
import { Button } from "../components/ui/button";
import { motion } from "motion/react";
import { Heart } from "lucide-react";
import { useUser } from "../context/UserContext";
import { loginByWechat, loginByDemo, loginByPhone, getToken } from "../services/api";
import { decodeToken, devLogin, getToken, setToken as saveToken } from "../services/api";
/** 判断是否在微信小程序 webview 环境 */
function isWechatMiniProgram(): boolean {
@@ -12,354 +11,80 @@ function isWechatMiniProgram(): boolean {
return ua.includes("micromessenger") && ua.includes("miniprogram");
}
/** 调用 wx.miniProgram.login 获取 code */
function wxLogin(): Promise<string> {
return new Promise((resolve, reject) => {
const wx = (window as any).wx;
if (!wx?.miniProgram) {
reject(new Error("非小程序环境"));
return;
}
wx.miniProgram.login({
success: (res: any) => {
if (res.code) {
resolve(res.code);
} else {
reject(new Error("获取登录凭证失败"));
}
},
fail: () => reject(new Error("微信登录失败")),
});
});
}
export function LoginPage() {
const navigate = useNavigate();
const { setToken, fetchProfile, user } = useUser();
const [isLoading, setIsLoading] = useState(false);
const { setTokenAndUser, fetchProfile } = useUser();
const [error, setError] = useState("");
const [autoLoginTried, setAutoLoginTried] = useState(false);
// 手机号登录状态
const [showPhoneLogin, setShowPhoneLogin] = useState(false);
const [phoneNumber, setPhoneNumber] = useState("");
const [smsCode, setSmsCode] = useState("");
const [smsSent, setSmsSent] = useState(false);
const [countdown, setCountdown] = useState(0);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 已有 token 且已完成 onboarding,直接跳首页
useEffect(() => {
if (getToken() && user.isOnboarded) {
navigate("/home", { replace: true });
}
}, []);
// 优先使用 URL 中的 token(小程序传参)
const params = new URLSearchParams(window.location.search);
const tokenFromUrl = params.get('token');
// 倒计时
useEffect(() => {
if (countdown > 0) {
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (countdownRef.current) clearInterval(countdownRef.current);
return 0;
}
return prev - 1;
if (tokenFromUrl) {
// token 变了就更新存储
const oldToken = getToken();
if (tokenFromUrl !== oldToken) {
const payload = decodeToken(tokenFromUrl);
setTokenAndUser(tokenFromUrl, {
id: payload.sub,
openid: payload.openid,
phone: payload.mobile,
});
}, 1000);
}
return () => {
if (countdownRef.current) clearInterval(countdownRef.current);
};
}, [countdown > 0]);
const handleLoginSuccess = useCallback(
async (res: { access_token: string; is_new_user: boolean }) => {
setToken(res.access_token);
if (res.is_new_user) {
// 新用户 → 走 onboarding 流程
navigate("/onboarding");
} else {
// 老用户 → 拉取资料后直接进首页
await fetchProfile();
navigate("/home", { replace: true });
// token 没变,确保 localStorage 有数据
saveToken(tokenFromUrl);
}
},
[setToken, fetchProfile, navigate]
);
// 小程序环境自动登录
useEffect(() => {
if (autoLoginTried) return;
if (getToken() && user.isOnboarded) return;
// 拉取孕期数据,进入首页或 onboarding
fetchProfile().then((dto) => {
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
}).catch(() => {
setError("登录已失效,请在小程序重新进入");
});
return;
}
// 小程序环境但没有 token → 通知小程序传递
if (isWechatMiniProgram()) {
setAutoLoginTried(true);
setIsLoading(true);
wxLogin()
.then((code) => loginByWechat(code))
.then(handleLoginSuccess)
.catch((err) => {
setError(err.message || "自动登录失败");
})
.finally(() => setIsLoading(false));
}
}, [autoLoginTried, user.isOnboarded, handleLoginSuccess]);
// 一键体验(demo)
const handleDemoLogin = async () => {
setError("");
setIsLoading(true);
try {
if (isWechatMiniProgram()) {
const code = await wxLogin();
const res = await loginByWechat(code);
await handleLoginSuccess(res);
} else {
const res = await loginByDemo();
await handleLoginSuccess(res);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "登录失败,请重试";
setError(message);
} finally {
setIsLoading(false);
}
};
// 发送验证码
const handleSendSms = () => {
if (phoneNumber.length !== 11) {
setError("请输入正确的11位手机号");
try {
(window as any).wx?.miniProgram?.postMessage({ type: "NEED_TOKEN" });
} catch { /* ignore */ }
setError("正在获取登录信息...");
return;
}
setError("");
setSmsSent(true);
setCountdown(60);
};
// 手机号登录
const handlePhoneLogin = async () => {
if (phoneNumber.length !== 11) {
setError("请输入正确的11位手机号");
return;
}
if (smsCode.length < 4) {
setError("请输入验证码");
return;
}
setError("");
setIsLoading(true);
try {
const res = await loginByPhone(phoneNumber, smsCode);
await handleLoginSuccess(res);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "登录失败,请重试";
setError(message);
} finally {
setIsLoading(false);
}
};
// 浏览器开发环境 → 自动使用测试账号
devLogin().then((res) => {
setTokenAndUser(res.token, {
id: res.user_id,
openid: res.openid,
phone: res.mobile,
});
fetchProfile().then((dto) => {
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
});
}).catch((err) => {
setError(err?.message || "登录失败,请刷新重试");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="min-h-dvh bg-morandi-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full"
>
<div className="text-center mb-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring" }}
className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-morandi-300 to-morandi-400 rounded-full mb-4"
>
<Heart className="w-10 h-10 text-white fill-white" />
</motion.div>
<h1 className="text-3xl font-semibold bg-gradient-to-r from-morandi-400 to-morandi-500 bg-clip-text text-transparent mb-2">
孕育无忧
</h1>
<p className="text-gray-600">您的专属孕期健康助手</p>
</div>
<div className="text-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="bg-white rounded-3xl shadow-md p-8"
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-morandi-300 to-morandi-400 rounded-full mb-4"
>
<AnimatePresence mode="wait">
{!showPhoneLogin ? (
/* ─── 主登录页 ─── */
<motion.div
key="main"
initial={{ opacity: 0, x: 0 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
{/* 错误提示 */}
{error && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-red-500 bg-red-50 px-4 py-2 rounded-xl"
>
{error}
</motion.p>
)}
{/* 手机号登录按钮 */}
<Button
onClick={() => { setError(""); setShowPhoneLogin(true); }}
disabled={isLoading}
className="w-full h-12 rounded-xl bg-gradient-to-r from-morandi-400 to-morandi-500 hover:from-morandi-500 hover:to-morandi-500 text-white font-medium"
>
<Phone className="w-4 h-4 mr-2" />
手机号登录
</Button>
{/* 分隔线 */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-200" />
<span className="text-xs text-gray-400">或</span>
<div className="flex-1 h-px bg-gray-200" />
</div>
{/* 一键体验 */}
<Button
onClick={handleDemoLogin}
disabled={isLoading}
variant="outline"
className="w-full h-12 rounded-xl border-2 border-morandi-200 text-morandi-500 font-medium hover:bg-morandi-50"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
登录中...
</>
) : isWechatMiniProgram() ? (
"微信一键授权登录"
) : (
"游客体验"
)}
</Button>
<p className="text-xs text-gray-400 text-center">
游客模式数据不保留,建议使用手机号登录
</p>
</motion.div>
) : (
/* ─── 手机号登录表单 ─── */
<motion.div
key="phone"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="space-y-4"
>
{/* 返回 */}
<button
onClick={() => { setShowPhoneLogin(false); setError(""); setSmsSent(false); setSmsCode(""); }}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 -ml-1 mb-1"
>
<ChevronLeft className="w-4 h-4" />
返回
</button>
<h2 className="text-lg font-semibold text-gray-800">手机号登录</h2>
<p className="text-sm text-gray-500 -mt-2">登录后数据将绑定到您的手机号</p>
{/* 错误提示 */}
{error && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-red-500 bg-red-50 px-4 py-2 rounded-xl"
>
{error}
</motion.p>
)}
{/* 手机号输入 */}
<div className="space-y-1">
<label className="text-sm text-gray-600 font-medium">手机号</label>
<input
type="tel"
maxLength={11}
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/\D/g, ""))}
placeholder="请输入11位手机号"
className="w-full h-12 px-4 rounded-xl border-2 border-gray-200 focus:border-morandi-400 outline-none transition-colors text-base"
/>
</div>
{/* 验证码输入 */}
<div className="space-y-1">
<label className="text-sm text-gray-600 font-medium">验证码</label>
<div className="flex gap-3">
<input
type="text"
maxLength={6}
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ""))}
placeholder="请输入验证码"
className="flex-1 h-12 px-4 rounded-xl border-2 border-gray-200 focus:border-morandi-400 outline-none transition-colors text-base"
/>
<button
onClick={handleSendSms}
disabled={countdown > 0 || phoneNumber.length !== 11}
className={`px-4 h-12 rounded-xl text-sm font-medium whitespace-nowrap transition-all ${
countdown > 0 || phoneNumber.length !== 11
? "bg-gray-100 text-gray-400"
: "bg-morandi-100 text-morandi-500 hover:bg-morandi-200"
}`}
>
{countdown > 0 ? `${countdown}s` : smsSent ? "重新发送" : "获取验证码"}
</button>
</div>
{smsSent && (
<p className="text-xs text-morandi-400 mt-1">
验证码已发送,请输入 123456
</p>
)}
</div>
{/* 登录按钮 */}
<Button
onClick={handlePhoneLogin}
disabled={isLoading || phoneNumber.length !== 11 || smsCode.length < 4}
className="w-full h-12 rounded-xl bg-gradient-to-r from-morandi-400 to-morandi-500 hover:from-morandi-500 hover:to-morandi-500 text-white font-medium disabled:opacity-50"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
登录中...
</>
) : (
<>
登录
<ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button>
</motion.div>
)}
</AnimatePresence>
<p className="text-xs text-gray-500 text-center mt-6">
登录即表示同意
<a href="#" className="text-morandi-400 hover:underline">
用户协议
</a>
和
<a href="#" className="text-morandi-400 hover:underline">
隐私政策
</a>
</p>
<Heart className="w-8 h-8 text-white fill-white" />
</motion.div>
</motion.div>
{error ? (
<p className="text-sm text-red-500">{error}</p>
) : (
<p className="text-sm text-gray-500">加载中...</p>
)}
</div>
</div>
);
}

View File

@@ -24,8 +24,14 @@ import { getToken } from "./services/api";
/** 路由守卫:未登录跳转登录页 */
function ProtectedRoute() {
const token = getToken();
const hasProfile = !!localStorage.getItem("pregnancy_user_profile");
if (!token && !hasProfile) {
const urlToken = new URLSearchParams(window.location.search).get('token');
// 来自小程序的 token → 让 LoginPage 处理
if (urlToken) {
return <Navigate to={`/?token=${encodeURIComponent(urlToken)}`} replace />;
}
if (!token) {
return <Navigate to="/" replace />;
}
return <Outlet />;

View File

@@ -54,31 +54,21 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
// ─── Auth ───
interface LoginResponse {
access_token: string;
token_type: string;
user_id: string;
is_new_user: boolean;
export interface TokenPayload {
sub: string; // user_id
openid: string;
mobile: string;
exp: number;
}
export async function loginByPhone(phone: string, smsCode: string): Promise<LoginResponse> {
return fetchApi<LoginResponse>("/auth/phone-login", {
method: "POST",
body: JSON.stringify({ phone, sms_code: smsCode }),
});
/** 解析 JWT payload(base64 解码,不验证签名) */
export function decodeToken(token: string): TokenPayload {
return JSON.parse(atob(token.split('.')[1]));
}
export async function loginByWechat(code: string): Promise<LoginResponse> {
return fetchApi<LoginResponse>("/auth/wechat-login", {
method: "POST",
body: JSON.stringify({ code }),
});
}
export async function loginByDemo(): Promise<LoginResponse> {
return fetchApi<LoginResponse>("/auth/demo-login", {
method: "POST",
});
/** 开发环境:获取测试账号 token */
export async function devLogin(): Promise<{ token: string; user_id: string; openid: string; mobile: string }> {
return fetchApi("/auth/dev-login", { method: "POST" });
}
// ─── User Profile ───