Bladeren bron

Merge branch 'base' into tabbar

feige996 11 maanden geleden
bovenliggende
commit
6673c2e269

+ 5 - 4
package.json

@@ -1,7 +1,7 @@
 {
   "name": "unibest",
   "type": "commonjs",
-  "version": "2.9.1",
+  "version": "2.10.0",
   "description": "unibest - 最好的 uniapp 开发模板",
   "author": {
     "name": "feige996",
@@ -101,11 +101,12 @@
     "@tanstack/vue-query": "^5.62.16",
     "abortcontroller-polyfill": "^1.7.8",
     "dayjs": "1.11.10",
+    "js-cookie": "^3.0.5",
     "pinia": "2.0.36",
     "pinia-plugin-persistedstate": "3.2.1",
     "qs": "6.5.3",
     "vue": "^3.5.15",
-    "wot-design-uni": "^1.4.0",
+    "wot-design-uni": "^1.9.1",
     "z-paging": "^2.8.4"
   },
   "devDependencies": {
@@ -144,8 +145,8 @@
     "typescript": "^5.7.2",
     "unocss": "^66.0.0",
     "unplugin-auto-import": "^0.17.8",
-    "vite": "5.2.8",
+    "vite": "6.3.5",
     "vite-plugin-restart": "^0.4.2",
-    "vue-tsc": "^1.8.27"
+    "vue-tsc": "^2.2.10"
   }
 }

+ 6 - 0
pages.config.ts

@@ -54,6 +54,12 @@ export default defineUniPages({
         icon: 'iconfont icon-my',
         iconType: 'iconfont',
       },
+      {
+        iconPath: 'static/tabbar/personal.png',
+        selectedIconPath: 'static/tabbar/personalHL.png',
+        pagePath: 'pages/mine/index',
+        text: '我的',
+      },
     ],
   },
 })

File diff suppressed because it is too large
+ 2249 - 5374
pnpm-lock.yaml


+ 83 - 0
src/api/login.ts

@@ -0,0 +1,83 @@
+import { ICaptcha, IUpdateInfo, IUpdatePassword, IUserInfoVo, IUserLogin } from './login.typings'
+import { http } from '@/utils/http'
+
+/**
+ * 登录表单
+ */
+export interface ILoginForm {
+  username: string
+  password: string
+  code: string
+  uuid: string
+}
+
+/**
+ * 获取验证码
+ * @returns ICaptcha 验证码
+ */
+export const getCode = () => {
+  return http.get<ICaptcha>('/user/getCode')
+}
+
+/**
+ * 用户登录
+ * @param loginForm 登录表单
+ */
+export const login = (loginForm: ILoginForm) => {
+  return http.post<IUserLogin>('/user/login', loginForm)
+}
+
+/**
+ * 获取用户信息
+ */
+export const getUserInfo = () => {
+  return http.get<IUserInfoVo>('/user/info')
+}
+
+/**
+ * 退出登录
+ */
+export const logout = () => {
+  return http.get<void>('/user/logout')
+}
+
+/**
+ * 修改用户信息
+ */
+export const updateInfo = (data: IUpdateInfo) => {
+  return http.post('/user/updateInfo', data)
+}
+
+/**
+ * 修改用户密码
+ */
+export const updateUserPassword = (data: IUpdatePassword) => {
+  return http.post('/user/updatePassword', data)
+}
+
+/**
+ * 获取微信登录凭证
+ * @returns Promise 包含微信登录凭证(code)
+ */
+export const getWxCode = () => {
+  return new Promise<UniApp.LoginRes>((resolve, reject) => {
+    uni.login({
+      provider: 'weixin',
+      success: (res) => resolve(res),
+      fail: (err) => reject(new Error(err)),
+    })
+  })
+}
+
+/**
+ * 微信登录参数
+ */
+
+/**
+ * 微信登录
+ * @param params 微信登录参数,包含code
+ * @returns Promise 包含登录结果
+ */
+export const wxLogin = (data: { code: string }) => {
+  return http.post<IUserLogin>('/user/wxLogin', data)
+}

+ 57 - 0
src/api/login.typings.ts

@@ -0,0 +1,57 @@
+/**
+ * 用户信息
+ */
+export type IUserInfoVo = {
+  id: number
+  username: string
+  avatar: string
+  token: string
+}
+
+/**
+ * 登录返回的信息
+ */
+export type IUserLogin = {
+  id: string
+  username: string
+  token: string
+}
+
+/**
+ * 获取验证码
+ */
+export type ICaptcha = {
+  captchaEnabled: boolean
+  uuid: string
+  image: string
+}
+/**
+ * 上传成功的信息
+ */
+export type IUploadSuccessInfo = {
+  fileId: number
+  originalName: string
+  fileName: string
+  storagePath: string
+  fileHash: string
+  fileType: string
+  fileBusinessType: string
+  fileSize: number
+}
+/**
+ * 更新用户信息
+ */
+export type IUpdateInfo = {
+  id: number
+  name: string
+  sex: string
+}
+/**
+ * 更新用户信息
+ */
+export type IUpdatePassword = {
+  id: number
+  oldPassword: string
+  newPassword: string
+  confirmPassword: string
+}

+ 38 - 28
src/components/fg-navbar/fg-navbar.vue

@@ -1,44 +1,54 @@
 <script lang="ts" setup>
-withDefaults(defineProps<{
-  leftText?: string;
-  rightText?: string;
-  leftArrow?: boolean;
-  bordered?: boolean;
-  fixed?: boolean;
-  placeholder?: boolean;
-  zIndex?: number;
-  safeAreaInsetTop?: boolean;
-  leftDisabled?: boolean;
-  rightDisabled?: boolean;
-}>(), {
-  leftText: '返回',
-  rightText: '',
-  leftArrow: true,
-  bordered: true,
-  fixed: false,
-  placeholder: true,
-  zIndex: 1,
-  safeAreaInsetTop: true,
-  leftDisabled: false,
-  rightDisabled: false,
-});
+withDefaults(
+  defineProps<{
+    leftText?: string
+    rightText?: string
+    leftArrow?: boolean
+    bordered?: boolean
+    fixed?: boolean
+    placeholder?: boolean
+    zIndex?: number
+    safeAreaInsetTop?: boolean
+    leftDisabled?: boolean
+    rightDisabled?: boolean
+  }>(),
+  {
+    leftText: '返回',
+    rightText: '',
+    leftArrow: true,
+    bordered: true,
+    fixed: false,
+    placeholder: true,
+    zIndex: 1,
+    safeAreaInsetTop: true,
+    leftDisabled: false,
+    rightDisabled: false,
+  },
+)
 
 function handleClickLeft() {
   uni.navigateBack({
     fail() {
       uni.reLaunch({
         url: '/pages/index/index',
-      });
+      })
     },
-  });
+  })
 }
 </script>
 
 <template>
   <wd-navbar
-    :left-text="leftText" :right-text="rightText" :left-arrow="leftArrow"
-    :bordered="bordered" :fixed="fixed" :placeholder="placeholder" :z-index="zIndex"
-    :safe-area-inset-top="safeAreaInsetTop" :left-disabled="leftDisabled" :right-disabled="rightDisabled"
+    :left-text="leftText"
+    :right-text="rightText"
+    :left-arrow="leftArrow"
+    :bordered="bordered"
+    :fixed="fixed"
+    :placeholder="placeholder"
+    :z-index="zIndex"
+    :safe-area-inset-top="safeAreaInsetTop"
+    :left-disabled="leftDisabled"
+    :right-disabled="rightDisabled"
     @click-left="handleClickLeft"
   >
     <template #title>

+ 1 - 1
src/interceptors/route.ts

@@ -12,7 +12,7 @@ const loginRoute = '/pages/login/index'
 
 const isLogined = () => {
   const userStore = useUserStore()
-  return userStore.isLogined
+  return !!userStore.userInfo.username
 }
 
 const isDev = import.meta.env.DEV

+ 36 - 2
src/pages.json

@@ -42,6 +42,12 @@
         "text": "我的",
         "icon": "iconfont icon-my",
         "iconType": "iconfont"
+      },
+      {
+        "iconPath": "static/tabbar/personal.png",
+        "selectedIconPath": "static/tabbar/personalHL.png",
+        "pagePath": "pages/mine/index",
+        "text": "我的"
       }
     ]
   },
@@ -65,12 +71,40 @@
       }
     },
     {
-      "path": "pages/my/index",
+      "path": "pages/login/index",
+      "type": "page",
+      "style": {
+        "navigationBarTitleText": "登录",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/mine/index",
       "type": "page",
-      "layout": "tabbar",
       "style": {
         "navigationBarTitleText": "我的"
       }
+    },
+    {
+      "path": "pages/mine/about/index",
+      "type": "page",
+      "style": {
+        "navigationBarTitleText": "关于我们"
+      }
+    },
+    {
+      "path": "pages/mine/info/index",
+      "type": "page",
+      "style": {
+        "navigationBarTitleText": "个人资料"
+      }
+    },
+    {
+      "path": "pages/mine/password/index",
+      "type": "page",
+      "style": {
+        "navigationBarTitleText": "修改密码"
+      }
     }
   ],
   "subPackages": []

+ 1 - 1
src/pages/index/index.vue

@@ -42,7 +42,7 @@ defineOptions({
 const { safeAreaInsets } = uni.getSystemInfoSync()
 const author = ref('菲鸽')
 const description = ref(
-  'unibest 是一个集成了多种工具和技术的 uniapp 开发模板,由 uniapp + Vue3 + Ts + Vite4 + UnoCss + UniUI + VSCode 构建,模板具有代码提示、自动格式化、统一配置、代码片段等功能,并内置了许多常用的基本组件和基本功能,让你编写 uniapp 拥有 best 体验。',
+  'unibest 是一个集成了多种工具和技术的 uniapp 开发模板,由 uniapp + Vue3 + Ts + Vite6 + UnoCss + VSCode 构建,模板具有代码提示、自动格式化、统一配置、代码片段等功能,并内置了许多常用的基本组件和基本功能,让你编写 uniapp 拥有 best 体验。',
 )
 // 测试 uni API 自动引入
 onLoad(() => {

+ 584 - 0
src/pages/login/index.vue

@@ -0,0 +1,584 @@
+<route lang="json5" type="page">
+{
+  style: {
+    navigationBarTitleText: '登录',
+    navigationStyle: 'custom',
+  },
+}
+</route>
+<template>
+  <view class="login-container">
+    <!-- 背景装饰元素 -->
+    <view class="bg-decoration bg-circle-1"></view>
+    <view class="bg-decoration bg-circle-2"></view>
+    <view class="bg-decoration bg-circle-3"></view>
+
+    <view class="login-header">
+      <image class="login-logo" :src="appLogo" mode="aspectFit"></image>
+      <view class="login-title">{{ appTitle }}</view>
+    </view>
+    <view class="login-form">
+      <view class="welcome-text">欢迎登录</view>
+      <view class="login-desc">请输入您的账号和密码</view>
+      <view class="login-input-group">
+        <view class="input-wrapper">
+          <wd-input
+            v-model="loginForm.username"
+            prefix-icon="user"
+            placeholder="请输入用户名"
+            clearable
+            class="login-input"
+            :border="false"
+            required
+          ></wd-input>
+          <view class="input-bottom-line"></view>
+        </view>
+        <view class="input-wrapper">
+          <wd-input
+            v-model="loginForm.password"
+            prefix-icon="lock-on"
+            placeholder="请输入密码"
+            clearable
+            show-password
+            class="login-input"
+            :border="false"
+            required
+          ></wd-input>
+          <view class="input-bottom-line"></view>
+        </view>
+        <!-- 验证码区域 -->
+        <view class="input-wrapper captcha-wrapper">
+          <wd-input
+            v-if="captcha.captchaEnabled"
+            v-model="loginForm.code"
+            prefix-icon="secured"
+            placeholder="请输入验证码"
+            clearable
+            class="login-input captcha-input"
+            :border="false"
+            required
+          >
+            <template #suffix>
+              <image
+                class="captcha-image"
+                :src="'data:image/gif;base64,' + captcha.image"
+                mode="aspectFit"
+                @click="refreshCaptcha"
+              ></image>
+            </template>
+          </wd-input>
+          <view class="input-bottom-line"></view>
+        </view>
+      </view>
+      <!-- 登录按钮组 -->
+      <view class="login-buttons">
+        <!-- 账号密码登录按钮 -->
+        <wd-button
+          type="primary"
+          size="large"
+          block
+          @click="handleAccountLogin"
+          class="account-login-btn"
+        >
+          <wd-icon name="right" size="18px" class="login-icon"></wd-icon>
+          登录
+        </wd-button>
+        <!-- 微信小程序一键登录按钮 -->
+        <!-- #ifdef MP-WEIXIN -->
+        <view class="divider">
+          <view class="divider-line"></view>
+          <view class="divider-text">或</view>
+          <view class="divider-line"></view>
+        </view>
+        <wd-button
+          type="info"
+          size="large"
+          block
+          plain
+          @click="handleWechatLogin"
+          class="wechat-login-btn"
+        >
+          微信一键登录
+        </wd-button>
+        <!-- #endif -->
+      </view>
+    </view>
+    <!-- 隐私协议勾选 -->
+    <view class="privacy-agreement">
+      <wd-checkbox
+        v-model="agreePrivacy"
+        shape="square"
+        class="privacy-checkbox"
+        active-color="var(--wot-color-theme, #1989fa)"
+      >
+        <view class="agreement-text">
+          我已阅读并同意
+          <text class="agreement-link" @click.stop="handleAgreement('user')">《用户协议》</text>
+          和
+          <text class="agreement-link" @click.stop="handleAgreement('privacy')">《隐私政策》</text>
+        </view>
+      </wd-checkbox>
+    </view>
+    <view class="login-footer"></view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { useUserStore } from '@/store/user'
+import { isMpWeixin } from '@/utils/platform'
+import { getCode, ILoginForm } from '@/api/login'
+import { toast } from '@/utils/toast'
+import { isTableBar } from '@/utils/index'
+import { ICaptcha } from '@/api/login.typings'
+const redirectRoute = ref('')
+
+// 获取环境变量
+const appTitle = ref(import.meta.env.VITE_APP_TITLE || 'Unibest Login')
+const appLogo = ref(import.meta.env.VITE_APP_LOGO || '/static/logo.svg')
+
+// 初始化store
+const userStore = useUserStore()
+// 路由位置
+// 验证码图片
+const captcha = ref<ICaptcha>({
+  captchaEnabled: false,
+  uuid: '',
+  image: '',
+})
+// 登录表单数据
+const loginForm = ref<ILoginForm>({
+  username: 'admin',
+  password: '123456',
+  code: '',
+  uuid: '',
+})
+// 隐私协议勾选状态
+const agreePrivacy = ref(true)
+
+// 页面加载完毕时触发
+onLoad((option) => {
+  // 一进来就刷新验证码
+  captcha.value.captchaEnabled && refreshCaptcha()
+  // 获取跳转路由
+  if (option.redirect) {
+    redirectRoute.value = option.redirect
+  }
+})
+
+// 账号密码登录
+const handleAccountLogin = async () => {
+  if (!agreePrivacy.value) {
+    toast.error('请阅读同意协议')
+    return
+  }
+  // 表单验证
+  if (!loginForm.value.username) {
+    toast.error('请输入用户名')
+    return
+  }
+  if (!loginForm.value.password) {
+    toast.error('请输入密码')
+    return
+  }
+  if (captcha.value.captchaEnabled && !loginForm.value.code) {
+    toast.error('请输入验证码')
+    return
+  }
+  // 执行登录
+  await userStore.login(loginForm.value)
+  // 跳转到首页或重定向页面
+  const targetUrl = redirectRoute.value || '/pages/index/index'
+  if (isTableBar(targetUrl)) {
+    uni.switchTab({ url: targetUrl })
+  } else {
+    uni.redirectTo({ url: targetUrl })
+  }
+}
+
+// 微信登录
+const handleWechatLogin = async () => {
+  if (!isMpWeixin) {
+    toast.info('请在微信小程序中使用此功能')
+    return
+  }
+
+  // 验证是否同意隐私协议
+  if (!agreePrivacy.value) {
+    toast.error('请先阅读并同意用户协议和隐私政策')
+    return
+  }
+  // 微信登录
+  await userStore.wxLogin()
+  // 跳转到首页或重定向页面
+  const targetUrl = redirectRoute.value || '/pages/index/index'
+  if (isTableBar(targetUrl)) {
+    uni.switchTab({ url: targetUrl })
+  } else {
+    uni.redirectTo({ url: targetUrl })
+  }
+}
+
+// 刷新验证码
+const refreshCaptcha = () => {
+  // 获取验证码
+  getCode().then((res) => {
+    const { data } = res
+    loginForm.value.uuid = data.uuid
+    captcha.value = data
+  })
+}
+
+// 处理协议点击
+const handleAgreement = (type: 'user' | 'privacy') => {
+  const title = type === 'user' ? '用户协议' : '隐私政策'
+  // showToast(`查看${title}`)
+  // 实际项目中可以跳转到对应的协议页面
+  // uni.navigateTo({
+  //   url: `/pages/agreement/${type}`
+  // })
+}
+</script>
+
+<style lang="scss" scoped>
+/* 验证码输入框样式 */
+.captcha-wrapper {
+  .captcha-input {
+    :deep(.wd-input__suffix) {
+      margin-right: 0;
+      padding-right: 0;
+    }
+  }
+
+  .captcha-image {
+    width: 100px;
+    height: 36px;
+    margin-left: 10px;
+    border-radius: 8px;
+    cursor: pointer;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    transition: all 0.3s ease;
+    position: relative;
+    overflow: hidden;
+
+    &::after {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), transparent);
+      pointer-events: none;
+    }
+
+    &:active {
+      opacity: 0.8;
+      transform: scale(0.96);
+      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+    }
+  }
+}
+
+.login-container {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+  padding: 0 70rpx;
+  background-color: #ffffff;
+  background-image: linear-gradient(
+    135deg,
+    rgba(25, 137, 250, 0.05) 0%,
+    rgba(255, 255, 255, 0) 100%
+  );
+  position: relative;
+  overflow: hidden;
+}
+
+/* 背景装饰元素 */
+.bg-decoration {
+  position: absolute;
+  border-radius: 50%;
+  background: linear-gradient(135deg, rgba(25, 137, 250, 0.05), rgba(25, 137, 250, 0.1));
+  z-index: 0;
+  pointer-events: none;
+}
+
+.bg-circle-1 {
+  width: 500rpx;
+  height: 500rpx;
+  top: -200rpx;
+  right: -200rpx;
+  opacity: 0.6;
+}
+
+.bg-circle-2 {
+  width: 400rpx;
+  height: 400rpx;
+  bottom: 10%;
+  left: -200rpx;
+  opacity: 0.4;
+}
+
+.bg-circle-3 {
+  width: 300rpx;
+  height: 300rpx;
+  bottom: -100rpx;
+  right: 10%;
+  opacity: 0.3;
+  background: linear-gradient(135deg, rgba(7, 193, 96, 0.05), rgba(7, 193, 96, 0.1));
+}
+
+.login-header {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin-top: 120rpx;
+  animation: fadeInDown 0.8s ease-out;
+
+  .login-logo {
+    width: 200rpx;
+    height: 200rpx;
+    border-radius: 36rpx;
+    box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.12);
+    transition: all 0.3s ease;
+
+    &:active {
+      transform: scale(0.95);
+      box-shadow: 0 6rpx 15rpx rgba(0, 0, 0, 0.1);
+    }
+  }
+
+  .login-title {
+    margin-top: 30rpx;
+    font-size: 46rpx;
+    font-weight: bold;
+    color: #333333;
+    letter-spacing: 3rpx;
+    text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
+  }
+}
+
+.login-form {
+  flex: 1;
+  margin-top: 70rpx;
+  animation: fadeIn 0.8s ease-out 0.2s both;
+
+  .welcome-text {
+    margin-bottom: 16rpx;
+    font-size: 48rpx;
+    font-weight: bold;
+    color: #333333;
+    text-align: center;
+    letter-spacing: 1rpx;
+  }
+
+  .login-desc {
+    margin-bottom: 70rpx;
+    font-size: 28rpx;
+    color: #888888;
+    text-align: center;
+  }
+
+  .login-input-group {
+    margin-bottom: 60rpx;
+    position: relative;
+    z-index: 1;
+
+    .input-wrapper {
+      position: relative;
+      margin-bottom: 50rpx;
+      transition: all 0.3s ease;
+      border-radius: 16rpx;
+      overflow: hidden;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      .login-input {
+        padding: 12rpx 20rpx;
+        background-color: rgba(245, 247, 250, 0.7);
+        border-radius: 16rpx;
+        transition: all 0.3s ease;
+
+        :deep(.wd-input__inner) {
+          font-size: 30rpx;
+          color: #333333;
+        }
+
+        :deep(.wd-input__placeholder) {
+          font-size: 28rpx;
+          color: #aaaaaa;
+        }
+
+        &:focus-within {
+          background-color: rgba(245, 247, 250, 0.95);
+          box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.06);
+          transform: translateY(-3rpx);
+        }
+      }
+
+      .input-bottom-line {
+        position: absolute;
+        bottom: -2rpx;
+        left: 5%;
+        width: 90%;
+        height: 2rpx;
+        background: linear-gradient(
+          to right,
+          transparent,
+          var(--wot-color-theme, #1989fa),
+          transparent
+        );
+        transition: transform 0.4s ease;
+        transform: scaleX(0);
+        opacity: 0.8;
+      }
+
+      &:focus-within .input-bottom-line {
+        transform: scaleX(1);
+      }
+
+      .input-icon {
+        margin-right: 16rpx;
+        color: #666666;
+        transition: color 0.3s ease;
+      }
+
+      &:focus-within .input-icon {
+        color: var(--wot-color-theme, #1989fa);
+      }
+    }
+  }
+
+  .login-buttons {
+    display: flex;
+    flex-direction: column;
+    gap: 36rpx;
+
+    .account-login-btn {
+      height: 96rpx;
+      margin-top: 20rpx;
+      font-size: 32rpx;
+      font-weight: 500;
+      letter-spacing: 2rpx;
+      border-radius: 48rpx;
+      box-shadow: 0 10rpx 20rpx rgba(25, 137, 250, 0.25);
+      transition: all 0.3s ease;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      .login-icon {
+        margin-right: 8rpx;
+        opacity: 0.8;
+        transition: all 0.3s ease;
+      }
+
+      &:active {
+        box-shadow: 0 5rpx 10rpx rgba(25, 137, 250, 0.2);
+        transform: scale(0.98);
+
+        .login-icon {
+          transform: translateX(3rpx);
+        }
+      }
+    }
+
+    .divider {
+      display: flex;
+      align-items: center;
+      margin: 24rpx 0;
+
+      .divider-line {
+        flex: 1;
+        height: 1px;
+        background-color: #eeeeee;
+      }
+
+      .divider-text {
+        padding: 0 24rpx;
+        font-size: 24rpx;
+        color: #999999;
+      }
+    }
+
+    .wechat-login-btn {
+      height: 96rpx;
+      font-size: 32rpx;
+      color: #07c160;
+      border-color: #07c160;
+      border-radius: 48rpx;
+      transition: all 0.3s ease;
+
+      .wechat-icon {
+        margin-right: 12rpx;
+      }
+
+      &:active {
+        background-color: rgba(7, 193, 96, 0.08);
+        transform: scale(0.98);
+      }
+    }
+  }
+}
+
+.privacy-agreement {
+  display: flex;
+  justify-content: center;
+  margin: 30rpx 0 40rpx;
+  animation: fadeIn 0.8s ease-out 0.4s both;
+
+  .privacy-checkbox {
+    display: flex;
+    align-items: center;
+  }
+
+  .agreement-text {
+    font-size: 26rpx;
+    line-height: 1.6;
+    color: #666666;
+
+    .agreement-link {
+      padding: 0 4rpx;
+      font-weight: 500;
+      color: var(--wot-color-theme, #1989fa);
+      transition: all 0.3s ease;
+
+      &:active {
+        opacity: 0.8;
+        transform: scale(0.98);
+      }
+    }
+  }
+}
+
+.login-footer {
+  padding: 50rpx 0;
+  margin-top: auto;
+}
+
+/* 添加动画效果 */
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+
+@keyframes fadeInDown {
+  from {
+    opacity: 0;
+    transform: translateY(-20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+</style>

+ 173 - 0
src/pages/mine/about/index.vue

@@ -0,0 +1,173 @@
+<route lang="json5">
+{
+  style: {
+    navigationBarTitleText: '关于我们',
+  },
+}
+</route>
+
+<template>
+  <view class="about-container">
+    <view class="about-card">
+      <!-- 应用信息 -->
+      <view class="app-info">
+        <view class="logo-wrapper">
+          <wd-img :src="appLogo" width="120px" height="120px" radius="24rpx"></wd-img>
+        </view>
+        <view class="app-name">{{ appTitle }}</view>
+        <view class="app-version">版本 {{ packageJson.version }}</view>
+      </view>
+
+      <!-- 联系方式 -->
+      <view class="info-section">
+        <view class="section-title">联系我们</view>
+        <view class="section-content">
+          <view class="contact-item">
+            <wd-icon name="phone" size="20px" class="contact-icon"></wd-icon>
+            <text class="contact-text">客服电话:400-XXX-XXXX</text>
+          </view>
+          <view class="contact-item">
+            <wd-icon name="mail" size="20px" class="contact-icon"></wd-icon>
+            <text class="contact-text">邮箱:support@unibest.tech</text>
+          </view>
+          <view class="contact-item">
+            <wd-icon name="location" size="20px" class="contact-icon"></wd-icon>
+            <text class="contact-text">地址:中国·深圳</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 版权信息 -->
+      <view class="copyright">
+        <text>Copyright © 2025-{{ currentYear }} {{ appTitle }}</text>
+        <text>All Rights Reserved</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import packageJson from '@/../package.json'
+
+const appTitle = ref(import.meta.env.VITE_APP_TITLE || 'unibest')
+const appLogo = ref(import.meta.env.VITE_APP_LOGO || '/static/logo.svg')
+
+// 当前年份
+const currentYear = computed(() => new Date().getFullYear())
+</script>
+
+<style lang="scss" scoped>
+.about-container {
+  background-color: #f5f7fa;
+  padding: 30rpx;
+}
+
+.about-card {
+  background-color: #ffffff;
+  border-radius: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
+  overflow: hidden;
+  padding: 40rpx 30rpx;
+}
+
+/* 应用信息 */
+.app-info {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 30rpx 0 50rpx;
+  border-bottom: 2rpx solid #f0f0f0;
+}
+
+.logo-wrapper {
+  margin-bottom: 20rpx;
+  box-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.08);
+  border-radius: 24rpx;
+}
+
+.app-name {
+  font-size: 40rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 10rpx;
+}
+
+.app-version {
+  font-size: 28rpx;
+  color: #999;
+}
+
+/* 信息区块 */
+.info-section {
+  padding: 40rpx 0;
+  border-bottom: 2rpx solid #f0f0f0;
+}
+
+.section-title {
+  font-size: 34rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 20rpx;
+  position: relative;
+  padding-left: 24rpx;
+
+  &::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 8rpx;
+    height: 32rpx;
+    background: linear-gradient(135deg, #4a7bff, #6a5acd);
+    border-radius: 4rpx;
+  }
+}
+
+.section-content {
+  padding: 0 10rpx;
+}
+
+.content-text {
+  font-size: 30rpx;
+  color: #666;
+  line-height: 1.6;
+  text-align: justify;
+}
+
+/* 联系方式 */
+.contact-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.contact-icon {
+  margin-right: 20rpx;
+  color: #4a7bff;
+}
+
+.contact-text {
+  font-size: 30rpx;
+  color: #666;
+}
+
+/* 版权信息 */
+.copyright {
+  padding-top: 40rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+
+  text {
+    font-size: 26rpx;
+    color: #999;
+    line-height: 1.6;
+  }
+}
+</style>

+ 367 - 0
src/pages/mine/index.vue

@@ -0,0 +1,367 @@
+<route lang="json5">
+{
+  style: {
+    navigationBarTitleText: '我的',
+  },
+}
+</route>
+
+<template>
+  <view class="profile-container">
+    {{ JSON.stringify(userStore.userInfo) }}
+    <!-- 用户信息区域 -->
+    <view class="user-info-section">
+      <!-- #ifdef MP-WEIXIN -->
+      <button class="avatar-button" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
+        <wd-img :src="userStore.userInfo.avatar" width="80px" height="80px" radius="50%"></wd-img>
+      </button>
+      <!-- #endif -->
+      <!-- #ifndef MP-WEIXIN -->
+      <view class="avatar-wrapper" @click="run">
+        <wd-img :src="userStore.userInfo.avatar" width="100%" height="100%" radius="50%"></wd-img>
+      </view>
+      <!-- #endif -->
+      <view class="user-details">
+        <!-- #ifdef MP-WEIXIN -->
+        <input
+          type="nickname"
+          class="weui-input"
+          placeholder="请输入昵称"
+          v-model="userStore.userInfo.username"
+        />
+        <!-- #endif -->
+        <!-- #ifndef MP-WEIXIN -->
+        <view class="username">{{ userStore.userInfo.username }}</view>
+        <!-- #endif -->
+        <view class="user-id">ID: {{ userStore.userInfo.id }}</view>
+      </view>
+    </view>
+
+    <!-- 功能区块 -->
+    <view class="function-section">
+      <view class="cell-group">
+        <view class="group-title">账号管理</view>
+        <wd-cell title="个人资料" is-link @click="handleProfileInfo">
+          <template #icon>
+            <wd-icon name="user" size="20px"></wd-icon>
+          </template>
+        </wd-cell>
+        <wd-cell title="账号安全" is-link @click="handlePassword">
+          <template #icon>
+            <wd-icon name="lock-on" size="20px"></wd-icon>
+          </template>
+        </wd-cell>
+      </view>
+
+      <view class="cell-group">
+        <view class="group-title">通用设置</view>
+        <wd-cell title="消息通知" is-link @click="handleInform">
+          <template #icon>
+            <wd-icon name="notification" size="20px"></wd-icon>
+          </template>
+        </wd-cell>
+        <wd-cell title="清理缓存" is-link @click="handleClearCache">
+          <template #icon>
+            <wd-icon name="clear" size="20px"></wd-icon>
+          </template>
+        </wd-cell>
+        <wd-cell title="应用更新" is-link @click="handleAppUpdate">
+          <template #icon>
+            <wd-icon name="refresh1" size="20px"></wd-icon>
+          </template>
+        </wd-cell>
+        <wd-cell title="关于我们" is-link @click="handleAbout">
+          <template #icon>
+            <wd-icon name="info-circle" size="20px"></wd-icon>
+          </template>
+        </wd-cell>
+      </view>
+
+      <view class="logout-button-wrapper">
+        <wd-button type="error" v-if="hasLogin" block @click="handleLogout">退出登录</wd-button>
+        <wd-button type="primary" v-else block @click="handleLogin">登录</wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { useUserStore } from '@/store'
+import { useToast } from 'wot-design-uni'
+import { uploadFileUrl, useUpload } from '@/utils/uploadFile'
+import { storeToRefs } from 'pinia'
+import { IUploadSuccessInfo } from '@/api/login.typings'
+
+const userStore = useUserStore()
+
+const toast = useToast()
+const hasLogin = ref(false)
+
+onShow((options) => {
+  hasLogin.value = !!uni.getStorageSync('token')
+  console.log('个人中心onShow', hasLogin.value, options)
+
+  hasLogin.value && useUserStore().getUserInfo()
+})
+// #ifndef MP-WEIXIN
+// 上传头像
+const { run } = useUpload<IUploadSuccessInfo>(
+  uploadFileUrl.USER_AVATAR,
+  {},
+  {
+    onSuccess: (res) => useUserStore().getUserInfo(),
+  },
+)
+// #endif
+
+// 微信小程序下登录
+const handleLogin = async () => {
+  // #ifdef MP-WEIXIN
+
+  // 微信登录
+  await userStore.wxLogin()
+  hasLogin.value = true
+  // #endif
+  // #ifndef MP-WEIXIN
+  uni.navigateTo({ url: '/pages/login/index' })
+  // #endif
+}
+
+// #ifdef MP-WEIXIN
+
+// 微信小程序下选择头像事件
+const onChooseAvatar = (e: any) => {
+  console.log('选择头像', e.detail)
+  const { avatarUrl } = e.detail
+  const { run } = useUpload<IUploadSuccessInfo>(
+    uploadFileUrl.USER_AVATAR,
+    {},
+    {
+      onSuccess: (res) => useUserStore().getUserInfo(),
+    },
+    avatarUrl,
+  )
+  run()
+}
+// #endif
+// #ifdef MP-WEIXIN
+// 微信小程序下设置用户名
+const getUserInfo = (e: any) => {
+  console.log(e.detail)
+}
+// #endif
+
+// 个人资料
+const handleProfileInfo = () => {
+  uni.navigateTo({ url: `/pages/mine/info/index` })
+}
+// 账号安全
+const handlePassword = () => {
+  uni.navigateTo({ url: `/pages/mine/password/index` })
+}
+// 消息通知
+const handleInform = () => {
+  // uni.navigateTo({ url: `/pages/mine/inform/index` })
+  toast.success('功能开发中')
+}
+// 应用更新
+const handleAppUpdate = () => {
+  // #ifdef MP
+  // #ifndef MP-HARMONY
+  const updateManager = uni.getUpdateManager()
+  updateManager.onCheckForUpdate(function (res) {
+    // 请求完新版本信息的回调
+    // console.log(res.hasUpdate)
+    if (res.hasUpdate) {
+      toast.success('检测到新版本,正在下载中...')
+    } else {
+      toast.success('已是最新版本')
+    }
+  })
+  updateManager.onUpdateReady(function (res) {
+    uni.showModal({
+      title: '更新提示',
+      content: '新版本已经准备好,是否重启应用?',
+      success(res) {
+        if (res.confirm) {
+          // 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
+          updateManager.applyUpdate()
+        }
+      },
+    })
+  })
+  updateManager.onUpdateFailed(function (res) {
+    // 新的版本下载失败
+    toast.error('新版本下载失败')
+  })
+  // #endif
+  // #endif
+
+  // #ifndef MP
+  toast.success('功能开发中')
+  // #endif
+}
+// 关于我们
+const handleAbout = () => {
+  uni.navigateTo({ url: `/pages/mine/about/index` })
+}
+// 清除缓存
+const handleClearCache = () => {
+  uni.showModal({
+    title: '清除缓存',
+    content: '确定要清除所有缓存吗?\n清除后需要重新登录',
+    success: (res) => {
+      if (res.confirm) {
+        try {
+          // 清除所有缓存
+          uni.clearStorageSync()
+          // 清除用户信息并跳转到登录页
+          useUserStore().logout()
+          toast.success('清除缓存成功')
+        } catch (err) {
+          console.error('清除缓存失败:', err)
+          toast.error('清除缓存失败')
+        }
+      }
+    },
+  })
+}
+// 退出登录
+const handleLogout = () => {
+  uni.showModal({
+    title: '提示',
+    content: '确定要退出登录吗?',
+    success: (res) => {
+      if (res.confirm) {
+        // 清空用户信息
+        useUserStore().logout()
+        hasLogin.value = false
+        // 执行退出登录逻辑
+        toast.success('退出登录成功')
+        // #ifdef MP-WEIXIN
+        // 微信小程序,去首页
+        // uni.reLaunch({ url: '/pages/index/index' })
+        // #endif
+        // #ifndef MP-WEIXIN
+        // 非微信小程序,去登录页
+        // uni.reLaunch({ url: '/pages/login/index' })
+        // #endif
+      }
+    },
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+/* 基础样式 */
+.profile-container {
+  overflow: hidden;
+  font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
+  background-color: #f7f8fa;
+}
+/* 用户信息区域 */
+.user-info-section {
+  display: flex;
+  align-items: center;
+  padding: 40rpx;
+  margin: 30rpx 30rpx 20rpx;
+  background-color: #fff;
+  border-radius: 24rpx;
+  box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
+  transition: all 0.3s ease;
+}
+
+.avatar-wrapper {
+  width: 160rpx;
+  height: 160rpx;
+  margin-right: 40rpx;
+  overflow: hidden;
+  border: 4rpx solid #f5f5f5;
+  border-radius: 50%;
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
+}
+.avatar-button {
+  height: 160rpx;
+  padding: 0;
+  margin-right: 40rpx;
+  overflow: hidden;
+  border: 4rpx solid #f5f5f5;
+  border-radius: 50%;
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
+}
+.user-details {
+  flex: 1;
+}
+
+.username {
+  margin-bottom: 12rpx;
+  font-size: 38rpx;
+  font-weight: 600;
+  color: #333;
+  letter-spacing: 0.5rpx;
+}
+
+.user-id {
+  font-size: 28rpx;
+  color: #666;
+}
+
+.user-created {
+  margin-top: 8rpx;
+  font-size: 24rpx;
+  color: #999;
+}
+/* 功能区块 */
+.function-section {
+  padding: 0 20rpx;
+  margin-top: 20rpx;
+}
+
+.cell-group {
+  margin-bottom: 20rpx;
+  overflow: hidden;
+  background-color: #fff;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
+}
+
+.group-title {
+  padding: 24rpx 30rpx 16rpx;
+  font-size: 30rpx;
+  font-weight: 500;
+  color: #999;
+  background-color: #fafafa;
+}
+
+:deep(.wd-cell) {
+  border-bottom: 1rpx solid #f5f5f5;
+
+  &:last-child {
+    border-bottom: none;
+  }
+
+  .wd-cell__title {
+    margin-left: 5px;
+    font-size: 32rpx;
+    color: #333;
+  }
+
+  .cell-icon {
+    margin-right: 20rpx;
+    font-size: 36rpx;
+  }
+}
+/* 退出登录按钮 */
+.logout-button-wrapper {
+  padding: 40rpx 30rpx;
+}
+
+:deep(.wd-button--danger) {
+  height: 88rpx;
+  font-size: 32rpx;
+  line-height: 88rpx;
+  color: #fff;
+  background-color: #f53f3f;
+  border-radius: 44rpx;
+}
+</style>

+ 190 - 0
src/pages/mine/info/index.vue

@@ -0,0 +1,190 @@
+<route lang="json5">
+{
+  style: {
+    navigationBarTitleText: '个人资料',
+  },
+}
+</route>
+
+<template>
+  <view class="profile-info-container">
+    <view class="profile-card">
+      <view class="form-wrapper">
+        <wd-form ref="formRef" :model="formData" label-width="160rpx" class="profile-form">
+          <wd-cell-group class="form-group">
+            <!-- 昵称 -->
+            <view class="sex-field">
+              <text class="field-label">昵称</text>
+              <wd-input
+                prop="name"
+                clearable
+                v-model="formData.name"
+                placeholder="请输入昵称"
+                :rules="[{ required: true, message: '请填写昵称' }]"
+                class="form-input"
+              />
+            </view>
+
+            <!-- 性别 -->
+            <view class="sex-field">
+              <text class="field-label">性别</text>
+              <wd-radio-group
+                v-model="formData.sex"
+                shape="button"
+                :rules="[{ required: true, message: '请选择性别' }]"
+              >
+                <wd-radio :value="'1'">男</wd-radio>
+                <wd-radio :value="'0'">女</wd-radio>
+              </wd-radio-group>
+            </view>
+          </wd-cell-group>
+        </wd-form>
+
+        <!-- 操作按钮 -->
+        <view class="form-actions">
+          <wd-button type="primary" size="large" @click="handleSubmit">保存修改</wd-button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { useUserStore } from '@/store'
+import { storeToRefs } from 'pinia'
+import { toast } from '@/utils/toast'
+import { updateInfo } from '@/api/login'
+
+// 表单引用
+const formRef = ref()
+
+// 用户信息
+const userStore = useUserStore()
+const { userInfo } = storeToRefs(userStore)
+
+// 表单数据
+const formData = ref({
+  id: userInfo.value.id,
+  name: userInfo.value.name,
+  sex: userInfo.value.sex,
+})
+
+// 提交表单
+const handleSubmit = async () => {
+  // 表单验证
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  const { message } = await updateInfo(formData.value)
+  await useUserStore().getUserInfo()
+  toast.success(message)
+}
+</script>
+
+<style lang="scss" scoped>
+.profile-info-container {
+  min-height: 100vh;
+  background-color: #f5f7fa;
+  padding: 30rpx;
+}
+
+.profile-card {
+  background-color: #ffffff;
+  border-radius: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
+  overflow: hidden;
+}
+
+.card-header {
+  padding: 40rpx 30rpx 20rpx;
+  border-bottom: 2rpx solid #f0f0f0;
+}
+
+.card-title {
+  font-size: 36rpx;
+  font-weight: 600;
+  color: #333;
+  position: relative;
+  display: inline-block;
+  padding-bottom: 16rpx;
+
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    width: 60rpx;
+    height: 6rpx;
+    background: linear-gradient(90deg, #4a7bff, #6a5acd);
+    border-radius: 6rpx;
+  }
+}
+
+.form-wrapper {
+  padding: 30rpx;
+}
+
+.form-group {
+  border-radius: 16rpx;
+  overflow: hidden;
+  margin-bottom: 40rpx;
+}
+
+.form-input {
+  font-size: 30rpx;
+}
+
+.sex-field {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 30rpx;
+  background-color: #ffffff;
+}
+
+.field-label {
+  width: 160rpx;
+  font-size: 30rpx;
+  color: #333;
+}
+
+.radio-group {
+  flex: 1;
+  display: flex;
+  gap: 20rpx;
+}
+
+.radio-btn {
+  flex: 1;
+  height: 80rpx;
+  line-height: 80rpx;
+  text-align: center;
+  font-size: 30rpx;
+  border-radius: 12rpx;
+  background-color: #f5f7fa;
+
+  &:active {
+    opacity: 0.8;
+  }
+}
+
+.form-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 20rpx;
+}
+
+.submit-btn {
+  height: 90rpx;
+  border-radius: 45rpx;
+  font-size: 32rpx;
+  font-weight: 500;
+  background: linear-gradient(135deg, #4a7bff, #6a5acd);
+  box-shadow: 0 8rpx 16rpx rgba(74, 123, 255, 0.2);
+  transition: all 0.3s ease;
+
+  &:active {
+    transform: translateY(2rpx);
+    box-shadow: 0 4rpx 8rpx rgba(74, 123, 255, 0.15);
+  }
+}
+</style>

+ 203 - 0
src/pages/mine/password/index.vue

@@ -0,0 +1,203 @@
+<route lang="json5">
+{
+  style: {
+    navigationBarTitleText: '修改密码',
+  },
+}
+</route>
+
+<template>
+  <view class="profile-info-container">
+    <view class="profile-card">
+      <view class="form-wrapper">
+        <wd-form ref="formRef" :model="formData" label-width="160rpx" class="profile-form">
+          <wd-cell-group class="form-group">
+            <!-- 昵称 -->
+            <view class="sex-field">
+              <text class="field-label">旧密码</text>
+              <wd-input
+                prop="oldPassword"
+                clearable
+                v-model="formData.oldPassword"
+                placeholder="请输入旧密码"
+                show-password
+                :rules="[{ required: true, message: '请填写旧密码' }]"
+                class="form-input"
+              />
+            </view>
+            <view class="sex-field">
+              <text class="field-label">新密码</text>
+              <wd-input
+                prop="newPassword"
+                clearable
+                v-model="formData.newPassword"
+                placeholder="请输入新密码"
+                show-password
+                :rules="[{ required: true, message: '请填写新密码' }]"
+                class="form-input"
+              />
+            </view>
+            <view class="sex-field">
+              <text class="field-label">确认密码</text>
+              <wd-input
+                prop="confirmPassword"
+                clearable
+                v-model="formData.confirmPassword"
+                placeholder="请输入新密码"
+                show-password
+                :rules="[{ required: true, message: '请填写新密码' }]"
+                class="form-input"
+              />
+            </view>
+          </wd-cell-group>
+        </wd-form>
+
+        <!-- 操作按钮 -->
+        <view class="form-actions">
+          <wd-button type="primary" size="large" @click="handleSubmit">保存修改</wd-button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { useUserStore } from '@/store'
+import { storeToRefs } from 'pinia'
+import { toast } from '@/utils/toast'
+import { updateInfo, updateUserPassword } from '@/api/login'
+
+// 表单引用
+const formRef = ref()
+
+// 用户信息
+const userStore = useUserStore()
+const { userInfo } = storeToRefs(userStore)
+
+// 表单数据
+const formData = ref({
+  id: userInfo.value.id,
+  oldPassword: '',
+  newPassword: '',
+  confirmPassword: '',
+})
+
+// 提交表单
+const handleSubmit = async () => {
+  // 表单验证
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  const { message } = await updateUserPassword(formData.value)
+  await useUserStore().logout()
+  toast.success('修改成功,请重新登录')
+}
+</script>
+
+<style lang="scss" scoped>
+.profile-info-container {
+  min-height: 100vh;
+  background-color: #f5f7fa;
+  padding: 30rpx;
+}
+
+.profile-card {
+  background-color: #ffffff;
+  border-radius: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
+  overflow: hidden;
+}
+
+.card-header {
+  padding: 40rpx 30rpx 20rpx;
+  border-bottom: 2rpx solid #f0f0f0;
+}
+
+.card-title {
+  font-size: 36rpx;
+  font-weight: 600;
+  color: #333;
+  position: relative;
+  display: inline-block;
+  padding-bottom: 16rpx;
+
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    width: 60rpx;
+    height: 6rpx;
+    background: linear-gradient(90deg, #4a7bff, #6a5acd);
+    border-radius: 6rpx;
+  }
+}
+
+.form-wrapper {
+  padding: 30rpx;
+}
+
+.form-group {
+  border-radius: 16rpx;
+  overflow: hidden;
+  margin-bottom: 40rpx;
+}
+
+.form-input {
+  font-size: 30rpx;
+}
+
+.sex-field {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 30rpx;
+  background-color: #ffffff;
+}
+
+.field-label {
+  width: 160rpx;
+  font-size: 30rpx;
+  color: #333;
+}
+
+.radio-group {
+  flex: 1;
+  display: flex;
+  gap: 20rpx;
+}
+
+.radio-btn {
+  flex: 1;
+  height: 80rpx;
+  line-height: 80rpx;
+  text-align: center;
+  font-size: 30rpx;
+  border-radius: 12rpx;
+  background-color: #f5f7fa;
+
+  &:active {
+    opacity: 0.8;
+  }
+}
+
+.form-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 20rpx;
+}
+
+.submit-btn {
+  height: 90rpx;
+  border-radius: 45rpx;
+  font-size: 32rpx;
+  font-weight: 500;
+  background: linear-gradient(135deg, #4a7bff, #6a5acd);
+  box-shadow: 0 8rpx 16rpx rgba(74, 123, 255, 0.2);
+  transition: all 0.3s ease;
+
+  &:active {
+    transform: translateY(2rpx);
+    box-shadow: 0 4rpx 8rpx rgba(74, 123, 255, 0.15);
+  }
+}
+</style>

BIN
src/static/images/avatar.jpg


BIN
src/static/images/default-avatar.png


+ 83 - 15
src/store/user.ts

@@ -1,32 +1,100 @@
+import {
+  login as _login,
+  getUserInfo as _getUserInfo,
+  wxLogin as _wxLogin,
+  logout as _logout,
+  getWxCode,
+} from '@/api/login'
 import { defineStore } from 'pinia'
 import { ref } from 'vue'
+import { toast } from '@/utils/toast'
+import { IUserInfoVo } from '@/api/login.typings'
 
-const initState = { nickname: '', avatar: '' }
+// 初始化状态
+const userInfoState: IUserInfoVo = {
+  id: 0,
+  username: '',
+  avatar: '/static/images/default-avatar.png',
+  token: '',
+}
 
 export const useUserStore = defineStore(
   'user',
   () => {
-    const userInfo = ref<IUserInfo>({ ...initState })
-
-    const setUserInfo = (val: IUserInfo) => {
+    // 定义用户信息
+    const userInfo = ref<IUserInfoVo>({ ...userInfoState })
+    // 设置用户信息
+    const setUserInfo = (val: IUserInfoVo) => {
+      console.log('设置用户信息', val)
+      // 若头像为空 则使用默认头像
+      if (!val.avatar) {
+        val.avatar = userInfoState.avatar
+      } else {
+        val.avatar = 'https://oss.laf.run/ukw0y1-site/avatar.jpg?feige'
+      }
       userInfo.value = val
     }
-
-    const clearUserInfo = () => {
-      userInfo.value = { ...initState }
+    // 删除用户信息
+    const removeUserInfo = () => {
+      userInfo.value = { ...userInfoState }
+      uni.removeStorageSync('userInfo')
+      uni.removeStorageSync('token')
+    }
+    /**
+     * 用户登录
+     * @param credentials 登录参数
+     * @returns R<IUserLogin>
+     */
+    const login = async (credentials: {
+      username: string
+      password: string
+      code: string
+      uuid: string
+    }) => {
+      const res = await _login(credentials)
+      console.log('登录信息', res)
+      toast.success('登录成功')
+      getUserInfo()
+      return res
     }
-    // 一般没有reset需求,不需要的可以删除
-    const reset = () => {
-      userInfo.value = { ...initState }
+    /**
+     * 获取用户信息
+     */
+    const getUserInfo = async () => {
+      const res = await _getUserInfo()
+      const userInfo = res.data
+      setUserInfo(userInfo)
+      uni.setStorageSync('userInfo', userInfo)
+      uni.setStorageSync('token', userInfo.token)
+      // TODO 这里可以增加获取用户路由的方法 根据用户的角色动态生成路由
+      return res
+    }
+    /**
+     * 退出登录 并 删除用户信息
+     */
+    const logout = async () => {
+      _logout()
+      removeUserInfo()
+    }
+    /**
+     * 微信登录
+     */
+    const wxLogin = async () => {
+      // 获取微信小程序登录的code
+      const data = await getWxCode()
+      console.log('微信登录code', data)
+
+      const res = await _wxLogin(data)
+      getUserInfo()
+      return res
     }
-    const isLogined = computed(() => !!userInfo.value.token)
 
     return {
       userInfo,
-      setUserInfo,
-      clearUserInfo,
-      isLogined,
-      reset,
+      login,
+      wxLogin,
+      getUserInfo,
+      logout,
     }
   },
   {

+ 0 - 2
src/types/components.d.ts

@@ -8,8 +8,6 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     FgNavbar: typeof import('./../components/fg-navbar/fg-navbar.vue')['default']
-    FgTabbar: typeof import('./../components/fg-tabbar/fg-tabbar.vue')['default']
     PrivacyPopup: typeof import('./../components/privacy-popup/privacy-popup.vue')['default']
-    Tabbar: typeof import('./../components/tabbar/tabbar.vue')['default']
   }
 }

+ 7 - 2
src/types/uni-pages.d.ts

@@ -6,12 +6,17 @@
 interface NavigateToOptions {
   url: "/pages/index/index" |
        "/pages/about/about" |
-       "/pages/my/index";
+       "/pages/my/index"|
+       "/pages/login/index" |
+       "/pages/mine/index" |
+       "/pages/mine/about/index" |
+       "/pages/mine/info/index" |
+       "/pages/mine/password/index";
 }
 interface RedirectToOptions extends NavigateToOptions {}
 
 interface SwitchTabOptions {
-  url: "/pages/index/index" | "/pages/about/about" | "/pages/my/index"
+  url: "/pages/index/index" | "/pages/about/about" | "/pages/my/index"|"/pages/mine/index"
 }
 
 type ReLaunchOptions = NavigateToOptions | SwitchTabOptions;

+ 9 - 0
src/typings.ts

@@ -4,3 +4,12 @@ export enum TestEnum {
   A = '1',
   B = '2',
 }
+
+// uni.uploadFile文件上传参数
+export type IUniUploadFileOptions = {
+  file?: File
+  files?: UniApp.UploadFileOptionFiles[]
+  filePath?: string
+  name?: string
+  formData?: any
+}

+ 20 - 0
src/utils/index.ts

@@ -23,6 +23,26 @@ export const getIsTabbar = () => {
   }
 }
 
+/**
+ * 判断指定页面是否是 tabbar 页
+ * @param path 页面路径
+ * @returns true: 是 tabbar 页 false: 不是 tabbar 页
+ */
+export const isTableBar = (path: string) => {
+  if (!tabBar) {
+    return false
+  }
+  if (!tabBar.list.length) {
+    // 通常有 tabBar 的话,list 不能有空,且至少有2个元素,这里其实不用处理
+    return false
+  }
+  // 这里需要处理一下 path,因为 tabBar 中的 pagePath 是不带 /pages 前缀的
+  if (path.startsWith('/')) {
+    path = path.substring(1)
+  }
+  return !!tabBar.list.find((e) => e.pagePath === path)
+}
+
 /**
  * 获取当前页面路由的 path 路径和 redirectPath 路径
  * path 如 '/pages/login/index'

+ 65 - 0
src/utils/toast.ts

@@ -0,0 +1,65 @@
+/**
+ * toast 弹窗组件
+ * 支持 success/error/warning/info 四种状态
+ * 可配置 duration, position 等参数
+ */
+
+type ToastType = 'success' | 'error' | 'warning' | 'info'
+
+interface ToastOptions {
+  type?: ToastType
+  duration?: number
+  position?: 'top' | 'middle' | 'bottom'
+  icon?: 'success' | 'error' | 'none' | 'loading' | 'fail' | 'exception'
+  message: string
+}
+
+export function showToast(options: ToastOptions | string) {
+  const defaultOptions: ToastOptions = {
+    type: 'info',
+    duration: 2000,
+    position: 'middle',
+    message: '',
+  }
+  const mergedOptions =
+    typeof options === 'string'
+      ? { ...defaultOptions, message: options }
+      : { ...defaultOptions, ...options }
+  // 映射position到uniapp支持的格式
+  const positionMap: Record<ToastOptions['position'], 'top' | 'bottom' | 'center'> = {
+    top: 'top',
+    middle: 'center',
+    bottom: 'bottom',
+  }
+
+  // 映射图标类型
+  const iconMap: Record<
+    ToastType,
+    'success' | 'error' | 'none' | 'loading' | 'fail' | 'exception'
+  > = {
+    success: 'success',
+    error: 'error',
+    warning: 'fail',
+    info: 'none',
+  }
+
+  // 调用uni.showToast显示提示
+  uni.showToast({
+    title: mergedOptions.message,
+    duration: mergedOptions.duration,
+    position: positionMap[mergedOptions.position],
+    icon: mergedOptions.icon || iconMap[mergedOptions.type],
+    mask: true,
+  })
+}
+
+export const toast = {
+  success: (message: string, options?: Omit<ToastOptions, 'type'>) =>
+    showToast({ ...options, type: 'success', message }),
+  error: (message: string, options?: Omit<ToastOptions, 'type'>) =>
+    showToast({ ...options, type: 'error', message }),
+  warning: (message: string, options?: Omit<ToastOptions, 'type'>) =>
+    showToast({ ...options, type: 'warning', message }),
+  info: (message: string, options?: Omit<ToastOptions, 'type'>) =>
+    showToast({ ...options, type: 'info', message }),
+}

+ 336 - 0
src/utils/uploadFile.ts

@@ -0,0 +1,336 @@
+import { toast } from './toast'
+
+/**
+ * 文件上传钩子函数使用示例
+ * @example
+ * const { loading, error, data, progress, run } = useUpload<IUploadResult>(
+ *   uploadUrl,
+ *   {},
+ *   {
+ *     maxSize: 5, // 最大5MB
+ *     sourceType: ['album'], // 仅支持从相册选择
+ *     onProgress: (p) => console.log(`上传进度:${p}%`),
+ *     onSuccess: (res) => console.log('上传成功', res),
+ *     onError: (err) => console.error('上传失败', err),
+ *   },
+ * )
+ */
+
+/**
+ * 上传文件的URL配置
+ */
+export const uploadFileUrl = {
+  /** 用户头像上传地址 */
+  USER_AVATAR: import.meta.env.VITE_SERVER_BASEURL + '/user/avatar',
+}
+
+/**
+ * 通用文件上传函数(支持直接传入文件路径)
+ * @param url 上传地址
+ * @param filePath 本地文件路径
+ * @param formData 额外表单数据
+ * @param options 上传选项
+ */
+export const useFileUpload = <T = string>(
+  url: string,
+  filePath: string,
+  formData: Record<string, any> = {},
+  options: Omit<UploadOptions, 'sourceType' | 'sizeType' | 'count'> = {},
+) => {
+  return useUpload<T>(
+    url,
+    formData,
+    {
+      ...options,
+      sourceType: ['album'],
+      sizeType: ['original'],
+    },
+    filePath,
+  )
+}
+
+export interface UploadOptions {
+  /** 最大可选择的图片数量,默认为1 */
+  count?: number
+  /** 所选的图片的尺寸,original-原图,compressed-压缩图 */
+  sizeType?: Array<'original' | 'compressed'>
+  /** 选择图片的来源,album-相册,camera-相机 */
+  sourceType?: Array<'album' | 'camera'>
+  /** 文件大小限制,单位:MB */
+  maxSize?: number //
+  /** 上传进度回调函数 */
+  onProgress?: (progress: number) => void
+  /** 上传成功回调函数 */
+  onSuccess?: (res: UniApp.UploadFileSuccessCallbackResult) => void
+  /** 上传失败回调函数 */
+  onError?: (err: Error | UniApp.GeneralCallbackResult) => void
+  /** 上传完成回调函数(无论成功失败) */
+  onComplete?: () => void
+}
+
+/**
+ * 文件上传钩子函数
+ * @template T 上传成功后返回的数据类型
+ * @param url 上传地址
+ * @param formData 额外的表单数据
+ * @param options 上传选项
+ * @returns 上传状态和控制对象
+ */
+export const useUpload = <T = string>(
+  url: string,
+  formData: Record<string, any> = {},
+  options: UploadOptions = {},
+  /** 直接传入文件路径,跳过选择器 */
+  directFilePath?: string,
+) => {
+  /** 上传中状态 */
+  const loading = ref(false)
+  /** 上传错误状态 */
+  const error = ref(false)
+  /** 上传成功后的响应数据 */
+  const data = ref<T>()
+  /** 上传进度(0-100) */
+  const progress = ref(0)
+
+  /** 解构上传选项,设置默认值 */
+  const {
+    /** 最大可选择的图片数量 */
+    count = 1,
+    /** 所选的图片的尺寸 */
+    sizeType = ['original', 'compressed'],
+    /** 选择图片的来源 */
+    sourceType = ['album', 'camera'],
+    /** 文件大小限制(MB) */
+    maxSize = 10,
+    /** 进度回调 */
+    onProgress,
+    /** 成功回调 */
+    onSuccess,
+    /** 失败回调 */
+    onError,
+    /** 完成回调 */
+    onComplete,
+  } = options
+
+  /**
+   * 检查文件大小是否超过限制
+   * @param size 文件大小(字节)
+   * @returns 是否通过检查
+   */
+  const checkFileSize = (size: number) => {
+    const sizeInMB = size / 1024 / 1024
+    if (sizeInMB > maxSize) {
+      toast.warning(`文件大小不能超过${maxSize}MB`)
+      return false
+    }
+    return true
+  }
+  /**
+   * 触发文件选择和上传
+   * 根据平台使用不同的选择器:
+   * - 微信小程序使用 chooseMedia
+   * - 其他平台使用 chooseImage
+   */
+  const run = () => {
+    if (directFilePath) {
+      // 直接使用传入的文件路径
+      loading.value = true
+      progress.value = 0
+      uploadFile<T>({
+        url,
+        tempFilePath: directFilePath,
+        formData,
+        data,
+        error,
+        loading,
+        progress,
+        onProgress,
+        onSuccess,
+        onError,
+        onComplete,
+      })
+      return
+    }
+
+    // #ifdef MP-WEIXIN
+    // 微信小程序环境下使用 chooseMedia API
+    uni.chooseMedia({
+      count,
+      mediaType: ['image'], // 仅支持图片类型
+      sourceType,
+      success: (res) => {
+        const file = res.tempFiles[0]
+        // 检查文件大小是否符合限制
+        if (!checkFileSize(file.size)) return
+
+        // 开始上传
+        loading.value = true
+        progress.value = 0
+        uploadFile<T>({
+          url,
+          tempFilePath: file.tempFilePath,
+          formData,
+          data,
+          error,
+          loading,
+          progress,
+          onProgress,
+          onSuccess,
+          onError,
+          onComplete,
+        })
+      },
+      fail: (err) => {
+        console.error('选择媒体文件失败:', err)
+        error.value = true
+        onError?.(err)
+      },
+    })
+    // #endif
+
+    // #ifndef MP-WEIXIN
+    // 非微信小程序环境下使用 chooseImage API
+    uni.chooseImage({
+      count,
+      sizeType,
+      sourceType,
+      success: (res) => {
+        console.log('选择图片成功:', res)
+
+        // 开始上传
+        loading.value = true
+        progress.value = 0
+        uploadFile<T>({
+          url,
+          tempFilePath: res.tempFilePaths[0],
+          formData,
+          data,
+          error,
+          loading,
+          progress,
+          onProgress,
+          onSuccess,
+          onError,
+          onComplete,
+        })
+      },
+      fail: (err) => {
+        console.error('选择图片失败:', err)
+        error.value = true
+        onError?.(err)
+      },
+    })
+    // #endif
+  }
+
+  return { loading, error, data, progress, run }
+}
+
+/**
+ * 文件上传选项接口
+ * @template T 上传成功后返回的数据类型
+ */
+interface UploadFileOptions<T> {
+  /** 上传地址 */
+  url: string
+  /** 临时文件路径 */
+  tempFilePath: string
+  /** 额外的表单数据 */
+  formData: Record<string, any>
+  /** 上传成功后的响应数据 */
+  data: Ref<T | undefined>
+  /** 上传错误状态 */
+  error: Ref<boolean>
+  /** 上传中状态 */
+  loading: Ref<boolean>
+  /** 上传进度(0-100) */
+  progress: Ref<number>
+  /** 上传进度回调 */
+  onProgress?: (progress: number) => void
+  /** 上传成功回调 */
+  onSuccess?: (res: UniApp.UploadFileSuccessCallbackResult) => void
+  /** 上传失败回调 */
+  onError?: (err: Error | UniApp.GeneralCallbackResult) => void
+  /** 上传完成回调 */
+  onComplete?: () => void
+}
+
+/**
+ * 执行文件上传
+ * @template T 上传成功后返回的数据类型
+ * @param options 上传选项
+ */
+function uploadFile<T>({
+  url,
+  tempFilePath,
+  formData,
+  data,
+  error,
+  loading,
+  progress,
+  onProgress,
+  onSuccess,
+  onError,
+  onComplete,
+}: UploadFileOptions<T>) {
+  try {
+    // 创建上传任务
+    const uploadTask = uni.uploadFile({
+      url,
+      filePath: tempFilePath,
+      name: 'file', // 文件对应的 key
+      formData,
+      header: {
+        // H5环境下不需要手动设置Content-Type,让浏览器自动处理multipart格式
+        // #ifndef H5
+        'Content-Type': 'multipart/form-data',
+        // #endif
+      },
+      // 确保文件名称合法
+      success: (uploadFileRes) => {
+        try {
+          // 解析响应数据
+          const result = JSON.parse(uploadFileRes.data)
+          if (result.code === 1) {
+            // 上传成功
+            data.value = result.data as T
+            onSuccess?.(uploadFileRes)
+          } else {
+            // 业务错误
+            const err = new Error(result.message || '上传失败')
+            error.value = true
+            onError?.(err)
+          }
+        } catch (err) {
+          // 响应解析错误
+          console.error('解析上传响应失败:', err)
+          error.value = true
+          onError?.(new Error('上传响应解析失败'))
+        }
+      },
+      fail: (err) => {
+        // 上传请求失败
+        console.error('上传文件失败:', err)
+        error.value = true
+        onError?.(err)
+      },
+      complete: () => {
+        // 无论成功失败都执行
+        loading.value = false
+        onComplete?.()
+      },
+    })
+
+    // 监听上传进度
+    uploadTask.onProgressUpdate((res) => {
+      progress.value = res.progress
+      onProgress?.(res.progress)
+    })
+  } catch (err) {
+    // 创建上传任务失败
+    console.error('创建上传任务失败:', err)
+    error.value = true
+    loading.value = false
+    onError?.(new Error('创建上传任务失败'))
+  }
+}