简化H5应用用户授权
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
Reference in New Issue
Block a user