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