428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
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 ThemedPageMeta from '../../components/ThemedPageMeta'
|
||
import ScrollTopMask from '../../components/ScrollTopMask'
|
||
import { login, getMe, updateProfile, getToken } from '../../utils/api'
|
||
import { safeHideLoading } from '../../utils/request'
|
||
import { assetUrl } from '../../utils/asset'
|
||
import { fetchOrders } from '../../utils/api/order'
|
||
|
||
const ICON_MAP: Record<string, string> = {
|
||
'我的设计': assetUrl('/icon/调色盘.png'),
|
||
'词云生成': assetUrl('/icon/词云生成.png'),
|
||
'收货地址': assetUrl('/icon/地址.png'),
|
||
'联系客服': assetUrl('/icon/电话.png'),
|
||
'使用帮助': assetUrl('/icon/使用帮助.png'),
|
||
'设置': assetUrl('/icon/设置.png')
|
||
}
|
||
|
||
const STATUS_MAP = [
|
||
{ key: 'toDesign', label: '待设计', icon: assetUrl('/icon/三角尺.png') },
|
||
{ key: 'pending', label: '待付款', icon: assetUrl('/icon/四角星.svg') },
|
||
{ key: 'paid', label: '待发货', icon: assetUrl('/icon/包裹.png') },
|
||
{ key: 'shipping', label: '待收货', icon: assetUrl('/icon/杯子.png') },
|
||
{ key: 'done', label: '已完成', icon: assetUrl('/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 = async () => {
|
||
const designs = getDesignList()
|
||
try {
|
||
const remote = (await fetchOrders({ page: 1, pageSize: 100 })).list
|
||
setOrderStats({
|
||
toDesign: designs.filter(d => d.status === 'undesigned' || d.status === 'designing').length,
|
||
pending: remote.filter(o => o.status === 'PENDING').length,
|
||
paid: remote.filter(o => o.status === 'PAID' || o.status === 'PROCESSING').length,
|
||
shipping: remote.filter(o => o.status === 'SHIPPED').length,
|
||
done: remote.filter(o => o.status === 'COMPLETED').length
|
||
})
|
||
} catch {
|
||
const orders = getOrderList()
|
||
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 && 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 || !result.accessToken) {
|
||
loginFail('登录失败,未拿到 token')
|
||
return
|
||
}
|
||
// 取 openid 落本地
|
||
let openid = ''
|
||
try {
|
||
const me = await getMe()
|
||
openid = (me && 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 && (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 && me.openid) || ''
|
||
} catch {
|
||
openid = ''
|
||
}
|
||
if (!openid) {
|
||
loginFail('保存异常,请重试')
|
||
return
|
||
}
|
||
const stored = getUserInfo()
|
||
loginFinish(
|
||
(stored && stored.accessToken) || getToken(),
|
||
openid,
|
||
loginNickName,
|
||
loginAvatarUrl
|
||
)
|
||
} catch (e) {
|
||
console.error('[Profile] 保存资料出错:', e)
|
||
loginFail((e && (e as Error).message) || '保存失败')
|
||
}
|
||
}
|
||
|
||
const onChooseAvatar = (e: any) => {
|
||
setLoginAvatarUrl(e.detail.avatarUrl || '')
|
||
}
|
||
|
||
const handleQuickClick = (type: string) => {
|
||
switch (type) {
|
||
case 'toDesign':
|
||
Taro.setStorageSync('designList:filter', 'undesigned')
|
||
Taro.switchTab({ url: '/pages/designList/index' })
|
||
Taro.eventCenter.trigger('tabBarChange', '/pages/designList/index')
|
||
break
|
||
case 'pending':
|
||
Taro.setStorageSync('orders:filter', 'pending')
|
||
Taro.switchTab({ url: '/pages/orders/index' })
|
||
Taro.eventCenter.trigger('tabBarChange', '/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' }}>
|
||
<ThemedPageMeta />
|
||
<ScrollTopMask title="我的" targetSelector=".profile-header" />
|
||
<View className='profile-page'>
|
||
|
||
{/* 用户信息头部 */}
|
||
<View className='profile-header surface-card' style={{ marginTop: `${safe.statusBarHeight + 12}px` }}>
|
||
<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={assetUrl('/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 surface-card' onTap={() => handleMenuClick(item.path, item.label)}>
|
||
<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 surface-card mt-20'>
|
||
<View className='flex-between'>
|
||
<View className='flex-column'>
|
||
<View className='flex-center'>
|
||
<Image className='enterprise-icon' src={assetUrl('/icon/企业批量定制.png')} mode='aspectFit' />
|
||
<Text className='enterprise-title'>企业批量定制</Text>
|
||
</View>
|
||
<Text className='enterprise-desc'>年会礼品、入职纪念、团建伴手礼</Text>
|
||
</View>
|
||
<View className='btn-primary enterprise-btn'>
|
||
<Text>立即咨询</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 登录弹窗 */}
|
||
{showLogin && (
|
||
<View className='modal-overlay'>
|
||
<View className='login-card surface-card'>
|
||
<Text className='login-title'>登录智绘微刻</Text>
|
||
|
||
{loginStep === 'entry' ? (
|
||
<>
|
||
<Text className='login-desc'>登录后同步您的设计清单和订单</Text>
|
||
<View className='login-actions'>
|
||
<View className='btn-secondary' onTap={handleCloseLogin}>
|
||
<Text>取消</Text>
|
||
</View>
|
||
<View className='btn-primary' 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={assetUrl('/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-secondary' onTap={handleCloseLogin}>
|
||
<Text>取消</Text>
|
||
</View>
|
||
<View className='btn-primary' onTap={handleSaveProfile}>
|
||
<Text>保存</Text>
|
||
</View>
|
||
</View>
|
||
</>
|
||
)}
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|