登录注册
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@
|
||||
"description": "",
|
||||
"appid": "wx7ed74d60503b5ee3",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"urlCheck": false,
|
||||
"es6": false,
|
||||
"postcss": false,
|
||||
"minified": false,
|
||||
"minified": true,
|
||||
"enhance": false
|
||||
},
|
||||
"compileType": "miniprogram"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -13,6 +13,7 @@ class App extends Component {
|
||||
|
||||
// this.props.children 是将要会渲染的页面
|
||||
render () {
|
||||
// @ts-ignore
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '登录',
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<View className='login-page'>
|
||||
{/* ========== 自定义导航栏 ========== */}
|
||||
<View className='login-navbar' style={{ paddingTop: `${statusBarH}px` }}>
|
||||
<View className='navbar-inner' style={{ height: `${NAV_BAR_HEIGHT}px` }}>
|
||||
<View className='navbar-back' onClick={handleBack}>
|
||||
<Text className='back-arrow'>‹</Text>
|
||||
</View>
|
||||
<Text className='navbar-title'>登录</Text>
|
||||
<View className='navbar-placeholder' />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ========== 内容区域 ========== */}
|
||||
<View className='login-content'>
|
||||
{/* 品牌区域 */}
|
||||
<View className='login-brand'>
|
||||
<View className='logo-wrapper'>
|
||||
<Text className='logo-text'>校</Text>
|
||||
</View>
|
||||
<Text className='app-name'>校园通</Text>
|
||||
<Text className='app-slogan'>校园生活,一触即达</Text>
|
||||
</View>
|
||||
|
||||
{/* 功能介绍 */}
|
||||
<View className='login-features'>
|
||||
<Text className='feature-text'>发布任务 · 二手交易 · 校园资讯</Text>
|
||||
</View>
|
||||
|
||||
{/* 登录操作 */}
|
||||
<View className='login-actions'>
|
||||
<Button
|
||||
className={`login-btn ${submitting ? 'login-btn--loading' : ''}`}
|
||||
openType='getPhoneNumber'
|
||||
onGetPhoneNumber={handleGetPhoneNumber}
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '登录中...' : '微信手机号授权登录'}
|
||||
</Button>
|
||||
|
||||
<View className='login-agreement'>
|
||||
<Text className='agree-text'>登录即代表同意</Text>
|
||||
<Text className='agree-link' onClick={handleShowAgreement}>
|
||||
《用户协议》
|
||||
</Text>
|
||||
<Text className='agree-text'>和</Text>
|
||||
<Text className='agree-link' onClick={handleShowPrivacy}>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '编辑资料',
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<Gender>(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<Gender>(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 (
|
||||
<View className='profile-edit-page'>
|
||||
<View className='profile-edit-navbar' style={{ paddingTop: `${statusBarH}px` }}>
|
||||
<View className='navbar-inner' style={{ height: `${NAV_BAR_HEIGHT}px` }}>
|
||||
<View className='navbar-back' onClick={handleBack}>
|
||||
<Text className='back-arrow'>‹</Text>
|
||||
</View>
|
||||
<Text className='navbar-title'>编辑资料</Text>
|
||||
<View className='navbar-placeholder' />
|
||||
</View>
|
||||
</View>
|
||||
<View className='loading-wrapper'>
|
||||
<Text className='loading-text'>加载中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='profile-edit-page'>
|
||||
{/* ========== 自定义导航栏 ========== */}
|
||||
<View className='profile-edit-navbar' style={{ paddingTop: `${statusBarH}px` }}>
|
||||
<View className='navbar-inner' style={{ height: `${NAV_BAR_HEIGHT}px` }}>
|
||||
<View className='navbar-back' onClick={handleBack}>
|
||||
<Text className='back-arrow'>‹</Text>
|
||||
</View>
|
||||
<Text className='navbar-title'>编辑资料</Text>
|
||||
<View className='navbar-placeholder' />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ========== 表单区 ========== */}
|
||||
<View className='profile-edit-form'>
|
||||
{/* 头像 */}
|
||||
<View className='form-item form-item--avatar'>
|
||||
<Text className='form-label'>头像</Text>
|
||||
<Button
|
||||
className='avatar-picker-btn'
|
||||
openType='chooseAvatar'
|
||||
onChooseAvatar={handleChooseAvatar}
|
||||
>
|
||||
<Image
|
||||
className='avatar-img'
|
||||
src={avatar || DEFAULT_AVATAR}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
<View className='avatar-arrow'>
|
||||
<Text className='arrow-icon'>›</Text>
|
||||
</View>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* 昵称 */}
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>昵称</Text>
|
||||
<View className='form-input-wrapper'>
|
||||
<Input
|
||||
className='form-input'
|
||||
type='nickname'
|
||||
placeholder='请输入昵称'
|
||||
value={nickname}
|
||||
onInput={handleNicknameChange}
|
||||
onBlur={handleNicknameChange}
|
||||
maxlength={32}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 性别 */}
|
||||
<View className='form-item form-item--gender'>
|
||||
<Text className='form-label'>性别</Text>
|
||||
<View className='gender-options'>
|
||||
{GENDER_OPTIONS.map(opt => (
|
||||
<View
|
||||
key={opt.value}
|
||||
className={`gender-tag ${gender === opt.value ? 'gender-tag--active' : ''}`}
|
||||
onClick={() => handleGenderChange(opt.value)}
|
||||
>
|
||||
<Text className='gender-tag-text'>{opt.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 手机号(只读) */}
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>手机号</Text>
|
||||
<View className='form-value-wrapper'>
|
||||
<Text className='form-value form-value--readonly'>{mobileDisplay}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<View className='profile-edit-footer'>
|
||||
<Button
|
||||
className={`save-btn ${!isModified || submitting ? 'save-btn--disabled' : ''}`}
|
||||
onClick={handleSave}
|
||||
disabled={!isModified || submitting}
|
||||
loading={submitting}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+107
-21
@@ -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 (
|
||||
<View className='profile-page'>
|
||||
{/* ========== 背景图区域 ========== */}
|
||||
@@ -78,30 +129,50 @@ export default function Profile() {
|
||||
</View>
|
||||
|
||||
{/* 头像 + 用户信息 */}
|
||||
<View className='user-card' onClick={handleEditProfile}>
|
||||
<Image className='avatar' src={USER_INFO.avatar} mode='aspectFill' />
|
||||
<View className='user-info'>
|
||||
<View className='name-row'>
|
||||
<Text className='name'>{USER_INFO.name}</Text>
|
||||
<Text className='gender-tag'>{USER_INFO.gender === '男' ? '♂' : '♀'}</Text>
|
||||
</View>
|
||||
<Text className='class-name'>{USER_INFO.className}</Text>
|
||||
</View>
|
||||
<Icon name='arrow' size={18} color='rgba(255,255,255,0.7)' />
|
||||
<View
|
||||
className={`user-card ${!isLoggedIn ? 'user-card--login' : ''}`}
|
||||
onClick={isLoggedIn ? handleEditProfile : handleGoToLogin}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Image className='avatar' src={displayUser.avatar} mode='aspectFill' />
|
||||
<View className='user-info'>
|
||||
<View className='name-row'>
|
||||
<Text className='name'>{displayUser.name}</Text>
|
||||
<Text className='gender-tag'>{displayUser.gender === '男' ? '♂' : displayUser.gender === '女' ? '♀' : ''}</Text>
|
||||
</View>
|
||||
<Text className='class-name'>{displayUser.className}</Text>
|
||||
</View>
|
||||
<Icon name='arrow' size={18} color='rgba(255,255,255,0.7)' />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View className='avatar-placeholder'>
|
||||
<Icon name='user-circle-o' size={48} color='rgba(255,255,255,0.7)' />
|
||||
</View>
|
||||
<View className='user-info'>
|
||||
<View className='name-row'>
|
||||
<Text className='name login-text'>点击登录账号</Text>
|
||||
</View>
|
||||
<Text className='class-name'>登录后体验更多功能</Text>
|
||||
</View>
|
||||
<Icon name='arrow' size={18} color='rgba(255,255,255,0.7)' />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 点赞/评论/收藏 */}
|
||||
<View className='stats-row'>
|
||||
<View className='stat-item'>
|
||||
<Text className='stat-num'>{USER_INFO.stats.likes}</Text>
|
||||
<Text className='stat-num'>{displayUser.stats.likes}</Text>
|
||||
<Text className='stat-label'>点赞</Text>
|
||||
</View>
|
||||
<View className='stat-item'>
|
||||
<Text className='stat-num'>{USER_INFO.stats.comments}</Text>
|
||||
<Text className='stat-num'>{displayUser.stats.comments}</Text>
|
||||
<Text className='stat-label'>评论</Text>
|
||||
</View>
|
||||
<View className='stat-item'>
|
||||
<Text className='stat-num'>{USER_INFO.stats.favorites}</Text>
|
||||
<Text className='stat-num'>{displayUser.stats.favorites}</Text>
|
||||
<Text className='stat-label'>收藏</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -148,6 +219,21 @@ export default function Profile() {
|
||||
</CellGroup>
|
||||
</View>
|
||||
|
||||
{/* ========== 退出登录(仅登录后显示) ========== */}
|
||||
{isLoggedIn && (
|
||||
<View className='section-card feature-list'>
|
||||
<CellGroup inset>
|
||||
<Cell
|
||||
title='退出登录'
|
||||
icon='cross'
|
||||
isLink
|
||||
border={false}
|
||||
onClick={handleLogout}
|
||||
/>
|
||||
</CellGroup>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ========== 底部信息 ========== */}
|
||||
<View className='footer-info'>
|
||||
<Text className='footer-icp'>湘ICP备2024000000号-1</Text>
|
||||
|
||||
@@ -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<ApiResponse<WxLoginData>> {
|
||||
return post<WxLoginData>('/api/wx/login', params, { skipToken: true })
|
||||
}
|
||||
@@ -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<ApiResponse<User>> {
|
||||
return get<User>('/api/user')
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑用户资料
|
||||
* PUT /api/user/profile(需 Bearer token 认证)
|
||||
*
|
||||
* 三个字段均可选,只更新传入的字段。
|
||||
* 返回完整用户数据(含未脱敏的手机号和邮箱)
|
||||
*/
|
||||
export function updateUserProfileApi(
|
||||
params: UpdateProfileParams,
|
||||
): Promise<ApiResponse<User>> {
|
||||
return put<User>('/api/user/profile', params)
|
||||
}
|
||||
@@ -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<void>
|
||||
logout: () => void
|
||||
/** 更新用户信息(用于编辑资料后同步 store) */
|
||||
setUser: (user: User) => void
|
||||
}
|
||||
|
||||
const useAuthStore = create<AuthState>((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
|
||||
@@ -0,0 +1,17 @@
|
||||
import Taro from "@tarojs/taro";
|
||||
|
||||
/** API 统一返回结构 */
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
code: number
|
||||
msg?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
/** 请求配置(扩展 Taro 原生配置) */
|
||||
export interface RequestConfig extends Taro.request.Option {
|
||||
/** 是否跳过 token 附加(如登录接口) */
|
||||
skipToken?: boolean
|
||||
/** 是否跳过错误提示(业务自行处理) */
|
||||
skipErrorToast?: boolean
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/** 性别枚举 */
|
||||
export type Gender = 0 | 1 | 2
|
||||
|
||||
/** 性别映射 */
|
||||
export const GENDER_MAP: Record<Gender, string> = {
|
||||
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
|
||||
}
|
||||
@@ -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<number, string> = {
|
||||
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<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
|
||||
const { skipToken, skipErrorToast, ...restConfig } = config
|
||||
|
||||
// 构建请求头
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((restConfig.header as Record<string, string>) || {}),
|
||||
}
|
||||
|
||||
// 自动附加 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<T>
|
||||
|
||||
// 业务成功
|
||||
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<T = any>(url: string, config?: Omit<RequestConfig, 'url' | 'method'>) {
|
||||
return request<T>({ ...config, url, method: 'GET' })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求快捷方法
|
||||
*/
|
||||
export function post<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'url' | 'method' | 'data'>) {
|
||||
return request<T>({ ...config, url, method: 'POST', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 请求快捷方法
|
||||
*/
|
||||
export function put<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'url' | 'method' | 'data'>) {
|
||||
return request<T>({ ...config, url, method: 'PUT', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求快捷方法
|
||||
*/
|
||||
export function del<T = any>(url: string, config?: Omit<RequestConfig, 'url' | 'method'>) {
|
||||
return request<T>({ ...config, url, method: 'DELETE' })
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user