添加登录和后端校验
完成后端设计(未在本仓库体现),通过安全的手段完成了登录鉴权
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user