添加登录和后端校验

完成后端设计(未在本仓库体现),通过安全的手段完成了登录鉴权
This commit is contained in:
2026-08-06 16:27:36 +08:00
commit b19a56003f
286 changed files with 51650 additions and 0 deletions
+421
View File
@@ -0,0 +1,421 @@
import { View, Text, Image, Input, Button } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { useState } from 'react'
import './index.scss'
import { getOrderList, getUserInfo, setUserInfo, getDesignList } from '../../utils/store'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
import { login, getMe, updateProfile, getToken } from '../../utils/api'
import { safeHideLoading } from '../../utils/request'
const ICON_MAP: Record<string, string> = {
'我的设计': '/icon/调色盘.png',
'词云生成': '/icon/词云生成.png',
'收货地址': '/icon/地址.png',
'联系客服': '/icon/电话.png',
'使用帮助': '/icon/使用帮助.png',
'设置': '/icon/设置.png'
}
const STATUS_MAP = [
{ key: 'toDesign', label: '待设计', icon: '/icon/三角尺.png' },
{ key: 'pending', label: '待付款', icon: '/icon/四角星.png' },
{ key: 'paid', label: '待发货', icon: '/icon/包裹.png' },
{ key: 'shipping', label: '待收货', icon: '/icon/杯子.png' },
{ key: 'done', label: '已完成', icon: '/icon/书本.png' }
]
const MENU_ITEMS = [
{ label: '我的设计', path: '/pages/designList/index' },
{ label: '词云生成', path: '/pages/wordcloud/index' },
{ label: '收货地址', path: '/pages/address/index' },
{ label: '联系客服', path: '/pages/service/index' },
{ label: '使用帮助', path: '/pages/agreement/index' },
{ label: '设置', path: '/pages/settings/index' }
]
// Stable no-op: Taro runtime crashes when an event handler is removed from a
// Text/Image element (pure-text is missing from componentsAlias). Keeping
// onTap as a function at all times avoids the removeEventListener side-effect
// that triggers the crash.
const noop = () => {}
export default function ProfilePage() {
const { theme, resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [userInfo, setUserInfoState] = useState({ nickName: '', avatarUrl: '' })
const [showLogin, setShowLogin] = useState(false)
// loginStep: 'entry' 显示登录按钮;'fill' 新用户补全资料
const [loginStep, setLoginStep] = useState<'entry' | 'fill'>('entry')
const [loginNickName, setLoginNickName] = useState('')
const [loginAvatarUrl, setLoginAvatarUrl] = useState('')
const [loginLoading, setLoginLoading] = useState(false)
const [orderStats, setOrderStats] = useState({
toDesign: 0, pending: 0, paid: 0, shipping: 0, done: 0
})
const loadStats = () => {
const orders = getOrderList()
const designs = getDesignList()
setOrderStats({
toDesign: designs.filter(d => d.status === 'undesigned' || d.status === 'designing').length,
pending: orders.filter(o => o.statusCode === 'pending').length,
paid: orders.filter(o => o.statusCode === 'paid').length,
shipping: orders.filter(o => o.statusCode === 'shipping').length,
done: orders.filter(o => o.statusCode === 'done').length
})
}
const sync = () => {
const info = getUserInfo()
if (info?.openid) {
setIsLoggedIn(true)
setUserInfoState({ nickName: info.nickName || '', avatarUrl: info.avatarUrl || '' })
} else {
setIsLoggedIn(false)
setUserInfoState({ nickName: '', avatarUrl: '' })
}
loadStats()
}
useDidShow(() => {
sync()
})
const handleOpenLogin = () => {
setLoginStep('entry')
setLoginNickName('')
setLoginAvatarUrl('')
setShowLogin(true)
}
const handleCloseLogin = () => setShowLogin(false)
const loginFail = (msg: string) => {
setLoginLoading(false)
safeHideLoading()
Taro.showToast({ title: msg, icon: 'none' })
}
const loginFinish = (accessToken: string, openid: string, nickName: string, avatarUrl: string) => {
const info = {
openid,
nickName: nickName || '微信用户',
avatarUrl: avatarUrl || '',
accessToken,
loginAt: Date.now()
}
setUserInfo(info)
setUserInfoState({ nickName: info.nickName, avatarUrl: info.avatarUrl })
setIsLoggedIn(true)
setShowLogin(false)
setLoginStep('entry')
setLoginLoading(false)
safeHideLoading()
Taro.showToast({ title: '登录成功', icon: 'success' })
}
/**
* 登录:wx.login 拿 code -> 后端判断新老用户
* - 老用户(isNewUser=false) -> 直接用库里已有的昵称/头像完成,免设置
* - 新用户(isNewUser=true) -> 切到 'fill' 步骤补全资料
*/
const handleLogin = async () => {
if (loginLoading) return
console.log('[Profile] 登录按钮被点击,开始登录流程')
setLoginLoading(true)
Taro.showLoading({ title: '登录中', mask: true })
try {
const wxLogin = await Taro.login()
console.log('[Profile] wx.login 成功,code=', wxLogin.code)
const result = await login(wxLogin.code)
console.log('[Profile] 后端 /auth/login 返回:', JSON.stringify(result))
if (!result?.accessToken) {
loginFail('登录失败,未拿到 token')
return
}
// 取 openid 落本地
let openid = ''
try {
const me = await getMe()
openid = me?.openid || ''
} catch {
openid = ''
}
if (!openid) {
loginFail('登录异常,请重试')
return
}
// 资料不完整(新用户,或老用户从未设置过资料)都引导补填
const needProfile = result.isNewUser || !result.nickname || !result.avatar
if (needProfile) {
// 预填已有值(若老用户有一部分已设置)
setLoginNickName(result.nickname || '')
setLoginAvatarUrl(result.avatar || '')
safeHideLoading()
setLoginLoading(false)
setLoginStep('fill')
Taro.showToast({ title: '请设置头像和昵称', icon: 'none' })
return
}
// 资料完整的老用户:直接用库里已有资料
loginFinish(result.accessToken, openid, result.nickname || '', result.avatar || '')
} catch (e) {
console.error('[Profile] 登录流程出错:', e)
loginFail((e as Error)?.message || '后端连接失败,请检查网络')
}
}
/** 新用户补全资料后保存到后端并完成登录 */
const handleSaveProfile = async () => {
if (loginLoading) return
setLoginLoading(true)
Taro.showLoading({ title: '保存中', mask: true })
try {
// 把昵称/头像存到后端(如需真实 openid 可再 getMe
await updateProfile({
nickname: loginNickName || undefined,
avatar: loginAvatarUrl || undefined
})
let openid = ''
try {
const me = await getMe()
openid = me?.openid || ''
} catch {
openid = ''
}
if (!openid) {
loginFail('保存异常,请重试')
return
}
const stored = getUserInfo()
loginFinish(
stored?.accessToken || getToken(),
openid,
loginNickName,
loginAvatarUrl
)
} catch (e) {
console.error('[Profile] 保存资料出错:', e)
loginFail((e as Error)?.message || '保存失败')
}
}
const onChooseAvatar = (e: any) => {
setLoginAvatarUrl(e.detail.avatarUrl || '')
}
const handleQuickClick = (type: string) => {
Taro.hideToast()
switch (type) {
case 'toDesign':
Taro.setStorageSync('designList:filter', 'toDesign')
Taro.switchTab({ url: '/pages/designList/index' })
break
case 'pending':
Taro.setStorageSync('orders:filter', 'pending')
Taro.switchTab({ url: '/pages/orders/index' })
break
case 'paid':
Taro.setStorageSync('orders:filter', 'paid')
Taro.switchTab({ url: '/pages/orders/index' })
break
case 'shipping':
Taro.setStorageSync('orders:filter', 'shipping')
Taro.switchTab({ url: '/pages/orders/index' })
break
case 'done':
Taro.setStorageSync('orders:filter', 'done')
Taro.switchTab({ url: '/pages/orders/index' })
break
}
}
const handleMenuClick = (path: string, label: string) => {
// Clear any lingering toast that could intercept the subsequent navigateTo
Taro.hideToast()
if (!isLoggedIn && (path.includes('designList') || path.includes('orders'))) {
Taro.showToast({ title: '请先登录', icon: 'none' })
setShowLogin(true)
return
}
if (label === '联系客服') {
Taro.navigateTo({
url: '/pages/service/index',
fail: (err: any) => {
console.error('navigateTo service fail', err)
Taro.showToast({ title: '跳转失败', icon: 'none' })
}
})
return
}
if (path.startsWith('/pages/')) {
if (path.includes('designList') || path.includes('orders')) {
Taro.switchTab({
url: path,
fail: (err: any) => {
console.error('switchTab fail', err)
Taro.showToast({ title: '跳转失败', icon: 'none' })
}
})
} else {
Taro.navigateTo({
url: path,
fail: (err: any) => {
console.error('navigateTo fail', err)
Taro.showToast({ title: '跳转失败', icon: 'none' })
}
})
}
}
}
return (
<View className={`theme-${resolvedTheme}`} style={{ minHeight: '100vh' }}>
<View className='profile-page'>
{/* 用户信息头部 */}
<View className='profile-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
<View className='star-badge' />
<View className='user-info'>
{isLoggedIn && userInfo.avatarUrl ? (
<Image className='avatar' src={userInfo.avatarUrl} mode='aspectFill' />
) : (
<View className='avatar-placeholder' onTap={handleOpenLogin}>
<Image className='avatar-icon-img' src='/icon/个人.png' mode='aspectFit' />
</View>
)}
<View className='user-meta'>
<Text className='user-name' onTap={isLoggedIn ? noop : handleOpenLogin}>
{isLoggedIn ? userInfo.nickName : '点击登录'}
</Text>
<Text className='user-level'>
{isLoggedIn ? '智绘微刻会员' : '授权微信,开启专属定制'}
</Text>
</View>
</View>
{/* 5 状态快捷入口 */}
<View className='status-grid'>
<View className='status-row row2'>
{STATUS_MAP.slice(0, 2).map((s) => (
<View key={s.key} className='status-group'>
<View className='status-item' onTap={() => handleQuickClick(s.key)}>
<Image className='status-icon-img' src={s.icon} mode='aspectFit' />
<Text className='status-num'>{orderStats[s.key as keyof typeof orderStats]}</Text>
<Text className='status-label'>{s.label}</Text>
</View>
<View className='status-divider' />
</View>
))}
</View>
<View className='status-divider-h' />
<View className='status-row row3'>
{STATUS_MAP.slice(2).map((s, idx) => (
<View key={s.key} className='status-group'>
<View className='status-item' onTap={() => handleQuickClick(s.key)}>
<Image className='status-icon-img' src={s.icon} mode='aspectFit' />
<Text className='status-num'>{orderStats[s.key as keyof typeof orderStats]}</Text>
<Text className='status-label'>{s.label}</Text>
</View>
{idx < 2 && <View className='status-divider' />}
</View>
))}
</View>
</View>
</View>
{/* 功能菜单 6宫格 */}
<View className='mt-20'>
<Text className='section-title'></Text>
<View className='menu-grid'>
{MENU_ITEMS.map((item, idx) => (
<View key={idx} className='menu-card dashed-card' onTap={() => handleMenuClick(item.path, item.label)}>
<View className='star-badge' />
<Image className='menu-icon-img' src={ICON_MAP[item.label]} mode='aspectFit' />
<Text className='menu-label'>{item.label}</Text>
</View>
))}
</View>
</View>
{/* 企业定制入口 */}
<View className='enterprise-section dashed-card mt-20'>
<View className='star-badge' />
<View className='flex-between'>
<View className='flex-column'>
<View className='flex-center'>
<Image className='enterprise-icon' src='/icon/企业批量定制.png' mode='aspectFit' />
<Text className='enterprise-title'></Text>
</View>
<Text className='enterprise-desc'></Text>
</View>
<View className='btn-gradient enterprise-btn'>
<Text></Text>
</View>
</View>
</View>
<View style={{ height: '40px' }} />
{/* 登录弹窗 */}
{showLogin && (
<View className='modal-overlay'>
<View className='login-card dashed-card'>
<View className='star-badge' />
<Text className='login-title'></Text>
{loginStep === 'entry' ? (
<>
<Text className='login-desc'></Text>
<View className='login-actions'>
<View className='btn-outline' onTap={handleCloseLogin}>
<Text></Text>
</View>
<View className='btn-gradient' onTap={handleLogin}>
<Text></Text>
</View>
</View>
</>
) : (
<>
<Text className='login-desc'></Text>
<Button
className='avatar-btn'
openType='chooseAvatar'
onChooseAvatar={onChooseAvatar}
>
{loginAvatarUrl ? (
<Image className='avatar-img' src={loginAvatarUrl} mode='aspectFill' />
) : (
<Image className='avatar-placeholder-img' src='/icon/个人.png' mode='aspectFit' />
)}
</Button>
<Input
type='nickname'
className='nickname-input'
placeholder='请输入昵称'
value={loginNickName}
onInput={(e: any) => setLoginNickName(e.detail.value)}
/>
<View className='login-actions'>
<View className='btn-outline' onTap={handleCloseLogin}>
<Text></Text>
</View>
<View className='btn-gradient' onTap={handleSaveProfile}>
<Text></Text>
</View>
</View>
</>
)}
</View>
</View>
)}
</View>
</View>
)
}