Files
wechat_wc/src/pages/profile/index.tsx
T

428 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}