添加登录和后端校验

完成后端设计(未在本仓库体现),通过安全的手段完成了登录鉴权
This commit is contained in:
2026-08-06 16:27:36 +08:00
commit b19a56003f
286 changed files with 51650 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
.login-guard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 80vh;
}
.login-guard-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.login-guard-icon {
font-size: 80px;
margin-bottom: 24px;
}
.login-guard-title {
font-size: 36px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
}
.login-guard-desc {
font-size: 28px;
color: var(--text-secondary);
margin-bottom: 32px;
}
.login-guard-btn {
padding: 20px 48px;
}
+81
View File
@@ -0,0 +1,81 @@
import Taro from '@tarojs/taro'
import { useEffect, useState } from 'react'
import { View, Text } from '@tarojs/components'
import { getMe, logout } from '../../utils/api'
import { getUserInfoRaw, clearUserInfo, isMockOpenid } from '../../utils/store'
type GuardState = 'checking' | 'ok' | 'denied'
/**
* 登录守卫:不仅看本地是否有 openid,还要求本地用户与云端校验一致。
* - 本地 openid 是历史 mock 残留 -> 判定不一致,清掉并引导重新登录
* - 本地有 openid 但后端 getMe 校验失败/用户不存在 -> 判定不一致,清掉并引导重新登录
* - 本地与云端 ID/openid 一致 -> 放行
*/
export default function LoginGuard(props: { children: React.ReactNode }) {
const [state, setState] = useState<GuardState>('checking')
useEffect(() => {
const localUser = getUserInfoRaw()
// 本地根本没登录信息
if (!localUser?.openid) {
setState('denied')
return
}
// 本地是旧版 mock 残留:与真实云端不一致,强制重新登录
if (isMockOpenid(localUser.openid)) {
clearUserInfo()
logout()
setState('denied')
return
}
// 本地有真实 openid:到后端校验是否与云端一致
getMe()
.then((me) => {
const consistent = !!me && !!me.id && me.openid === localUser.openid
if (consistent) {
setState('ok')
} else {
clearUserInfo()
logout()
setState('denied')
}
})
.catch(() => {
// 后端不可用时的谨慎处理:不静默放行,也不抹掉本地,
// 返回 denied 让用户尝试重新登录(后端恢复后即可通过)
setState('denied')
})
}, [])
if (state === 'checking') {
return (
<View className='login-guard'>
<View className='login-guard-inner'>
<Text className='login-guard-icon'></Text>
<Text className='login-guard-title'></Text>
</View>
</View>
)
}
if (state === 'denied') {
return (
<View className='login-guard'>
<View className='login-guard-inner'>
<Text className='login-guard-icon'>🔒</Text>
<Text className='login-guard-title'></Text>
<Text className='login-guard-desc'></Text>
<View className='btn-gradient login-guard-btn' onTap={() => Taro.switchTab({ url: '/pages/profile/index' })}>
<Text></Text>
</View>
</View>
</View>
)
}
return props.children as any
}
+83
View File
@@ -0,0 +1,83 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 48px;
}
.login-card {
width: 100%;
max-width: 600px;
padding: 40px;
text-align: center;
}
.login-title {
font-size: 36px;
font-weight: 700;
color: var(--text-primary);
display: block;
margin-bottom: 12px;
}
.login-desc {
font-size: 26px;
color: var(--text-secondary);
display: block;
margin-bottom: 32px;
}
.avatar-btn {
width: 160px;
height: 160px;
border-radius: 50%;
margin: 0 auto 24px;
background: var(--bg-input);
border: 3px dashed var(--line-star);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.avatar-placeholder {
font-size: 24px;
color: var(--text-secondary);
}
.nickname-input {
width: 100%;
height: 80px;
background: var(--bg-input);
border: 3px dashed var(--line-star);
border-radius: 16px;
padding: 0 24px;
font-size: 28px;
color: var(--text-primary);
margin-bottom: 32px;
text-align: center;
}
.login-actions {
display: flex;
gap: 20px;
}
.login-actions .btn-outline,
.login-actions .btn-gradient {
flex: 1;
}
+119
View File
@@ -0,0 +1,119 @@
import { View, Text, Image, Button, Input } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState } from 'react'
import './index.scss'
import { setUserInfo } from '../../utils/store'
import { login, getMe } from '../../utils/api'
import { safeHideLoading } from '../../utils/request'
/**
* 登录弹窗(一步流程,无需手机号)
*
* 个人主体小程序无 getPhoneNumber 权限,故以 openid 作为用户唯一标识:
* 用户填头像+昵称 -> 点「登录」-> wx.login 拿 code -> 后端换 openid 自动注册/续登
* -> getMe 取用户信息 -> 完成
*/
export default function LoginModal({ visible, onClose, onLogin }: { visible: boolean; onClose: () => void; onLogin: () => void }) {
const [nickName, setNickName] = useState('')
const [avatarUrl, setAvatarUrl] = useState('')
const [loading, setLoading] = useState(false)
if (!visible) return null
const fail = (msg: string) => {
setLoading(false)
safeHideLoading()
Taro.showToast({ title: msg, icon: 'none' })
}
const finish = (accessToken: string, openid: string) => {
const info = {
openid,
nickName: nickName || '微信用户',
avatarUrl: avatarUrl || '',
accessToken,
loginAt: Date.now()
}
setUserInfo(info)
setLoading(false)
safeHideLoading()
Taro.showToast({ title: '登录成功', icon: 'success' })
onLogin()
}
const handleLogin = async () => {
if (loading) return
console.log('[LoginModal] 登录按钮被点击,开始登录流程')
setLoading(true)
Taro.showLoading({ title: '登录中', mask: true })
try {
const wxLogin = await Taro.login()
console.log('[LoginModal] wx.login 成功,code=', wxLogin.code)
const result = await login(wxLogin.code)
console.log('[LoginModal] 后端 /auth/login 返回:', JSON.stringify(result))
if (!result?.accessToken) {
fail('登录失败,未拿到 token')
return
}
let openid = ''
try {
const me = await getMe()
openid = me?.openid || ''
console.log('[LoginModal] getMe 返回:', JSON.stringify(me))
} catch {
openid = ''
}
if (!openid) {
fail('登录异常,请重试')
return
}
finish(result.accessToken, openid)
} catch (e) {
console.error('[LoginModal] 登录流程出错:', e)
fail((e as Error)?.message || '后端连接失败,请检查网络')
}
}
const onChooseAvatar = (e: any) => {
setAvatarUrl(e.detail.avatarUrl || '')
}
return (
<View className='modal-overlay'>
<View className='login-card dashed-card'>
<View className='star-badge' />
<Text className='login-title'></Text>
<Text className='login-desc'></Text>
<Button
className='avatar-btn'
openType='chooseAvatar'
onChooseAvatar={onChooseAvatar}
>
{avatarUrl ? (
<Image className='avatar-img' src={avatarUrl} mode='aspectFill' />
) : (
<Text className='avatar-placeholder'></Text>
)}
</Button>
<Input
type='nickname'
className='nickname-input'
placeholder='请输入昵称'
value={nickName}
onInput={(e: any) => setNickName(e.detail.value)}
/>
<View className='login-actions'>
<View className='btn-outline' onTap={onClose}>
<Text></Text>
</View>
<View className='btn-gradient' onTap={handleLogin}>
<Text></Text>
</View>
</View>
</View>
</View>
)
}
+28
View File
@@ -0,0 +1,28 @@
.theme-toggle {
position: fixed;
left: 24px;
bottom: 150px;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
z-index: 900;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.theme-toggle:active {
transform: scale(0.92);
background: rgba(0, 0, 0, 0.5);
}
.theme-icon {
font-size: 36px;
line-height: 1;
}
+21
View File
@@ -0,0 +1,21 @@
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useCallback } from 'react'
import { useThemeContext } from '../../context/ThemeContext'
import './index.scss'
export default function ThemeToggle() {
const { theme, toggleTheme } = useThemeContext()
const handleToggle = useCallback(() => {
// ThemeProvider 在 App.tsx 全局挂载,toggleTheme() 直接修改全局 state
// 不需要 eventCenter,所有页面会自动响应。
toggleTheme()
}, [toggleTheme])
return (
<View className='theme-toggle' onTap={handleToggle}>
<Text className='theme-icon'>{theme === 'light' ? '🌙' : '☀️'}</Text>
</View>
)
}