登录注册
This commit is contained in:
+2
-1
@@ -46,7 +46,8 @@
|
|||||||
"@tarojs/taro": "3.6.14",
|
"@tarojs/taro": "3.6.14",
|
||||||
"@tarojs/taro-h5": "3.6.14",
|
"@tarojs/taro-h5": "3.6.14",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antmjs/plugin-global-fix": "^2.3.21",
|
"@antmjs/plugin-global-fix": "^2.3.21",
|
||||||
|
|||||||
+2
-2
@@ -4,10 +4,10 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"appid": "wx7ed74d60503b5ee3",
|
"appid": "wx7ed74d60503b5ee3",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": true,
|
"urlCheck": false,
|
||||||
"es6": false,
|
"es6": false,
|
||||||
"postcss": false,
|
"postcss": false,
|
||||||
"minified": false,
|
"minified": true,
|
||||||
"enhance": false
|
"enhance": false
|
||||||
},
|
},
|
||||||
"compileType": "miniprogram"
|
"compileType": "miniprogram"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default defineAppConfig({
|
|||||||
'pages/messages/index',
|
'pages/messages/index',
|
||||||
'pages/profile/index',
|
'pages/profile/index',
|
||||||
'pages/article/index',
|
'pages/article/index',
|
||||||
|
'pages/login/index',
|
||||||
|
'pages/profile-edit/index',
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'light',
|
backgroundTextStyle: 'light',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class App extends Component {
|
|||||||
|
|
||||||
// this.props.children 是将要会渲染的页面
|
// this.props.children 是将要会渲染的页面
|
||||||
render () {
|
render () {
|
||||||
|
// @ts-ignore
|
||||||
return this.props.children
|
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;
|
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 {
|
.user-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 24px;
|
margin-left: 24px;
|
||||||
@@ -88,6 +100,11 @@
|
|||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-text {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.gender-tag {
|
.gender-tag {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
|
|||||||
+102
-16
@@ -1,7 +1,10 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, useCallback } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||||
import { Cell, CellGroup, Icon } from '@antmjs/vantui'
|
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'
|
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',
|
avatar: 'https://picsum.photos/seed/avatar/200/200',
|
||||||
name: '张三',
|
name: '未登录',
|
||||||
className: '计算机科学与技术 2022级',
|
className: '点击登录体验更多功能',
|
||||||
gender: '男' as const,
|
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: 'address', label: '地址管理', icon: 'location-o' },
|
||||||
{ key: 'about', label: '关于我们', icon: 'info-o' },
|
{ key: 'about', label: '关于我们', icon: 'info-o' },
|
||||||
{ key: 'feedback', label: '意见反馈', icon: 'chat-o' },
|
{ key: 'feedback', label: '意见反馈', icon: 'chat-o' },
|
||||||
{ key: 'settings', label: '系统设置', icon: 'setting-o' },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const statusBarH = useMemo(getStatusBarHeight, [])
|
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 = () => {
|
const handleSettings = () => {
|
||||||
Taro.showToast({ title: '设置页面即将上线', icon: 'none' })
|
Taro.showToast({ title: '设置页面即将上线', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditProfile = () => {
|
const handleEditProfile = () => {
|
||||||
Taro.showToast({ title: '编辑资料即将上线', icon: 'none' })
|
if (isLoggedIn) {
|
||||||
|
Taro.navigateTo({ url: '/pages/profile-edit/index' })
|
||||||
|
} else {
|
||||||
|
handleGoToLogin()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOrderTab = (key: string) => {
|
const handleOrderTab = (key: string) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
handleGoToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
Taro.showToast({ title: `${ORDER_TABS.find(t => t.key === key)?.label || ''}订单即将上线`, icon: 'none' })
|
Taro.showToast({ title: `${ORDER_TABS.find(t => t.key === key)?.label || ''}订单即将上线`, icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFeature = (item: typeof FEATURE_LIST[number]) => {
|
const handleFeature = (item: typeof FEATURE_LIST[number]) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
handleGoToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
Taro.showToast({ title: `${item.label}即将上线`, icon: 'none' })
|
Taro.showToast({ title: `${item.label}即将上线`, icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAllOrders = () => {
|
const handleAllOrders = () => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
handleGoToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
Taro.showToast({ title: '全部订单即将上线', icon: 'none' })
|
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 (
|
return (
|
||||||
<View className='profile-page'>
|
<View className='profile-page'>
|
||||||
{/* ========== 背景图区域 ========== */}
|
{/* ========== 背景图区域 ========== */}
|
||||||
@@ -78,30 +129,50 @@ export default function Profile() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 头像 + 用户信息 */}
|
{/* 头像 + 用户信息 */}
|
||||||
<View className='user-card' onClick={handleEditProfile}>
|
<View
|
||||||
<Image className='avatar' src={USER_INFO.avatar} mode='aspectFill' />
|
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='user-info'>
|
||||||
<View className='name-row'>
|
<View className='name-row'>
|
||||||
<Text className='name'>{USER_INFO.name}</Text>
|
<Text className='name'>{displayUser.name}</Text>
|
||||||
<Text className='gender-tag'>{USER_INFO.gender === '男' ? '♂' : '♀'}</Text>
|
<Text className='gender-tag'>{displayUser.gender === '男' ? '♂' : displayUser.gender === '女' ? '♀' : ''}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className='class-name'>{USER_INFO.className}</Text>
|
<Text className='class-name'>{displayUser.className}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Icon name='arrow' size={18} color='rgba(255,255,255,0.7)' />
|
<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>
|
||||||
|
|
||||||
{/* 点赞/评论/收藏 */}
|
{/* 点赞/评论/收藏 */}
|
||||||
<View className='stats-row'>
|
<View className='stats-row'>
|
||||||
<View className='stat-item'>
|
<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>
|
<Text className='stat-label'>点赞</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='stat-item'>
|
<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>
|
<Text className='stat-label'>评论</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='stat-item'>
|
<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>
|
<Text className='stat-label'>收藏</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -148,6 +219,21 @@ export default function Profile() {
|
|||||||
</CellGroup>
|
</CellGroup>
|
||||||
</View>
|
</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'>
|
<View className='footer-info'>
|
||||||
<Text className='footer-icp'>湘ICP备2024000000号-1</Text>
|
<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"
|
toposort "^2.0.2"
|
||||||
type-fest "^2.19.0"
|
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:
|
zwitch@^1.0.0:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.npmmirror.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
resolved "https://registry.npmmirror.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
||||||
|
|||||||
Reference in New Issue
Block a user