// frontend/js/auth-react.jsx // 全局共享的 React 鉴权与档案同步模块 // NOTE: 不在顶层解构 React hooks,避免与页面脚本冲突 function getMysticGuestId() { let guestId = localStorage.getItem('mystic_guest_id'); if (!guestId) { const randomPart = window.crypto?.randomUUID ? window.crypto.randomUUID().replace(/-/g, '') : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; guestId = `guest_${randomPart}`; localStorage.setItem('mystic_guest_id', guestId); } return guestId; } function getMysticCalculationHeaders(contentType = true) { const headers = {'X-Guest-ID': getMysticGuestId()}; const token = localStorage.getItem('mystic_token'); if (token) headers.Authorization = `Bearer ${token}`; if (contentType) headers['Content-Type'] = 'application/json'; return headers; } async function syncMysticGuestData(token) { const guestId = localStorage.getItem('mystic_guest_id'); if (guestId) { const claimResponse = await fetch('/api/auth/claim-guest', { method: 'POST', headers: {'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`}, body: JSON.stringify({guest_id: guestId}) }); if (!claimResponse.ok) throw new Error('游客测算记录归档失败,请稍后重试'); } const history = JSON.parse(localStorage.getItem('mystic_test_history') || '[]'); if (history.length) { const results = history.map(item => ({ test_key: item.testKey, test_title: item.testTitle, result_name: item.resultName, summary: item.summary, score: Number(item.score) || 0, icon: item.icon || '', completed_at: item.completedAt || item.date || null, })).filter(item => item.test_key && item.test_title && item.result_name && item.summary); if (results.length) { const syncResponse = await fetch('/api/user/quick-tests/sync', { method: 'POST', headers: {'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`}, body: JSON.stringify({results: results.slice(0, 30)}) }); if (!syncResponse.ok) throw new Error('快速测试记录同步失败,请稍后重试'); } } } /** * 核心 Auth Hook * 用于检查登录状态、获取档案数据、处理登录注册登出 */ function useAuth() { const { useState, useEffect } = React; const [user, setUser] = useState(null); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const fetchProfile = async (token) => { try { const res = await fetch('/api/user/profile', { headers: { 'Authorization': `Bearer ${token}` } }); if (res.ok) { const data = await res.json(); setProfile(data.data); localStorage.setItem('mystic_profile', JSON.stringify(data.data)); } } catch (e) { console.error("Profile fetch failed:", e); } }; const checkLogin = async () => { setLoading(true); const token = localStorage.getItem('mystic_token'); if (!token) { setUser(null); setProfile(null); setLoading(false); return; } try { const res = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } }); if (res.ok) { const userData = await res.json(); setUser(userData); await fetchProfile(token); } else { localStorage.removeItem('mystic_token'); setUser(null); } } catch (e) { console.error(e); } finally { setLoading(false); } }; useEffect(() => { // 先尝试从本地读取 const cachedProfile = localStorage.getItem('mystic_profile'); if (cachedProfile) { try { setProfile(JSON.parse(cachedProfile)); } catch (e) {} } checkLogin(); }, []); const logout = async () => { const token = localStorage.getItem('mystic_token'); if (token) { fetch('/api/auth/logout', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); } localStorage.removeItem('mystic_token'); localStorage.removeItem('mystic_profile'); localStorage.removeItem('mystic_guest_id'); setUser(null); setProfile(null); }; const silentUpdateProfile = async (updateData) => { const token = localStorage.getItem('mystic_token'); if (!token || !updateData) return; try { const res = await fetch('/api/user/profile', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(updateData) }); if (res.ok) { await fetchProfile(token); } } catch (e) { console.error("Silent sync failed", e); } }; return { user, profile, loading, checkLogin, logout, silentUpdateProfile }; } /** * 通用精美登录/注册弹窗组件 */ function LoginModal({ isOpen, onClose, onSuccess, initialMode = 'login' }) { const { useState, useEffect } = React; const [mode, setMode] = useState('password'); const [methods, setMethods] = useState({sms_enabled: false, password_enabled: true}); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [nickname, setNickname] = useState(''); const [birthDate, setBirthDate] = useState(''); const [birthTime, setBirthTime] = useState('12:00'); const [gender, setGender] = useState('女'); const [cityName, setCityName] = useState('北京'); const [realName, setRealName] = useState(''); const [mbti, setMbti] = useState(''); const [smsNeedsProfile, setSmsNeedsProfile] = useState(false); const [newPassword, setNewPassword] = useState(''); const [error, setError] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [sendingCode, setSendingCode] = useState(false); const [cooldown, setCooldown] = useState(0); useEffect(() => { if (!isOpen) return; setError(''); setSmsNeedsProfile(false); fetch('/api/auth/methods') .then(res => res.json()) .then(data => { setMethods(data); setMode(initialMode === 'register' ? 'register' : (data.sms_enabled ? 'sms' : 'password')); }) .catch(() => setMode(initialMode === 'register' ? 'register' : 'password')); }, [isOpen, initialMode]); useEffect(() => { if (cooldown <= 0) return; const timer = setInterval(() => setCooldown(value => Math.max(0, value - 1)), 1000); return () => clearInterval(timer); }, [cooldown]); if (!isOpen) return null; const readResponse = async (res) => { const data = await res.json().catch(() => ({})); if (!res.ok) { const detail = Array.isArray(data.detail) ? data.detail.map(item => item.msg).filter(Boolean).join(';') : data.detail; throw new Error(detail || data.message || '请求失败,请稍后再试'); } return data; }; const finishLogin = async (data) => { localStorage.setItem('mystic_token', data.token); await syncMysticGuestData(data.token); if (onSuccess) await onSuccess(); onClose(); }; const registrationProfile = () => ({ nickname: nickname.trim(), real_name: realName.trim() || null, birth_date: birthDate, birth_time: birthTime, gender, city_name: cityName.trim(), mbti: mbti || null, }); const validateRegistrationProfile = () => { if (!nickname.trim()) throw new Error('请填写昵称'); if (!birthDate) throw new Error('请选择出生日期'); if (!birthTime) throw new Error('请选择出生时间'); if (!gender) throw new Error('请选择性别'); if (!cityName.trim()) throw new Error('请填写出生城市'); }; const sendCode = async () => { if (!/^1[3-9]\d{9}$/.test(phone)) return setError('请输入正确的手机号'); setError(''); setSendingCode(true); try { const purpose = mode === 'reset' ? 'reset' : 'login'; const res = await fetch('/api/auth/sms/send', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({phone, purpose}) }); const data = await readResponse(res); setCooldown(60); if (data.debug_code) setCode(data.debug_code); setError('验证码已发送'); } catch (err) { setError(err.message); } finally { setSendingCode(false); } }; const handleSubmit = async (event) => { event.preventDefault(); setError(''); setIsSubmitting(true); try { if (mode === 'sms') { if (!/^1[3-9]\d{9}$/.test(phone)) throw new Error('请输入正确的手机号'); if (!/^\d{6}$/.test(code)) throw new Error('请输入6位验证码'); if (smsNeedsProfile) validateRegistrationProfile(); const res = await fetch('/api/auth/sms/login', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({phone, code, ...(smsNeedsProfile ? registrationProfile() : {})}) }); if (res.status === 409) { const data = await res.json().catch(() => ({})); setSmsNeedsProfile(true); throw new Error(data.detail || '首次使用请完善个人档案'); } return await finishLogin(await readResponse(res)); } if (mode === 'reset') { if (newPassword.length < 8) throw new Error('新密码至少8位'); const res = await fetch('/api/auth/password/reset', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({phone, code, new_password: newPassword}) }); await readResponse(res); setMode('password'); setError('密码已重置,请使用账号密码登录'); return; } if (username.trim().length < 2) throw new Error('账号至少2个字符'); const minimum = mode === 'register' ? 8 : 6; if (password.length < minimum) throw new Error(`密码至少${minimum}位`); if (mode === 'register') validateRegistrationProfile(); const endpoint = mode === 'register' ? '/api/auth/register' : '/api/auth/login'; const res = await fetch(endpoint, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ username: username.trim(), password, ...(mode === 'register' ? registrationProfile() : {}) }) }); await readResponse(res); if (mode === 'register') { const loginRes = await fetch('/api/auth/login', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: username.trim(), password}) }); return await finishLogin(await readResponse(loginRes)); } return await finishLogin(await readResponse(res)); } catch (err) { setError(err.message); } finally { setIsSubmitting(false); } }; return (

登录知了

同步记录,让陪伴记得住你

{methods.sms_enabled && mode !== 'register' && (
)}
{(mode === 'sms' || mode === 'reset') ? ( <> setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))} placeholder="手机号" className="w-full bg-slate-50 px-4 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" />
setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="6位验证码" className="min-w-0 flex-1 bg-slate-50 px-4 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" />
{mode === 'reset' && setNewPassword(e.target.value)} placeholder="设置新密码(至少8位)" className="w-full bg-slate-50 px-4 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" />} ) : ( <> setUsername(e.target.value)} placeholder="账号" className="w-full bg-slate-50 px-4 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" /> setPassword(e.target.value)} placeholder={mode === 'register' ? '密码(至少8位)' : '密码'} className="w-full bg-slate-50 px-4 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" /> )} {(mode === 'register' || (mode === 'sms' && smsNeedsProfile)) && (

基础档案

setNickname(e.target.value)} placeholder="昵称(必填)" className="w-full bg-slate-50 px-4 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" /> setRealName(e.target.value)} placeholder="真实姓名(选填)" className="w-full bg-slate-50 px-4 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" />
setBirthDate(e.target.value)} className="min-w-0 w-full bg-slate-50 px-3 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" /> setBirthTime(e.target.value)} className="min-w-0 w-full bg-slate-50 px-3 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" />
{['女','男','其他'].map(item => )}
setCityName(e.target.value)} placeholder="出生城市(必填)" className="w-full bg-slate-50 px-4 py-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-purple-200" />
)} {error &&

{error}

}
{mode !== 'register' && } {mode === 'register' && } {methods.sms_enabled && (mode === 'password' || mode === 'register') && }

登录即表示同意 用户协议隐私政策