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