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