简化H5应用用户授权
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,7 +27,7 @@ uploads/
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
.trae/
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -1,87 +1,97 @@
|
|||||||
import uuid
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import os
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pymongo.errors import DuplicateKeyError
|
from jose import jwt
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import WechatLoginRequest, PhoneLoginRequest, TokenResponse
|
|
||||||
from app.middleware.auth import create_access_token
|
|
||||||
from app.services.wechat import code_to_session
|
from app.services.wechat import code_to_session
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
DEV_MODE = os.environ.get("DEV_MODE", "").lower() in ("1", "true", "yes")
|
||||||
@router.post("/demo-login", response_model=TokenResponse)
|
# 固定测试账号 openid(dev 模式下使用)
|
||||||
async def demo_login():
|
DEV_OPENID = "dev_test_account_001"
|
||||||
"""Web 体验用的演示登录,每次创建独立的临时用户"""
|
|
||||||
demo_openid = f"demo_{uuid.uuid4().hex[:12]}"
|
|
||||||
user = User(
|
|
||||||
openid=demo_openid,
|
|
||||||
name="体验用户",
|
|
||||||
)
|
|
||||||
await user.insert()
|
|
||||||
|
|
||||||
token = create_access_token(str(user.id))
|
|
||||||
return TokenResponse(
|
|
||||||
access_token=token,
|
|
||||||
user_id=str(user.id),
|
|
||||||
is_new_user=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/wechat-login", response_model=TokenResponse)
|
class ExchangeCodeRequest(BaseModel):
|
||||||
async def wechat_login(req: WechatLoginRequest):
|
code: str
|
||||||
"""微信小程序登录"""
|
mobile: str | None = None
|
||||||
try:
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
user_id: str
|
||||||
|
openid: str
|
||||||
|
mobile: str
|
||||||
|
|
||||||
|
|
||||||
|
def _build_token(user: User) -> str:
|
||||||
|
"""生成自包含 JWT:sub + openid + mobile"""
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(days=30)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user.id),
|
||||||
|
"openid": user.openid,
|
||||||
|
"mobile": user.phone or "",
|
||||||
|
"exp": expire,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/exchange-code")
|
||||||
|
async def exchange_code(req: ExchangeCodeRequest) -> TokenResponse:
|
||||||
|
"""小程序登录:code → openid → 创建/查找 User → 返回自包含 JWT"""
|
||||||
wechat_data = await code_to_session(req.code)
|
wechat_data = await code_to_session(req.code)
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
openid = wechat_data["openid"]
|
openid = wechat_data["openid"]
|
||||||
user = await User.find_one(User.openid == openid)
|
|
||||||
is_new = user is None
|
|
||||||
|
|
||||||
|
user = await User.find_one(User.openid == openid)
|
||||||
if not user:
|
if not user:
|
||||||
user = User(
|
user = User(
|
||||||
openid=openid,
|
openid=openid,
|
||||||
session_key=wechat_data.get("session_key"),
|
session_key=wechat_data.get("session_key"),
|
||||||
union_id=wechat_data.get("unionid"),
|
union_id=wechat_data.get("unionid"),
|
||||||
|
phone=req.mobile or None,
|
||||||
)
|
)
|
||||||
await user.insert()
|
await user.insert()
|
||||||
else:
|
else:
|
||||||
|
# 更新 session_key + 手机号
|
||||||
user.session_key = wechat_data.get("session_key")
|
user.session_key = wechat_data.get("session_key")
|
||||||
|
if req.mobile:
|
||||||
|
user.phone = req.mobile
|
||||||
await user.save()
|
await user.save()
|
||||||
|
|
||||||
token = create_access_token(str(user.id))
|
token = _build_token(user)
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=token,
|
token=token,
|
||||||
user_id=str(user.id),
|
user_id=str(user.id),
|
||||||
is_new_user=is_new,
|
openid=user.openid,
|
||||||
|
mobile=user.phone or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/phone-login", response_model=TokenResponse)
|
@router.post("/dev-login")
|
||||||
async def phone_login(req: PhoneLoginRequest):
|
async def dev_login() -> TokenResponse:
|
||||||
"""手机号登录(简化版,生产环境需接入短信验证)"""
|
"""开发环境:使用固定测试账号,不依赖微信"""
|
||||||
# TODO: 接入真实短信验证服务
|
if not DEV_MODE:
|
||||||
if req.sms_code != "123456":
|
raise HTTPException(status_code=403, detail="仅开发环境可用")
|
||||||
raise HTTPException(status_code=400, detail="验证码错误")
|
|
||||||
|
|
||||||
user = await User.find_one(User.phone == req.phone)
|
|
||||||
is_new = user is None
|
|
||||||
|
|
||||||
|
user = await User.find_one(User.openid == DEV_OPENID)
|
||||||
if not user:
|
if not user:
|
||||||
try:
|
user = User(
|
||||||
user = User(openid=f"phone_{req.phone}", phone=req.phone)
|
openid=DEV_OPENID,
|
||||||
await user.insert()
|
name="开发测试用户",
|
||||||
except DuplicateKeyError:
|
phone="13800000000",
|
||||||
# 并发竞态:另一个请求已创建该用户,回退查找
|
)
|
||||||
user = await User.find_one(User.phone == req.phone)
|
await user.insert()
|
||||||
is_new = False
|
|
||||||
|
token = _build_token(user)
|
||||||
token = create_access_token(str(user.id))
|
return TokenResponse(
|
||||||
return TokenResponse(
|
token=token,
|
||||||
access_token=token,
|
user_id=str(user.id),
|
||||||
user_id=str(user.id),
|
openid=user.openid,
|
||||||
is_new_user=is_new,
|
mobile=user.phone or "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class WechatLoginRequest(BaseModel):
|
class ExchangeCodeRequest(BaseModel):
|
||||||
code: str # wx.login() 获取的 code
|
code: str
|
||||||
|
mobile: str | None = None
|
||||||
|
|
||||||
class PhoneLoginRequest(BaseModel):
|
|
||||||
phone: str
|
|
||||||
sms_code: str
|
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
token: str
|
||||||
token_type: str = "bearer"
|
|
||||||
user_id: str
|
user_id: str
|
||||||
is_new_user: bool = False
|
openid: str
|
||||||
|
mobile: str
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ init_config() {
|
|||||||
APP_PORT=${APP_PORT:-${PORT:-8000}}
|
APP_PORT=${APP_PORT:-${PORT:-8000}}
|
||||||
APP_WORKERS=${APP_WORKERS:-1}
|
APP_WORKERS=${APP_WORKERS:-1}
|
||||||
APP_LOG_LEVEL=${APP_LOG_LEVEL:-"info"}
|
APP_LOG_LEVEL=${APP_LOG_LEVEL:-"info"}
|
||||||
|
DEV_MODE=${DEV_MODE:-""}
|
||||||
|
|
||||||
# PID 文件路径
|
# PID 文件路径
|
||||||
PID_FILE="${APP_DIR}/.${APP_NAME}.pid"
|
PID_FILE="${APP_DIR}/.${APP_NAME}.pid"
|
||||||
@@ -82,6 +83,7 @@ init_config() {
|
|||||||
log_info "服务器端口: $APP_PORT"
|
log_info "服务器端口: $APP_PORT"
|
||||||
log_info "工作进程数: $APP_WORKERS"
|
log_info "工作进程数: $APP_WORKERS"
|
||||||
log_info "日志级别: $APP_LOG_LEVEL"
|
log_info "日志级别: $APP_LOG_LEVEL"
|
||||||
|
log_info "开发模式: $([ -n \"$DEV_MODE\" ] && echo \"已启用\" || echo \"已禁用\")"
|
||||||
log_info "日志文件: $LOG_FILE"
|
log_info "日志文件: $LOG_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ const DEFAULT_PROFILE: UserProfile = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = "pregnancy_user_profile";
|
const STORAGE_KEY = "pregnancy_user_profile";
|
||||||
|
const USER_BASIC_KEY = "user_basic";
|
||||||
|
|
||||||
|
export interface BasicUserInfo {
|
||||||
|
id: string;
|
||||||
|
openid: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
function loadProfile(): UserProfile {
|
function loadProfile(): UserProfile {
|
||||||
try {
|
try {
|
||||||
@@ -169,6 +176,7 @@ interface UserContextValue {
|
|||||||
updateUser: (data: Partial<UserProfile>) => void;
|
updateUser: (data: Partial<UserProfile>) => void;
|
||||||
updateUserRemote: (data: Partial<UserProfile>) => Promise<void>;
|
updateUserRemote: (data: Partial<UserProfile>) => Promise<void>;
|
||||||
setToken: (token: string) => void;
|
setToken: (token: string) => void;
|
||||||
|
setTokenAndUser: (token: string, info: BasicUserInfo) => void;
|
||||||
fetchProfile: () => Promise<void>;
|
fetchProfile: () => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
@@ -224,17 +232,28 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [updateUser]);
|
}, [updateUser]);
|
||||||
|
|
||||||
const fetchProfile = useCallback(async () => {
|
const fetchProfile = useCallback(async (): Promise<UserProfileDTO | null> => {
|
||||||
try {
|
try {
|
||||||
const dto = await getUserProfile();
|
const dto = await getUserProfile();
|
||||||
const profile = mapDtoToProfile(dto);
|
const profile = mapDtoToProfile(dto);
|
||||||
setUser(profile);
|
setUser(profile);
|
||||||
saveProfile(profile);
|
saveProfile(profile);
|
||||||
|
return dto;
|
||||||
} catch {
|
} catch {
|
||||||
// Token 失效会被 api.ts 自动处理(跳转登录页)
|
// 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(() => {
|
const logout = useCallback(() => {
|
||||||
clearToken();
|
clearToken();
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
@@ -250,7 +269,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ user, token, updateUser, updateUserRemote, setToken, fetchProfile, logout }}>
|
<UserContext.Provider value={{ user, token, updateUser, updateUserRemote, setToken, setTokenAndUser, fetchProfile, logout }}>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { Heart, Loader2, Phone, ArrowRight, ChevronLeft } from "lucide-react";
|
import { Heart } from "lucide-react";
|
||||||
import { Button } from "../components/ui/button";
|
|
||||||
import { useUser } from "../context/UserContext";
|
import { useUser } from "../context/UserContext";
|
||||||
import { loginByWechat, loginByDemo, loginByPhone, getToken } from "../services/api";
|
import { decodeToken, devLogin, getToken, setToken as saveToken } from "../services/api";
|
||||||
|
|
||||||
/** 判断是否在微信小程序 webview 环境 */
|
/** 判断是否在微信小程序 webview 环境 */
|
||||||
function isWechatMiniProgram(): boolean {
|
function isWechatMiniProgram(): boolean {
|
||||||
@@ -12,354 +11,80 @@ function isWechatMiniProgram(): boolean {
|
|||||||
return ua.includes("micromessenger") && ua.includes("miniprogram");
|
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() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setToken, fetchProfile, user } = useUser();
|
const { setTokenAndUser, fetchProfile } = useUser();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
if (getToken() && user.isOnboarded) {
|
// 优先使用 URL 中的 token(小程序传参)
|
||||||
navigate("/home", { replace: true });
|
const params = new URLSearchParams(window.location.search);
|
||||||
}
|
const tokenFromUrl = params.get('token');
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 倒计时
|
if (tokenFromUrl) {
|
||||||
useEffect(() => {
|
// token 变了就更新存储
|
||||||
if (countdown > 0) {
|
const oldToken = getToken();
|
||||||
countdownRef.current = setInterval(() => {
|
if (tokenFromUrl !== oldToken) {
|
||||||
setCountdown((prev) => {
|
const payload = decodeToken(tokenFromUrl);
|
||||||
if (prev <= 1) {
|
setTokenAndUser(tokenFromUrl, {
|
||||||
if (countdownRef.current) clearInterval(countdownRef.current);
|
id: payload.sub,
|
||||||
return 0;
|
openid: payload.openid,
|
||||||
}
|
phone: payload.mobile,
|
||||||
return prev - 1;
|
|
||||||
});
|
});
|
||||||
}, 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 {
|
} else {
|
||||||
// 老用户 → 拉取资料后直接进首页
|
// token 没变,确保 localStorage 有数据
|
||||||
await fetchProfile();
|
saveToken(tokenFromUrl);
|
||||||
navigate("/home", { replace: true });
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[setToken, fetchProfile, navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 小程序环境自动登录
|
// 拉取孕期数据,进入首页或 onboarding
|
||||||
useEffect(() => {
|
fetchProfile().then((dto) => {
|
||||||
if (autoLoginTried) return;
|
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
|
||||||
if (getToken() && user.isOnboarded) return;
|
}).catch(() => {
|
||||||
|
setError("登录已失效,请在小程序重新进入");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小程序环境但没有 token → 通知小程序传递
|
||||||
if (isWechatMiniProgram()) {
|
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 {
|
try {
|
||||||
if (isWechatMiniProgram()) {
|
(window as any).wx?.miniProgram?.postMessage({ type: "NEED_TOKEN" });
|
||||||
const code = await wxLogin();
|
} catch { /* ignore */ }
|
||||||
const res = await loginByWechat(code);
|
setError("正在获取登录信息...");
|
||||||
await handleLoginSuccess(res);
|
return;
|
||||||
} 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 = () => {
|
devLogin().then((res) => {
|
||||||
if (phoneNumber.length !== 11) {
|
setTokenAndUser(res.token, {
|
||||||
setError("请输入正确的11位手机号");
|
id: res.user_id,
|
||||||
return;
|
openid: res.openid,
|
||||||
}
|
phone: res.mobile,
|
||||||
setError("");
|
});
|
||||||
setSmsSent(true);
|
fetchProfile().then((dto) => {
|
||||||
setCountdown(60);
|
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
|
||||||
};
|
});
|
||||||
|
}).catch((err) => {
|
||||||
// 手机号登录
|
setError(err?.message || "登录失败,请刷新重试");
|
||||||
const handlePhoneLogin = async () => {
|
});
|
||||||
if (phoneNumber.length !== 11) {
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-morandi-50 flex items-center justify-center p-4">
|
<div className="min-h-dvh bg-morandi-50 flex items-center justify-center p-4">
|
||||||
|
<div className="text-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
animate={{ rotate: 360 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
|
||||||
className="w-full"
|
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"
|
||||||
>
|
>
|
||||||
<div className="text-center mb-8">
|
<Heart className="w-8 h-8 text-white fill-white" />
|
||||||
<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>
|
</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">
|
{error ? (
|
||||||
孕育无忧
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">您的专属孕期健康助手</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
className="bg-white rounded-3xl shadow-md p-8"
|
|
||||||
>
|
|
||||||
<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() ? (
|
|
||||||
"微信一键授权登录"
|
|
||||||
) : (
|
) : (
|
||||||
"游客体验"
|
<p className="text-sm text-gray-500">加载中...</p>
|
||||||
)}
|
|
||||||
</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>
|
</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>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,14 @@ import { getToken } from "./services/api";
|
|||||||
/** 路由守卫:未登录跳转登录页 */
|
/** 路由守卫:未登录跳转登录页 */
|
||||||
function ProtectedRoute() {
|
function ProtectedRoute() {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const hasProfile = !!localStorage.getItem("pregnancy_user_profile");
|
const urlToken = new URLSearchParams(window.location.search).get('token');
|
||||||
if (!token && !hasProfile) {
|
|
||||||
|
// 来自小程序的 token → 让 LoginPage 处理
|
||||||
|
if (urlToken) {
|
||||||
|
return <Navigate to={`/?token=${encodeURIComponent(urlToken)}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
|
|||||||
@@ -54,31 +54,21 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
|
|||||||
|
|
||||||
// ─── Auth ───
|
// ─── Auth ───
|
||||||
|
|
||||||
interface LoginResponse {
|
export interface TokenPayload {
|
||||||
access_token: string;
|
sub: string; // user_id
|
||||||
token_type: string;
|
openid: string;
|
||||||
user_id: string;
|
mobile: string;
|
||||||
is_new_user: boolean;
|
exp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginByPhone(phone: string, smsCode: string): Promise<LoginResponse> {
|
/** 解析 JWT payload(base64 解码,不验证签名) */
|
||||||
return fetchApi<LoginResponse>("/auth/phone-login", {
|
export function decodeToken(token: string): TokenPayload {
|
||||||
method: "POST",
|
return JSON.parse(atob(token.split('.')[1]));
|
||||||
body: JSON.stringify({ phone, sms_code: smsCode }),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginByWechat(code: string): Promise<LoginResponse> {
|
/** 开发环境:获取测试账号 token */
|
||||||
return fetchApi<LoginResponse>("/auth/wechat-login", {
|
export async function devLogin(): Promise<{ token: string; user_id: string; openid: string; mobile: string }> {
|
||||||
method: "POST",
|
return fetchApi("/auth/dev-login", { method: "POST" });
|
||||||
body: JSON.stringify({ code }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginByDemo(): Promise<LoginResponse> {
|
|
||||||
return fetchApi<LoginResponse>("/auth/demo-login", {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── User Profile ───
|
// ─── User Profile ───
|
||||||
|
|||||||
Reference in New Issue
Block a user