Merge branch 'base' into tabbar

This commit is contained in:
feige996 2025-05-28 00:46:30 +08:00
commit 6673c2e269
23 changed files with 4513 additions and 5429 deletions

View File

@ -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"
}
}

View File

@ -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: '我的',
},
],
},
})

7623
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

83
src/api/login.ts Normal file
View File

@ -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
src/api/login.typings.ts Normal file
View File

@ -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
}

View File

@ -1,16 +1,18 @@
<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;
}>(), {
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,
@ -21,24 +23,32 @@ withDefaults(defineProps<{
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>

View File

@ -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

View File

@ -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": []

View File

@ -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
src/pages/login/index.vue Normal file
View File

@ -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>

View File

@ -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
src/pages/mine/index.vue Normal file
View File

@ -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>

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

View File

@ -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 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
}
/**
*
*/
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 clearUserInfo = () => {
userInfo.value = { ...initState }
const res = await _wxLogin(data)
getUserInfo()
return res
}
// 一般没有reset需求不需要的可以删除
const reset = () => {
userInfo.value = { ...initState }
}
const isLogined = computed(() => !!userInfo.value.token)
return {
userInfo,
setUserInfo,
clearUserInfo,
isLogined,
reset,
login,
wxLogin,
getUserInfo,
logout,
}
},
{

View File

@ -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']
}
}

View File

@ -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;

View File

@ -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
}

View File

@ -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
src/utils/toast.ts Normal file
View File

@ -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
src/utils/uploadFile.ts Normal file
View File

@ -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('创建上传任务失败'))
}
}