diff --git a/package.json b/package.json index 1453af1..92f62aa 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "@tarojs/taro": "3.6.14", "@tarojs/taro-h5": "3.6.14", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "zustand": "^5.0.14" }, "devDependencies": { "@antmjs/plugin-global-fix": "^2.3.21", diff --git a/project.config.json b/project.config.json index 3faa838..03f465e 100644 --- a/project.config.json +++ b/project.config.json @@ -4,10 +4,10 @@ "description": "", "appid": "wx7ed74d60503b5ee3", "setting": { - "urlCheck": true, + "urlCheck": false, "es6": false, "postcss": false, - "minified": false, + "minified": true, "enhance": false }, "compileType": "miniprogram" diff --git a/src/app.config.ts b/src/app.config.ts index f8d0f64..1895e97 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -15,6 +15,8 @@ export default defineAppConfig({ 'pages/messages/index', 'pages/profile/index', 'pages/article/index', + 'pages/login/index', + 'pages/profile-edit/index', ], window: { backgroundTextStyle: 'light', diff --git a/src/app.ts b/src/app.ts index 3c32fab..f77b176 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ class App extends Component { // this.props.children 是将要会渲染的页面 render () { + // @ts-ignore return this.props.children } } diff --git a/src/pages/login/index.config.ts b/src/pages/login/index.config.ts new file mode 100644 index 0000000..365f297 --- /dev/null +++ b/src/pages/login/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationStyle: 'custom', + navigationBarTitleText: '登录', +}) diff --git a/src/pages/login/index.less b/src/pages/login/index.less new file mode 100644 index 0000000..a85b587 --- /dev/null +++ b/src/pages/login/index.less @@ -0,0 +1,165 @@ +/* ======================================== + 登录页面 + ======================================== */ + +.login-page { + min-height: 100vh; + background: #fff; +} + +/* ========== 自定义导航栏 ========== */ +.login-navbar { + background: #fff; + position: sticky; + top: 0; + z-index: 100; + + .navbar-inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + position: relative; + } + + .navbar-back { + width: 60px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + .back-arrow { + font-size: 48px; + color: #323233; + line-height: 1; + font-weight: 300; + } + } + + .navbar-title { + font-size: 32px; + font-weight: 500; + color: #323233; + position: absolute; + left: 50%; + transform: translateX(-50%); + } + + .navbar-placeholder { + width: 60px; + height: 60px; + flex-shrink: 0; + } +} + +/* ========== 内容区域 ========== */ +.login-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 80px 60px 0; +} + +/* ========== 品牌区域 ========== */ +.login-brand { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 40px; + + .logo-wrapper { + width: 160px; + height: 160px; + border-radius: 50%; + background: linear-gradient(160deg, #1989fa 0%, #07c160 100%); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24px; + box-shadow: 0 8px 32px rgba(25, 137, 250, 0.3); + } + + .logo-text { + font-size: 80px; + color: #fff; + font-weight: 700; + } + + .app-name { + font-size: 44px; + font-weight: 600; + color: #323233; + margin-bottom: 12px; + } + + .app-slogan { + font-size: 28px; + color: #969799; + } +} + +/* ========== 功能介绍 ========== */ +.login-features { + margin-bottom: 80px; + + .feature-text { + font-size: 26px; + color: #c8c9cc; + letter-spacing: 2px; + } +} + +/* ========== 登录操作区 ========== */ +.login-actions { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +.login-btn { + width: 100%; + height: 96px; + line-height: 96px; + background: linear-gradient(160deg, #1989fa 0%, #07c160 100%); + color: #fff; + font-size: 34px; + font-weight: 500; + border: none; + border-radius: 48px; + text-align: center; + padding: 0; + box-shadow: 0 6px 24px rgba(25, 137, 250, 0.35); + transition: opacity 0.2s; + + /* 重置微信 Button 默认样式 */ + &::after { + border: none; + } +} + +.login-btn--loading { + opacity: 0.75; +} + +/* ========== 协议文字 ========== */ +.login-agreement { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + margin-top: 32px; + line-height: 1.6; + + .agree-text { + font-size: 24px; + color: #c8c9cc; + } + + .agree-link { + font-size: 24px; + color: #1989fa; + } +} diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx new file mode 100644 index 0000000..e06536e --- /dev/null +++ b/src/pages/login/index.tsx @@ -0,0 +1,148 @@ +import { useState, useMemo, useEffect, useCallback } from 'react' +import Taro from '@tarojs/taro' +import { View, Text, Button } from '@tarojs/components' +import useAuthStore from '@/stores/auth/useAuthStore' +import './index.less' + +/** 状态栏高度 */ +function getStatusBarHeight(): number { + try { + return Taro.getSystemInfoSync().statusBarHeight || 20 + } catch { + return 20 + } +} + +/** 导航栏高度 */ +const NAV_BAR_HEIGHT = 44 + +export default function LoginPage() { + const statusBarH = useMemo(getStatusBarHeight, []) + const login = useAuthStore(s => s.login) + const isLoggedIn = useAuthStore(s => !!s.token && !!s.user) + const [submitting, setSubmitting] = useState(false) + + // 已登录则自动返回 + useEffect(() => { + if (isLoggedIn) { + Taro.navigateBack() + } + }, [isLoggedIn]) + + /** 返回上一页 */ + const handleBack = useCallback(() => { + Taro.navigateBack() + }, []) + + /** 手机号授权登录 */ + const handleGetPhoneNumber = useCallback( + async (e: any) => { + if (submitting) return + + const detail = e.detail || {} + + // 用户拒绝授权 + if (detail.errMsg && !detail.errMsg.includes(':ok')) { + Taro.showToast({ title: '需要授权手机号才能登录', icon: 'none' }) + return + } + + setSubmitting(true) + + try { + // 1. 获取微信登录 code(用于换取 openid / session_key) + const loginRes = await Taro.login() + if (!loginRes.code) { + Taro.showToast({ title: '获取登录凭证失败', icon: 'none' }) + return + } + + // 2. 调用后端登录接口(仅传 code + phoneCode) + await login({ + code: loginRes.code, + // 新版微信 API:动态令牌,后端直接调用微信接口换手机号 + phoneCode: detail.code, + // 旧版微信 API:加密数据,后端用 session_key 解密 + encryptedData: detail.encryptedData, + iv: detail.iv, + }) + + Taro.showToast({ title: '登录成功', icon: 'success' }) + setTimeout(() => { + Taro.navigateBack() + }, 1200) + } catch { + Taro.showToast({ title: '登录失败,请重试', icon: 'none' }) + } finally { + setSubmitting(false) + } + }, + [login, submitting], + ) + + /** 查看用户协议 */ + const handleShowAgreement = useCallback(() => { + Taro.showToast({ title: '用户协议即将上线', icon: 'none' }) + }, []) + + /** 查看隐私政策 */ + const handleShowPrivacy = useCallback(() => { + Taro.showToast({ title: '隐私政策即将上线', icon: 'none' }) + }, []) + + return ( + + {/* ========== 自定义导航栏 ========== */} + + + + + + 登录 + + + + + {/* ========== 内容区域 ========== */} + + {/* 品牌区域 */} + + + + + 校园通 + 校园生活,一触即达 + + + {/* 功能介绍 */} + + 发布任务 · 二手交易 · 校园资讯 + + + {/* 登录操作 */} + + + + + 登录即代表同意 + + 《用户协议》 + + + + 《隐私政策》 + + + + + + ) +} diff --git a/src/pages/profile-edit/index.config.ts b/src/pages/profile-edit/index.config.ts new file mode 100644 index 0000000..4226a9f --- /dev/null +++ b/src/pages/profile-edit/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationStyle: 'custom', + navigationBarTitleText: '编辑资料', +}) diff --git a/src/pages/profile-edit/index.less b/src/pages/profile-edit/index.less new file mode 100644 index 0000000..0d77528 --- /dev/null +++ b/src/pages/profile-edit/index.less @@ -0,0 +1,218 @@ +/* ======================================== + 编辑资料页面 + ======================================== */ + +.profile-edit-page { + min-height: 100vh; + background: #f7f8fa; +} + +/* ========== 自定义导航栏 ========== */ +.profile-edit-navbar { + background: #fff; + position: sticky; + top: 0; + z-index: 100; + + .navbar-inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + position: relative; + } + + .navbar-back { + width: 60px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + .back-arrow { + font-size: 48px; + color: #323233; + line-height: 1; + font-weight: 300; + } + } + + .navbar-title { + font-size: 32px; + font-weight: 500; + color: #323233; + position: absolute; + left: 50%; + transform: translateX(-50%); + } + + .navbar-placeholder { + width: 60px; + height: 60px; + flex-shrink: 0; + } +} + +/* ========== 加载状态 ========== */ +.loading-wrapper { + display: flex; + align-items: center; + justify-content: center; + height: 400px; + + .loading-text { + font-size: 28px; + color: #c8c9cc; + } +} + +/* ========== 表单区域 ========== */ +.profile-edit-form { + margin-top: 16px; + background: #fff; +} + +.form-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28px 32px; + border-bottom: 1px solid #f0f0f0; + + .form-label { + font-size: 30px; + color: #323233; + flex-shrink: 0; + margin-right: 24px; + } + + .form-input-wrapper { + flex: 1; + display: flex; + justify-content: flex-end; + } + + .form-input { + text-align: right; + font-size: 30px; + color: #323233; + width: 100%; + + &::placeholder { + color: #c8c9cc; + } + } + + .form-value-wrapper { + flex: 1; + display: flex; + justify-content: flex-end; + } + + .form-value { + font-size: 30px; + color: #323233; + + &--readonly { + color: #969799; + } + } +} + +/* 头像行 */ +.form-item--avatar { + .avatar-picker-btn { + position: relative; + width: 100px; + height: 100px; + padding: 0; + margin: 0; + border-radius: 50%; + background: transparent; + + &::after { + border: none; + } + + .avatar-img { + width: 100px; + height: 100px; + border-radius: 50%; + } + + .avatar-arrow { + position: absolute; + right: -8px; + top: 50%; + transform: translateY(-50%); + + .arrow-icon { + font-size: 36px; + color: #c8c9cc; + font-weight: 300; + } + } + } +} + +/* 性别行 */ +.form-item--gender { + .gender-options { + display: flex; + gap: 16px; + } + + .gender-tag { + padding: 8px 28px; + border-radius: 32px; + border: 2px solid #ebedf0; + background: #fff; + transition: all 0.2s; + + .gender-tag-text { + font-size: 26px; + color: #646566; + } + + &--active { + border-color: #1989fa; + background: #eaf3ff; + + .gender-tag-text { + color: #1989fa; + font-weight: 500; + } + } + } +} + +/* ========== 保存按钮 ========== */ +.profile-edit-footer { + padding: 48px 32px; +} + +.save-btn { + width: 100%; + height: 96px; + line-height: 96px; + background: linear-gradient(160deg, #1989fa 0%, #07c160 100%); + color: #fff; + font-size: 34px; + font-weight: 500; + border: none; + border-radius: 48px; + text-align: center; + padding: 0; + box-shadow: 0 6px 24px rgba(25, 137, 250, 0.35); + transition: opacity 0.2s; + + &::after { + border: none; + } + + &--disabled { + opacity: 0.5; + box-shadow: none; + } +} diff --git a/src/pages/profile-edit/index.tsx b/src/pages/profile-edit/index.tsx new file mode 100644 index 0000000..04d8119 --- /dev/null +++ b/src/pages/profile-edit/index.tsx @@ -0,0 +1,272 @@ +import { useState, useMemo, useEffect, useCallback } from 'react' +import Taro from '@tarojs/taro' +import { View, Text, Button, Input, Image } from '@tarojs/components' +import useAuthStore from '@/stores/auth/useAuthStore' +import { getUserInfoApi, updateUserProfileApi } from '@/services/user' +import type { Gender } from '@/types/user' +import './index.less' + +/** 性别选项 */ +const GENDER_OPTIONS: { value: Gender; label: string }[] = [ + { value: 0, label: '未知' }, + { value: 1, label: '男' }, + { value: 2, label: '女' }, +] + +/** 默认头像占位图(灰色圆) */ +const DEFAULT_AVATAR = + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iNTAiIGZpbGw9IiNFNUU1RTUiLz48L3N2Zz4=' + +/** 状态栏高度 */ +function getStatusBarHeight(): number { + try { + return Taro.getSystemInfoSync().statusBarHeight || 20 + } catch { + return 20 + } +} + +/** 导航栏高度 */ +const NAV_BAR_HEIGHT = 44 + +export default function ProfileEditPage() { + const statusBarH = useMemo(getStatusBarHeight, []) + const storeUser = useAuthStore(s => s.user) + const setUser = useAuthStore(s => s.setUser) + + // 本地编辑状态 + const [avatar, setAvatar] = useState('') + const [nickname, setNickname] = useState('') + const [gender, setGender] = useState(0) + const [mobile, setMobile] = useState('') + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + + // 记录初始值用于判断是否有修改 + const [initialAvatar, setInitialAvatar] = useState('') + const [initialNickname, setInitialNickname] = useState('') + const [initialGender, setInitialGender] = useState(0) + + /** 加载用户信息 */ + useEffect(() => { + ;(async () => { + try { + setLoading(true) + // 优先从 store 快速展示,再从 API 获取脱敏后的手机号 + if (storeUser) { + setAvatar(storeUser.avatar || '') + setNickname(storeUser.nickname || '') + setGender(storeUser.gender) + setMobile(storeUser.mobile || '') + setInitialAvatar(storeUser.avatar || '') + setInitialNickname(storeUser.nickname || '') + setInitialGender(storeUser.gender) + } + + // 从 API 获取最新数据(含脱敏手机号) + const res = await getUserInfoApi() + const user = res.data + setAvatar(user.avatar || '') + setNickname(user.nickname || '') + setGender(user.gender) + setMobile(user.mobile || '') + setInitialAvatar(user.avatar || '') + setInitialNickname(user.nickname || '') + setInitialGender(user.gender) + } catch { + // API 失败则使用 store 数据(已展示) + } finally { + setLoading(false) + } + })() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + /** 是否已修改 */ + const isModified = useMemo( + () => + avatar !== initialAvatar || + nickname !== initialNickname || + gender !== initialGender, + [avatar, nickname, gender, initialAvatar, initialNickname, initialGender], + ) + + /** 返回上一页 */ + const handleBack = useCallback(() => { + Taro.navigateBack() + }, []) + + /** 选择头像 */ + const handleChooseAvatar = useCallback((e: any) => { + const url = e.detail?.avatarUrl + if (url) { + setAvatar(url) + } + }, []) + + /** 昵称变化 */ + const handleNicknameChange = useCallback((e: any) => { + setNickname(e.detail?.value || '') + }, []) + + /** 选择性别 */ + const handleGenderChange = useCallback((value: Gender) => { + setGender(value) + }, []) + + /** 保存 */ + const handleSave = useCallback(async () => { + if (submitting) return + + // 校验 + if (nickname && nickname.length > 32) { + Taro.showToast({ title: '昵称不能超过 32 个字符', icon: 'none' }) + return + } + if (avatar && avatar.length > 500) { + Taro.showToast({ title: '头像地址过长', icon: 'none' }) + return + } + + setSubmitting(true) + try { + const params: { nickname?: string; avatar?: string; gender?: Gender } = { + nickname, avatar, gender + } + + const res = await updateUserProfileApi(params) + // 同步更新 store + setUser(res.data) + Taro.showToast({ title: '保存成功', icon: 'success' }) + setTimeout(() => { + Taro.navigateBack() + }, 1200) + } catch { + Taro.showToast({ title: '保存失败,请重试', icon: 'none' }) + } finally { + setSubmitting(false) + } + }, [ + submitting, + avatar, + nickname, + gender, + initialAvatar, + initialNickname, + initialGender, + setUser, + ]) + + /** 手机号展示(已脱敏或以 **** 显示) */ + const mobileDisplay = useMemo(() => { + if (!mobile) return '未绑定' + return mobile + }, [mobile]) + + if (loading) { + return ( + + + + + + + 编辑资料 + + + + + 加载中... + + + ) + } + + return ( + + {/* ========== 自定义导航栏 ========== */} + + + + + + 编辑资料 + + + + + {/* ========== 表单区 ========== */} + + {/* 头像 */} + + 头像 + + + + {/* 昵称 */} + + 昵称 + + + + + + {/* 性别 */} + + 性别 + + {GENDER_OPTIONS.map(opt => ( + handleGenderChange(opt.value)} + > + {opt.label} + + ))} + + + + {/* 手机号(只读) */} + + 手机号 + + {mobileDisplay} + + + + + {/* 保存按钮 */} + + + + + ) +} diff --git a/src/pages/profile/index.less b/src/pages/profile/index.less index 09ea606..1cb98ab 100644 --- a/src/pages/profile/index.less +++ b/src/pages/profile/index.less @@ -72,6 +72,18 @@ flex-shrink: 0; } + .avatar-placeholder { + width: 120px; + height: 120px; + border-radius: 50%; + border: 4px solid rgba(255, 255, 255, 0.4); + background: rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + .user-info { flex: 1; margin-left: 24px; @@ -88,6 +100,11 @@ margin-right: 12px; } + .login-text { + font-size: 36px; + font-weight: 500; + } + .gender-tag { font-size: 22px; width: 36px; diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 4fe22f3..3f7868d 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,7 +1,10 @@ -import { useMemo } from 'react' +import { useMemo, useCallback } from 'react' import Taro from '@tarojs/taro' import { View, Text, Image, ScrollView } from '@tarojs/components' import { Cell, CellGroup, Icon } from '@antmjs/vantui' +import useAuthStore from '@/stores/auth/useAuthStore' +import { GENDER_MAP } from '@/types/user' +import type { Gender } from '@/types/user' import './index.less' /** 状态栏高度 */ @@ -13,13 +16,13 @@ function getStatusBarHeight(): number { } } -/** 用户信息(后续接入 API) */ -const USER_INFO = { +/** 默认用户信息(未登录时展示占位) */ +const DEFAULT_USER = { avatar: 'https://picsum.photos/seed/avatar/200/200', - name: '张三', - className: '计算机科学与技术 2022级', + name: '未登录', + className: '点击登录体验更多功能', gender: '男' as const, - stats: { likes: 128, comments: 46, favorites: 89 }, + stats: { likes: '-', comments: '-', favorites: '-' }, } /** 订单入口 */ @@ -37,32 +40,80 @@ const FEATURE_LIST = [ { key: 'address', label: '地址管理', icon: 'location-o' }, { key: 'about', label: '关于我们', icon: 'info-o' }, { key: 'feedback', label: '意见反馈', icon: 'chat-o' }, - { key: 'settings', label: '系统设置', icon: 'setting-o' }, ] as const export default function Profile() { const statusBarH = useMemo(getStatusBarHeight, []) + const user = useAuthStore(s => s.user) + const logout = useAuthStore(s => s.logout) + const isLoggedIn = useAuthStore(s => !!s.token && !!s.user) + + /** 跳转登录页 */ + const handleGoToLogin = useCallback(() => { + Taro.navigateTo({ url: '/pages/login/index' }) + }, []) const handleSettings = () => { Taro.showToast({ title: '设置页面即将上线', icon: 'none' }) } const handleEditProfile = () => { - Taro.showToast({ title: '编辑资料即将上线', icon: 'none' }) + if (isLoggedIn) { + Taro.navigateTo({ url: '/pages/profile-edit/index' }) + } else { + handleGoToLogin() + } } const handleOrderTab = (key: string) => { + if (!isLoggedIn) { + handleGoToLogin() + return + } Taro.showToast({ title: `${ORDER_TABS.find(t => t.key === key)?.label || ''}订单即将上线`, icon: 'none' }) } const handleFeature = (item: typeof FEATURE_LIST[number]) => { + if (!isLoggedIn) { + handleGoToLogin() + return + } Taro.showToast({ title: `${item.label}即将上线`, icon: 'none' }) } const handleAllOrders = () => { + if (!isLoggedIn) { + handleGoToLogin() + return + } Taro.showToast({ title: '全部订单即将上线', icon: 'none' }) } + /** 退出登录 */ + const handleLogout = useCallback(() => { + Taro.showModal({ + title: '提示', + content: '确定要退出登录吗?', + success: (res) => { + if (res.confirm) { + logout() + Taro.showToast({ title: '已退出登录', icon: 'success' }) + } + }, + }) + }, [logout]) + + // 根据登录状态决定展示的用户信息 + const displayUser = isLoggedIn && user + ? { + avatar: user.avatar, + name: user.nickname, + className: '在校学生', // 后续可扩展班级字段 + gender: GENDER_MAP[user.gender] || '未知', + stats: { likes: '-', comments: '-', favorites: '-' }, // 暂用占位,后续从 API 获取 + } + : DEFAULT_USER + return ( {/* ========== 背景图区域 ========== */} @@ -78,30 +129,50 @@ export default function Profile() { {/* 头像 + 用户信息 */} - - - - - {USER_INFO.name} - {USER_INFO.gender === '男' ? '♂' : '♀'} - - {USER_INFO.className} - - + + {isLoggedIn ? ( + <> + + + + {displayUser.name} + {displayUser.gender === '男' ? '♂' : displayUser.gender === '女' ? '♀' : ''} + + {displayUser.className} + + + + ) : ( + <> + + + + + + 点击登录账号 + + 登录后体验更多功能 + + + + )} {/* 点赞/评论/收藏 */} - {USER_INFO.stats.likes} + {displayUser.stats.likes} 点赞 - {USER_INFO.stats.comments} + {displayUser.stats.comments} 评论 - {USER_INFO.stats.favorites} + {displayUser.stats.favorites} 收藏 @@ -148,6 +219,21 @@ export default function Profile() { + {/* ========== 退出登录(仅登录后显示) ========== */} + {isLoggedIn && ( + + + + + + )} + {/* ========== 底部信息 ========== */} 湘ICP备2024000000号-1 diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..e194655 --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,39 @@ +import { post } from '@/utils/request' +import type { ApiResponse } from '@/types/api' +import type { User } from '@/types/user' + +/** 登录请求参数 */ +export interface WxLoginParams { + /** 前端调用 Taro.login() 获取的临时凭证(用于换取 openid/session_key) */ + code: string + /** + * 手机号动态令牌(新版微信 API) + * openType="getPhoneNumber" 回调 e.detail.code + * 后端用此 code 调用微信接口换取手机号 + */ + phoneCode?: string + /** + * 手机号加密数据(旧版微信 API,与 iv 配套使用) + * openType="getPhoneNumber" 回调 e.detail.encryptedData + */ + encryptedData?: string + /** + * 加密算法初始向量(旧版微信 API,与 encryptedData 配套使用) + * openType="getPhoneNumber" 回调 e.detail.iv + */ + iv?: string +} + +/** 登录响应 data */ +export interface WxLoginData { + token: string + user: User +} + +/** + * 微信小程序登录 + * POST /api/wx/login(公开接口,不附带 token) + */ +export function wxLoginApi(params: WxLoginParams): Promise> { + return post('/api/wx/login', params, { skipToken: true }) +} diff --git a/src/services/user.ts b/src/services/user.ts new file mode 100644 index 0000000..197f8a0 --- /dev/null +++ b/src/services/user.ts @@ -0,0 +1,26 @@ +import { get, put } from '@/utils/request' +import type { ApiResponse } from '@/types/api' +import type { User, UpdateProfileParams } from '@/types/user' + +/** + * 获取当前用户信息 + * GET /api/user(需 Bearer token 认证) + * + * 注意:返回的 mobile 和 email 已脱敏(如 138****5678) + */ +export function getUserInfoApi(): Promise> { + return get('/api/user') +} + +/** + * 编辑用户资料 + * PUT /api/user/profile(需 Bearer token 认证) + * + * 三个字段均可选,只更新传入的字段。 + * 返回完整用户数据(含未脱敏的手机号和邮箱) + */ +export function updateUserProfileApi( + params: UpdateProfileParams, +): Promise> { + return put('/api/user/profile', params) +} diff --git a/src/stores/auth/useAuthStore.ts b/src/stores/auth/useAuthStore.ts new file mode 100644 index 0000000..d3e969f --- /dev/null +++ b/src/stores/auth/useAuthStore.ts @@ -0,0 +1,84 @@ +import { create } from 'zustand' +import Taro from '@tarojs/taro' +import { wxLoginApi } from '@/services/auth' +import type { WxLoginParams } from '@/services/auth' +import type { User } from '@/types/user' + +/** 存储 key */ +const STORAGE_KEYS = { + TOKEN: 'auth_token', + USER: 'auth_user', +} as const + +/** 从本地存储恢复登录态 */ +function loadFromStorage(): { user: User | null; token: string | null } { + try { + const storedToken = Taro.getStorageSync(STORAGE_KEYS.TOKEN) + const storedUser = Taro.getStorageSync(STORAGE_KEYS.USER) + if (storedToken && storedUser) { + return { token: storedToken, user: JSON.parse(storedUser) } + } + } catch { + // 存储数据损坏,清除并视为未登录 + try { Taro.removeStorageSync(STORAGE_KEYS.TOKEN) } catch { /* noop */ } + try { Taro.removeStorageSync(STORAGE_KEYS.USER) } catch { /* noop */ } + } + return { user: null, token: null } +} + +interface AuthState { + user: User | null + token: string | null + loading: boolean + login: (params: WxLoginParams) => Promise + logout: () => void + /** 更新用户信息(用于编辑资料后同步 store) */ + setUser: (user: User) => void +} + +const useAuthStore = create((set) => { + // 初始化时从 storage 恢复 + const initial = loadFromStorage() + + return { + user: initial.user, + token: initial.token, + loading: !!(initial.token && initial.user), // 已恢复则立即 ready + + /** 登录 */ + login: async (params: WxLoginParams) => { + const res = await wxLoginApi(params) + const { token, user } = res.data + set({ user, token }) + try { + Taro.setStorageSync(STORAGE_KEYS.TOKEN, token) + Taro.setStorageSync(STORAGE_KEYS.USER, JSON.stringify(user)) + } catch { + // storage 写入失败不阻塞登录流程 + } + }, + + /** 退出登录 */ + logout: () => { + set({ user: null, token: null }) + try { + Taro.removeStorageSync(STORAGE_KEYS.TOKEN) + Taro.removeStorageSync(STORAGE_KEYS.USER) + } catch { + // noop + } + }, + + /** 更新用户信息(编辑资料后同步 store + storage) */ + setUser: (user: User) => { + set({ user }) + try { + Taro.setStorageSync(STORAGE_KEYS.USER, JSON.stringify(user)) + } catch { + // storage 写入失败不阻塞 + } + }, + } +}) + +export default useAuthStore diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..f068144 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,17 @@ +import Taro from "@tarojs/taro"; + +/** API 统一返回结构 */ +export interface ApiResponse { + success: boolean + code: number + msg?: string + data: T +} + +/** 请求配置(扩展 Taro 原生配置) */ +export interface RequestConfig extends Taro.request.Option { + /** 是否跳过 token 附加(如登录接口) */ + skipToken?: boolean + /** 是否跳过错误提示(业务自行处理) */ + skipErrorToast?: boolean +} diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..55ababf --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,57 @@ +/** 性别枚举 */ +export type Gender = 0 | 1 | 2 + +/** 性别映射 */ +export const GENDER_MAP: Record = { + 0: '未知', + 1: '男', + 2: '女', +} + +/** 用户信息 */ +export interface User { + /** 用户 ID */ + id: number + /** 用户名(微信生成) */ + username: string + /** 用户昵称 */ + nickname: string + /** 用户头像 URL */ + avatar: string + /** 手机号(脱敏后可能为 138****5678) */ + mobile: string + /** 性别:0 未知 / 1 男 / 2 女 */ + gender: Gender + /** 邮箱(脱敏后可能为 t***@example.com) */ + email: string + /** 微信 openid */ + openid: string + /** 微信 unionid(非必返) */ + unionid?: string + /** 生日 */ + birthday: string | null + /** 余额 */ + balance: string + /** 状态:1 正常 */ + status: number + /** 注册时间 */ + created_at: string + /** 更新时间 */ + updated_at: string +} + +/** 登录返回结果 */ +export interface AuthResult { + user: User + token: string +} + +/** 编辑用户资料请求参数 */ +export interface UpdateProfileParams { + /** 昵称,最长 32 字符 */ + nickname?: string + /** 头像 URL,最长 500 字符 */ + avatar?: string + /** 性别:0 未知 / 1 男 / 2 女 */ + gender?: Gender +} diff --git a/src/utils/request.ts b/src/utils/request.ts new file mode 100644 index 0000000..13f03f7 --- /dev/null +++ b/src/utils/request.ts @@ -0,0 +1,198 @@ +import Taro from '@tarojs/taro' +import type { ApiResponse, RequestConfig } from '@/types/api' + +/** 存储 key(与 AuthContext 保持一致) */ +const STORAGE_TOKEN_KEY = 'auth_token' + +/** 登录页路径 */ +const LOGIN_PATH = '/pages/login/index' + +/** 默认请求超时(ms) */ +const DEFAULT_TIMEOUT = 15000 +const BASE_URL = "http://localhost:8000/index.php" + +/** + * HTTP 状态码 → 错误提示映射 + */ +const HTTP_ERROR_MAP: Record = { + 400: '参数不正确', + 401: '登录已过期,请重新登录', + 403: '您没有权限操作', + 404: '请求的资源不存在', + 408: '请求超时', + 500: '服务器内部错误', + 502: '网关错误', + 503: '服务暂时不可用', + 504: '网关超时', +} + +/** 业务状态码常量 */ +const BIZ_CODE = { + SUCCESS: 0, +} as const + +/** + * 获取本地存储的 token + */ +function getToken(): string | null { + try { + return Taro.getStorageSync(STORAGE_TOKEN_KEY) || null + } catch { + return null + } +} + +/** + * 清除本地认证信息 + */ +function clearAuth(): void { + try { + Taro.removeStorageSync(STORAGE_TOKEN_KEY) + Taro.removeStorageSync('auth_user') + } catch { + // noop + } +} + +/** + * 处理 HTTP 状态码错误 + * @param statusCode - HTTP 状态码 + */ +function handleHttpError(statusCode: number): void { + // 401 → 清除登录态并跳转登录页 + if (statusCode === 401) { + clearAuth() + Taro.showToast({ title: '登录已过期,请重新登录', icon: 'none' }) + // 避免在登录页重复跳转 + const pages = Taro.getCurrentPages() + const currentPage = pages[pages.length - 1] + if (currentPage?.route !== 'pages/login/index') { + setTimeout(() => { + Taro.navigateTo({ url: LOGIN_PATH }) + }, 800) + } + return + } + + const message = HTTP_ERROR_MAP[statusCode] || `请求失败 (状态码: ${statusCode})` + Taro.showToast({ title: message, icon: 'none' }) +} + +/** + * 处理业务错误 + * @param data - 接口返回数据 + */ +function handleBusinessError(data: ApiResponse): void { + const { msg } = data + if (msg) { + Taro.showToast({ title: msg, icon: 'none' }) + } +} + +/** + * 发起网络请求 + * + * @example + * ```ts + * // GET 请求 + * const res = await request({ url: '/api/user/info' }) + * + * // POST 请求 + * const res = await request({ url: '/api/order/create', method: 'POST', data: { id: 1 } }) + * + * // 跳过 token(如登录接口) + * const res = await request({ url: '/api/auth/login', method: 'POST', skipToken: true }) + * ``` + */ +export function request(config: RequestConfig): Promise> { + const { skipToken, skipErrorToast, ...restConfig } = config + + // 构建请求头 + const header: Record = { + 'Content-Type': 'application/json', + ...((restConfig.header as Record) || {}), + } + + // 自动附加 token + if (!skipToken) { + const token = getToken() + if (token) { + header['Authorization'] = `Bearer ${token}` + } + } + + return new Promise((resolve, reject) => { + Taro.request({ + ...restConfig, + url: BASE_URL + restConfig.url, + header, + timeout: restConfig.timeout || DEFAULT_TIMEOUT, + success(res) { + const { statusCode, data } = res + + // HTTP 状态码异常 + if (statusCode < 200 || statusCode >= 300) { + if (!skipErrorToast) { + handleHttpError(statusCode) + } + reject(res) + return + } + + const responseData = data as ApiResponse + + // 业务成功 + if (responseData.success) { + resolve(responseData) + return + } + + // 业务失败 + if (!skipErrorToast) { + handleBusinessError(responseData) + } + reject(responseData) + }, + fail(err) { + // 网络错误 / 超时 + const errMsg = err.errMsg || '' + if (errMsg.includes('timeout')) { + Taro.showToast({ title: '请求超时,请稍后重试', icon: 'none' }) + } else if (errMsg.includes('fail')) { + Taro.showToast({ title: '网络连接失败,请检查网络', icon: 'none' }) + } else { + Taro.showToast({ title: '网络错误,请稍后重试', icon: 'none' }) + } + reject(err) + }, + }) + }) +} + +/** + * GET 请求快捷方法 + */ +export function get(url: string, config?: Omit) { + return request({ ...config, url, method: 'GET' }) +} + +/** + * POST 请求快捷方法 + */ +export function post(url: string, data?: any, config?: Omit) { + return request({ ...config, url, method: 'POST', data }) +} + +/** + * PUT 请求快捷方法 + */ +export function put(url: string, data?: any, config?: Omit) { + return request({ ...config, url, method: 'PUT', data }) +} + +/** + * DELETE 请求快捷方法 + */ +export function del(url: string, config?: Omit) { + return request({ ...config, url, method: 'DELETE' }) +} diff --git a/yarn.lock b/yarn.lock index 9200805..9a87048 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11538,6 +11538,11 @@ yup@^1.2.0: toposort "^2.0.2" type-fest "^2.19.0" +zustand@^5.0.14: + version "5.0.14" + resolved "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz#18216c24fcb980cf36898f9c57520e67b1f77855" + integrity sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.npmmirror.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"