初始化=商城+金融,用于演示.
41
src/App.ku.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import FgTabbar from '@/tabbar/index.vue'
|
||||
import { isPageTabbar } from './tabbar/store'
|
||||
import { currRoute } from './utils'
|
||||
|
||||
const isCurrentPageTabbar = ref(true)
|
||||
onShow(() => {
|
||||
console.log('App.ku.vue onShow', currRoute())
|
||||
const { path } = currRoute()
|
||||
// “蜡笔小开心”提到本地是 '/pages/index/index',线上是 '/' 导致线上 tabbar 不见了
|
||||
// 所以这里需要判断一下,如果是 '/' 就当做首页,也要显示 tabbar
|
||||
if (path === '/') {
|
||||
isCurrentPageTabbar.value = true
|
||||
}
|
||||
else {
|
||||
isCurrentPageTabbar.value = isPageTabbar(path)
|
||||
}
|
||||
})
|
||||
|
||||
const helloKuRoot = ref('Hello AppKuVue')
|
||||
|
||||
const exposeRef = ref('this is form app.Ku.vue')
|
||||
|
||||
defineExpose({
|
||||
exposeRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view>
|
||||
<!-- 这个先隐藏了,知道这样用就行 -->
|
||||
<view class="hidden text-center">
|
||||
{{ helloKuRoot }},这里可以配置全局的东西
|
||||
</view>
|
||||
|
||||
<KuRootView />
|
||||
|
||||
<FgTabbar v-if="isCurrentPageTabbar" />
|
||||
</view>
|
||||
</template>
|
||||
26
src/App.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
|
||||
import { navigateToInterceptor } from '@/router/interceptor'
|
||||
|
||||
onLaunch((options) => {
|
||||
console.log('App.vue onLaunch', options)
|
||||
})
|
||||
onShow((options) => {
|
||||
console.log('App.vue onShow', options)
|
||||
// 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
|
||||
// https://github.com/unibest-tech/unibest/issues/192
|
||||
if (options?.path) {
|
||||
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
|
||||
}
|
||||
else {
|
||||
navigateToInterceptor.invoke({ url: '/' })
|
||||
}
|
||||
})
|
||||
onHide(() => {
|
||||
console.log('App Hide')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
</style>
|
||||
17
src/api/foo-alova.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { API_DOMAINS, http } from '@/http/alova'
|
||||
|
||||
export interface IFoo {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export function foo() {
|
||||
return http.Get<IFoo>('/foo', {
|
||||
params: {
|
||||
name: '菲鸽',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
meta: { domain: API_DOMAINS.SECONDARY }, // 用于切换请求地址
|
||||
})
|
||||
}
|
||||
43
src/api/foo.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { http } from '@/http/http'
|
||||
|
||||
export interface IFoo {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export function foo() {
|
||||
return http.Get<IFoo>('/foo', {
|
||||
params: {
|
||||
name: '菲鸽',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface IFooItem {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** GET 请求 */
|
||||
export async function getFooAPI(name: string) {
|
||||
return await http.get<IFooItem>('/foo', { name })
|
||||
}
|
||||
/** GET 请求;支持 传递 header 的范例 */
|
||||
export function getFooAPI2(name: string) {
|
||||
return http.get<IFooItem>('/foo', { name }, { 'Content-Type-100': '100' })
|
||||
}
|
||||
|
||||
/** POST 请求 */
|
||||
export function postFooAPI(name: string) {
|
||||
return http.post<IFooItem>('/foo', { name })
|
||||
}
|
||||
/** POST 请求;需要传递 query 参数的范例;微信小程序经常有同时需要query参数和body参数的场景 */
|
||||
export function postFooAPI2(name: string) {
|
||||
return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 })
|
||||
}
|
||||
/** POST 请求;支持 传递 header 的范例 */
|
||||
export function postFooAPI3(name: string) {
|
||||
return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 }, { 'Content-Type-100': '100' })
|
||||
}
|
||||
85
src/api/login.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { IAuthLoginRes, ICaptcha, IDoubleTokenRes, IUpdateInfo, IUpdatePassword, IUserInfoRes } from './types/login'
|
||||
import { http } from '@/http/http'
|
||||
|
||||
/**
|
||||
* 登录表单
|
||||
*/
|
||||
export interface ILoginForm {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
* @returns ICaptcha 验证码
|
||||
*/
|
||||
export function getCode() {
|
||||
return http.get<ICaptcha>('/user/getCode')
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param loginForm 登录表单
|
||||
*/
|
||||
export function login(loginForm: ILoginForm) {
|
||||
return http.post<IAuthLoginRes>('/auth/login', loginForm)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新token
|
||||
* @param refreshToken 刷新token
|
||||
*/
|
||||
export function refreshToken(refreshToken: string) {
|
||||
return http.post<IDoubleTokenRes>('/auth/refreshToken', { refreshToken })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
export function getUserInfo() {
|
||||
return http.get<IUserInfoRes>('/user/info')
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
export function logout() {
|
||||
return http.get<void>('/auth/logout')
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户信息
|
||||
*/
|
||||
export function updateInfo(data: IUpdateInfo) {
|
||||
return http.post('/user/updateInfo', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户密码
|
||||
*/
|
||||
export function updateUserPassword(data: IUpdatePassword) {
|
||||
return http.post('/user/updatePassword', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信登录凭证
|
||||
* @returns Promise 包含微信登录凭证(code)
|
||||
*/
|
||||
export function 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 function wxLogin(data: { code: string }) {
|
||||
return http.post<IAuthLoginRes>('/auth/wxLogin', data)
|
||||
}
|
||||
97
src/api/types/login.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// 认证模式类型
|
||||
export type AuthMode = 'single' | 'double'
|
||||
|
||||
// 单Token响应类型
|
||||
export interface ISingleTokenRes {
|
||||
token: string
|
||||
expiresIn: number // 有效期(秒)
|
||||
}
|
||||
|
||||
// 双Token响应类型
|
||||
export interface IDoubleTokenRes {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
accessExpiresIn: number // 访问令牌有效期(秒)
|
||||
refreshExpiresIn: number // 刷新令牌有效期(秒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录返回的信息,其实就是 token 信息
|
||||
*/
|
||||
export type IAuthLoginRes = ISingleTokenRes | IDoubleTokenRes
|
||||
|
||||
/**
|
||||
* 用户信息
|
||||
*/
|
||||
export interface IUserInfoRes {
|
||||
userId: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
[key: string]: any // 允许其他扩展字段
|
||||
}
|
||||
|
||||
// 认证存储数据结构
|
||||
export interface AuthStorage {
|
||||
mode: AuthMode
|
||||
tokens: ISingleTokenRes | IDoubleTokenRes
|
||||
userInfo?: IUserInfoRes
|
||||
loginTime: number // 登录时间戳
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
export interface ICaptcha {
|
||||
captchaEnabled: boolean
|
||||
uuid: string
|
||||
image: string
|
||||
}
|
||||
/**
|
||||
* 上传成功的信息
|
||||
*/
|
||||
export interface IUploadSuccessInfo {
|
||||
fileId: number
|
||||
originalName: string
|
||||
fileName: string
|
||||
storagePath: string
|
||||
fileHash: string
|
||||
fileType: string
|
||||
fileBusinessType: string
|
||||
fileSize: number
|
||||
}
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
export interface IUpdateInfo {
|
||||
id: number
|
||||
name: string
|
||||
sex: string
|
||||
}
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
export interface IUpdatePassword {
|
||||
id: number
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为单Token响应
|
||||
* @param tokenRes 登录响应数据
|
||||
* @returns 是否为单Token响应
|
||||
*/
|
||||
export function isSingleTokenRes(tokenRes: IAuthLoginRes): tokenRes is ISingleTokenRes {
|
||||
return 'token' in tokenRes && !('refreshToken' in tokenRes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为双Token响应
|
||||
* @param tokenRes 登录响应数据
|
||||
* @returns 是否为双Token响应
|
||||
*/
|
||||
export function isDoubleTokenRes(tokenRes: IAuthLoginRes): tokenRes is IDoubleTokenRes {
|
||||
return 'accessToken' in tokenRes && 'refreshToken' in tokenRes
|
||||
}
|
||||
0
src/components/.gitkeep
Normal file
35
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-svg-loader" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** 网站标题,应用名称 */
|
||||
readonly VITE_APP_TITLE: string
|
||||
/** 服务端口号 */
|
||||
readonly VITE_SERVER_PORT: string
|
||||
/** 后台接口地址 */
|
||||
readonly VITE_SERVER_BASEURL: string
|
||||
/** H5是否需要代理 */
|
||||
readonly VITE_APP_PROXY_ENABLE: 'true' | 'false'
|
||||
/** H5是否需要代理,需要的话有个前缀 */
|
||||
readonly VITE_APP_PROXY_PREFIX: string
|
||||
/** 后端是否有统一前缀 /api */
|
||||
readonly VITE_SERVER_HAS_API_PREFIX: 'true' | 'false'
|
||||
/** 认证模式,'single' | 'double' ==> 单token | 双token */
|
||||
readonly VITE_AUTH_MODE: 'single' | 'double'
|
||||
/** 是否清除console */
|
||||
readonly VITE_DELETE_CONSOLE: string
|
||||
// 更多环境变量...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare const __VITE_APP_PROXY__: 'true' | 'false'
|
||||
54
src/hooks/useRequest.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface IUseRequestOptions<T> {
|
||||
/** 是否立即执行 */
|
||||
immediate?: boolean
|
||||
/** 初始化数据 */
|
||||
initialData?: T
|
||||
}
|
||||
|
||||
interface IUseRequestReturn<T, P = undefined> {
|
||||
loading: Ref<boolean>
|
||||
error: Ref<boolean | Error>
|
||||
data: Ref<T | undefined>
|
||||
run: (args?: P) => Promise<T | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* useRequest是一个定制化的请求钩子,用于处理异步请求和响应。
|
||||
* @param func 一个执行异步请求的函数,返回一个包含响应数据的Promise。
|
||||
* @param options 包含请求选项的对象 {immediate, initialData}。
|
||||
* @param options.immediate 是否立即执行请求,默认为false。
|
||||
* @param options.initialData 初始化数据,默认为undefined。
|
||||
* @returns 返回一个对象{loading, error, data, run},包含请求的加载状态、错误信息、响应数据和手动触发请求的函数。
|
||||
*/
|
||||
export default function useRequest<T, P = undefined>(
|
||||
func: (args?: P) => Promise<T>,
|
||||
options: IUseRequestOptions<T> = { immediate: false },
|
||||
): IUseRequestReturn<T, P> {
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
|
||||
const run = async (args?: P) => {
|
||||
loading.value = true
|
||||
return func(args)
|
||||
.then((res) => {
|
||||
data.value = res
|
||||
error.value = false
|
||||
return data.value
|
||||
})
|
||||
.catch((err) => {
|
||||
error.value = err
|
||||
throw err
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
if (options.immediate) {
|
||||
(run as (args: P) => Promise<T | undefined>)({} as P)
|
||||
}
|
||||
return { loading, error, data, run }
|
||||
}
|
||||
116
src/hooks/useScroll.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 上拉刷新和下拉加载更多
|
||||
|
||||
在 unibest 框架中,我们通过组合 `useScroll` Hook 可结合 `scroll-view` 组件来轻松实现上拉刷新和下拉加载更多的功能。
|
||||
场景一 页面滚动
|
||||
|
||||
```
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '上拉刷新和下拉加载更多',
|
||||
enablePullDownRefresh: true,
|
||||
onReachBottomDistance: 100,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
场景二 局部滚动 结合 `scroll-view`
|
||||
|
||||
## 关键文件
|
||||
|
||||
- `src/hooks/useScroll.ts`: 提供了核心的滚动逻辑处理 Hook。
|
||||
- `src/pages-sub/demo/scroll.vue`: 一个具体的实现示例页面。
|
||||
|
||||
## `useScroll` Hook
|
||||
|
||||
`useScroll` 是一个 Vue Composition API Hook,它封装了处理下拉刷新和上拉加载的通用逻辑。
|
||||
|
||||
### 主要功能
|
||||
|
||||
- **管理加载状态**: 自动处理 `loading`(加载中)、`finished`(已加载全部)和 `error`(加载失败)等状态。
|
||||
- **分页逻辑**: 内部维护分页参数(页码 `page` 和每页数量 `pageSize`)。
|
||||
- **事件处理**: 提供 `onScrollToLower`(滚动到底部)、`onRefresherRefresh`(下拉刷新)等方法,用于在视图层触发。
|
||||
- **数据合并**: 自动将新加载的数据追加到现有列表 `list` 中。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```typescript
|
||||
import { useScroll } from '@/hooks/useScroll'
|
||||
import { getList } from '@/service/list' // 你的数据请求API
|
||||
|
||||
const {
|
||||
list, // 响应式的数据列表
|
||||
loading, // 是否加载中
|
||||
finished, // 是否已全部加载
|
||||
error, // 是否加载失败
|
||||
onScrollToLower, // 滚动到底部时触发的事件
|
||||
onRefresherRefresh, // 下拉刷新时触发的事件
|
||||
} = useScroll(getList) // 将获取数据的API函数传入
|
||||
```
|
||||
|
||||
## `scroll-view` 组件
|
||||
|
||||
`scroll-view` 是 uni-app 提供的可滚动视图区域组件,它提供了一系列属性来支持下拉刷新和上拉加载。
|
||||
|
||||
### 关键属性
|
||||
|
||||
- `scroll-y`: 允许纵向滚动。
|
||||
- `refresher-enabled`: 启用下拉刷新。
|
||||
- `refresher-triggered`: 控制下拉刷新动画的显示与隐藏,通过 `loading` 状态绑定。
|
||||
- `@scrolltolower`: 滚动到底部时触发的事件,绑定 `onScrollToLower` 方法。
|
||||
- `@refresherrefresh`: 触发下拉刷新时触发的事件,绑定 `onRefresherRefresh` 方法。
|
||||
|
||||
## 示例代码
|
||||
|
||||
以下是 `src/pages-sub/demo/scroll.vue` 中的核心代码,展示了如何将 `useScroll` 和 `scroll-view` 结合使用。
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view class="scroll-page">
|
||||
<scroll-view
|
||||
class="scroll-view"
|
||||
scroll-y
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="loading"
|
||||
@scrolltolower="onScrollToLower"
|
||||
@refresherrefresh="onRefresherRefresh"
|
||||
>
|
||||
<view v-for="item in list" :key="item.id" class="scroll-item">
|
||||
{{ item.name }}
|
||||
</view>
|
||||
|
||||
<!-- 加载状态提示 -->
|
||||
<view v-if="loading" class="loading-tip">加载中...</view>
|
||||
<view v-if="finished" class="finished-tip">没有更多了</view>
|
||||
<view v-if="error" class="error-tip">加载失败,请重试</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScroll } from '@/hooks/useScroll'
|
||||
import { getList } from '@/service/list'
|
||||
|
||||
const { list, loading, finished, error, onScrollToLower, onRefresherRefresh } = useScroll(getList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 样式省略 */
|
||||
.scroll-page, .scroll-view {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## 实现步骤总结
|
||||
|
||||
1. **创建API**: 确保你有一个返回分页数据的API请求函数(例如 `getList`),它应该接受页码和页面大小作为参数。
|
||||
2. **调用 `useScroll`**: 在你的页面脚本中,导入并调用 `useScroll` Hook,将你的API函数作为参数传入。
|
||||
3. **模板绑定**:
|
||||
- 使用 `scroll-view` 组件作为滚动容器。
|
||||
- 将其 `refresher-triggered` 属性绑定到 `useScroll` 返回的 `loading` 状态。
|
||||
- 将其 `@scrolltolower` 事件绑定到 `onScrollToLower` 方法。
|
||||
- 将其 `@refresherrefresh` 事件绑定到 `onRefresherRefresh` 方法。
|
||||
4. **渲染列表**: 使用 `v-for` 指令渲染 `useScroll` 返回的 `list` 数组。
|
||||
5. **添加加载提示**: 根据 `loading`, `finished`, `error` 状态,在列表底部显示不同的提示信息,提升用户体验。
|
||||
|
||||
通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。
|
||||
74
src/hooks/useScroll.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
interface UseScrollOptions<T> {
|
||||
fetchData: (page: number, pageSize: number) => Promise<T[]>
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
interface UseScrollReturn<T> {
|
||||
list: Ref<T[]>
|
||||
loading: Ref<boolean>
|
||||
finished: Ref<boolean>
|
||||
error: Ref<any>
|
||||
refresh: () => Promise<void>
|
||||
loadMore: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useScroll<T>({
|
||||
fetchData,
|
||||
pageSize = 10,
|
||||
}: UseScrollOptions<T>): UseScrollReturn<T> {
|
||||
const list = ref<T[]>([]) as Ref<T[]>
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const error = ref<any>(null)
|
||||
const page = ref(1)
|
||||
|
||||
const loadData = async () => {
|
||||
if (loading.value || finished.value)
|
||||
return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await fetchData(page.value, pageSize)
|
||||
if (data.length < pageSize) {
|
||||
finished.value = true
|
||||
}
|
||||
list.value.push(...data)
|
||||
page.value++
|
||||
}
|
||||
catch (err) {
|
||||
error.value = err
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadData()
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
|
||||
return {
|
||||
list,
|
||||
loading,
|
||||
finished,
|
||||
error,
|
||||
refresh,
|
||||
loadMore,
|
||||
}
|
||||
}
|
||||
171
src/hooks/useUpload.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { ref } from 'vue'
|
||||
import { getEnvBaseUrl } from '@/utils/index'
|
||||
|
||||
const VITE_UPLOAD_BASEURL = `${getEnvBaseUrl()}/upload`
|
||||
|
||||
type TfileType = 'image' | 'file'
|
||||
type TImage = 'png' | 'jpg' | 'jpeg' | 'webp' | '*'
|
||||
type TFile = 'doc' | 'docx' | 'ppt' | 'zip' | 'xls' | 'xlsx' | 'txt' | TImage
|
||||
|
||||
interface TOptions<T extends TfileType> {
|
||||
formData?: Record<string, any>
|
||||
maxSize?: number
|
||||
accept?: T extends 'image' ? TImage[] : TFile[]
|
||||
fileType?: T
|
||||
success?: (params: any) => void
|
||||
error?: (err: any) => void
|
||||
}
|
||||
|
||||
export default function useUpload<T extends TfileType>(options: TOptions<T> = {} as TOptions<T>) {
|
||||
const {
|
||||
formData = {},
|
||||
maxSize = 5 * 1024 * 1024,
|
||||
accept = ['*'],
|
||||
fileType = 'image',
|
||||
success,
|
||||
error: onError,
|
||||
} = options
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
const data = ref<any>(null)
|
||||
|
||||
const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string, size: number }) => {
|
||||
if (size > maxSize) {
|
||||
uni.showToast({
|
||||
title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`,
|
||||
icon: 'none',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// const fileExtension = file?.tempFiles?.name?.split('.').pop()?.toLowerCase()
|
||||
// const isTypeValid = accept.some((type) => type === '*' || type.toLowerCase() === fileExtension)
|
||||
|
||||
// if (!isTypeValid) {
|
||||
// uni.showToast({
|
||||
// title: `仅支持 ${accept.join(', ')} 格式的文件`,
|
||||
// icon: 'none',
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
|
||||
loading.value = true
|
||||
uploadFile({
|
||||
tempFilePath,
|
||||
formData,
|
||||
onSuccess: (res) => {
|
||||
// 修改这里的解析逻辑,适应不同平台的返回格式
|
||||
let parsedData = res
|
||||
try {
|
||||
// 尝试解析为JSON
|
||||
const jsonData = JSON.parse(res)
|
||||
// 检查是否包含data字段
|
||||
parsedData = jsonData.data || jsonData
|
||||
}
|
||||
catch (e) {
|
||||
// 如果解析失败,使用原始数据
|
||||
console.log('Response is not JSON, using raw data:', res)
|
||||
}
|
||||
data.value = parsedData
|
||||
// console.log('上传成功', res)
|
||||
success?.(parsedData)
|
||||
},
|
||||
onError: (err) => {
|
||||
error.value = err
|
||||
onError?.(err)
|
||||
},
|
||||
onComplete: () => {
|
||||
loading.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const run = () => {
|
||||
// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
|
||||
// 微信小程序在2023年10月17日之后,使用本API需要配置隐私协议
|
||||
const chooseFileOptions = {
|
||||
count: 1,
|
||||
success: (res: any) => {
|
||||
console.log('File selected successfully:', res)
|
||||
// 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]}
|
||||
// h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]}
|
||||
// h5的File有以下字段:{name: "girl.jpeg", size: 48976, type: "image/jpeg"}
|
||||
// App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]}
|
||||
// App的File有以下字段:{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976}
|
||||
let tempFilePath = ''
|
||||
let size = 0
|
||||
// #ifdef MP-WEIXIN
|
||||
tempFilePath = res.tempFiles[0].tempFilePath
|
||||
size = res.tempFiles[0].size
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
tempFilePath = res.tempFilePaths[0]
|
||||
size = res.tempFiles[0].size
|
||||
// #endif
|
||||
handleFileChoose({ tempFilePath, size })
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('File selection failed:', err)
|
||||
error.value = err
|
||||
onError?.(err)
|
||||
},
|
||||
}
|
||||
|
||||
if (fileType === 'image') {
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.chooseMedia({
|
||||
...chooseFileOptions,
|
||||
mediaType: ['image'],
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.chooseImage(chooseFileOptions)
|
||||
// #endif
|
||||
}
|
||||
else {
|
||||
uni.chooseFile({
|
||||
...chooseFileOptions,
|
||||
type: 'all',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, data, run }
|
||||
}
|
||||
|
||||
async function uploadFile({
|
||||
tempFilePath,
|
||||
formData,
|
||||
onSuccess,
|
||||
onError,
|
||||
onComplete,
|
||||
}: {
|
||||
tempFilePath: string
|
||||
formData: Record<string, any>
|
||||
onSuccess: (data: any) => void
|
||||
onError: (err: any) => void
|
||||
onComplete: () => void
|
||||
}) {
|
||||
uni.uploadFile({
|
||||
url: VITE_UPLOAD_BASEURL,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
formData,
|
||||
success: (uploadFileRes) => {
|
||||
try {
|
||||
const data = uploadFileRes.data
|
||||
onSuccess(data)
|
||||
}
|
||||
catch (err) {
|
||||
onError(err)
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('Upload failed:', err)
|
||||
onError(err)
|
||||
},
|
||||
complete: onComplete,
|
||||
})
|
||||
}
|
||||
13
src/http/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 请求库
|
||||
|
||||
目前unibest支持3种请求库:
|
||||
- 菲鸽简单封装的 `简单版本http`,路径(src/http/http.ts),对应的示例在 src/api/foo.ts
|
||||
- `alova 的 http`,路径(src/http/alova.ts),对应的示例在 src/api/foo-alova.ts
|
||||
- `vue-query`, 路径(src/http/vue-query.ts), 目前主要用在自动生成接口,详情看(https://unibest.tech/base/17-generate),示例在 src/service/app 文件夹
|
||||
|
||||
## 如何选择
|
||||
如果您以前用过 alova 或者 vue-query,可以优先使用您熟悉的。
|
||||
如果您的项目简单,简单版本的http 就够了,也不会增加包体积。(发版的时候可以去掉alova和vue-query,如果没有超过包体积,留着也无所谓 ^_^)
|
||||
|
||||
## roadmap
|
||||
菲鸽最近在优化脚手架,后续可以选择是否使用第三方的请求库,以及选择什么请求库。还在开发中,大概月底出来(8月31号)。
|
||||
119
src/http/alova.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { uniappRequestAdapter } from '@alova/adapter-uniapp'
|
||||
import type { IResponse } from './types'
|
||||
import AdapterUniapp from '@alova/adapter-uniapp'
|
||||
import { createAlova } from 'alova'
|
||||
import { createServerTokenAuthentication } from 'alova/client'
|
||||
import VueHook from 'alova/vue'
|
||||
import { toLoginPage } from '@/utils/toLoginPage'
|
||||
import { ContentTypeEnum, ResultEnum, ShowMessage } from './tools/enum'
|
||||
|
||||
// 配置动态Tag
|
||||
export const API_DOMAINS = {
|
||||
DEFAULT: import.meta.env.VITE_SERVER_BASEURL,
|
||||
SECONDARY: import.meta.env.VITE_SERVER_BASEURL_SECONDARY,
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建请求实例
|
||||
*/
|
||||
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<
|
||||
typeof VueHook,
|
||||
typeof uniappRequestAdapter
|
||||
>({
|
||||
// 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛
|
||||
refreshTokenOnError: {
|
||||
isExpired: (error) => {
|
||||
return error.response?.status === ResultEnum.Unauthorized
|
||||
},
|
||||
handler: async () => {
|
||||
try {
|
||||
// await authLogin();
|
||||
}
|
||||
catch (error) {
|
||||
// 切换到登录页
|
||||
toLoginPage({ mode: 'reLaunch' })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* alova 请求实例
|
||||
*/
|
||||
const alovaInstance = createAlova({
|
||||
baseURL: API_DOMAINS.DEFAULT,
|
||||
...AdapterUniapp(),
|
||||
timeout: 5000,
|
||||
statesHook: VueHook,
|
||||
|
||||
beforeRequest: onAuthRequired((method) => {
|
||||
// 设置默认 Content-Type
|
||||
method.config.headers = {
|
||||
ContentType: ContentTypeEnum.JSON,
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
...method.config.headers,
|
||||
}
|
||||
|
||||
const { config } = method
|
||||
const ignoreAuth = !config.meta?.ignoreAuth
|
||||
console.log('ignoreAuth===>', ignoreAuth)
|
||||
// 处理认证信息 自行处理认证问题
|
||||
if (ignoreAuth) {
|
||||
const token = 'getToken()'
|
||||
if (!token) {
|
||||
throw new Error('[请求错误]:未登录')
|
||||
}
|
||||
// method.config.headers.token = token;
|
||||
}
|
||||
|
||||
// 处理动态域名
|
||||
if (config.meta?.domain) {
|
||||
method.baseURL = config.meta.domain
|
||||
console.log('当前域名', method.baseURL)
|
||||
}
|
||||
}),
|
||||
|
||||
responded: onResponseRefreshToken((response, method) => {
|
||||
const { config } = method
|
||||
const { requestType } = config
|
||||
const {
|
||||
statusCode,
|
||||
data: rawData,
|
||||
errMsg,
|
||||
} = response as UniNamespace.RequestSuccessCallbackResult
|
||||
|
||||
// 处理特殊请求类型(上传/下载)
|
||||
if (requestType === 'upload' || requestType === 'download') {
|
||||
return response
|
||||
}
|
||||
|
||||
// 处理 HTTP 状态码错误
|
||||
if (statusCode !== 200) {
|
||||
const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]`
|
||||
console.error('errorMessage===>', errorMessage)
|
||||
uni.showToast({
|
||||
title: errorMessage,
|
||||
icon: 'error',
|
||||
})
|
||||
throw new Error(`${errorMessage}:${errMsg}`)
|
||||
}
|
||||
|
||||
// 处理业务逻辑错误
|
||||
const { code, message, data } = rawData as IResponse
|
||||
// 0和200当做成功都很普遍,这里直接兼容两者,见 ResultEnum
|
||||
if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) {
|
||||
if (config.meta?.toast !== false) {
|
||||
uni.showToast({
|
||||
title: message,
|
||||
icon: 'none',
|
||||
})
|
||||
}
|
||||
throw new Error(`请求错误[${code}]:${message}`)
|
||||
}
|
||||
// 处理成功响应,返回业务数据
|
||||
return data
|
||||
}),
|
||||
})
|
||||
|
||||
export const http = alovaInstance
|
||||
199
src/http/http.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { IDoubleTokenRes } from '@/api/types/login'
|
||||
import type { CustomRequestOptions, IResponse } from '@/http/types'
|
||||
import { nextTick } from 'vue'
|
||||
import { useTokenStore } from '@/store/token'
|
||||
import { isDoubleTokenMode } from '@/utils'
|
||||
import { toLoginPage } from '@/utils/toLoginPage'
|
||||
import { ResultEnum } from './tools/enum'
|
||||
|
||||
// 刷新 token 状态管理
|
||||
let refreshing = false // 防止重复刷新 token 标识
|
||||
let taskQueue: (() => void)[] = [] // 刷新 token 请求队列
|
||||
|
||||
export function http<T>(options: CustomRequestOptions) {
|
||||
// 1. 返回 Promise 对象
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
dataType: 'json',
|
||||
// #ifndef MP-WEIXIN
|
||||
responseType: 'json',
|
||||
// #endif
|
||||
// 响应成功
|
||||
success: async (res) => {
|
||||
const responseData = res.data as IResponse<T>
|
||||
const { code } = responseData
|
||||
|
||||
// 检查是否是401错误(包括HTTP状态码401或业务码401)
|
||||
const isTokenExpired = res.statusCode === 401 || code === 401
|
||||
|
||||
if (isTokenExpired) {
|
||||
const tokenStore = useTokenStore()
|
||||
if (!isDoubleTokenMode) {
|
||||
// 未启用双token策略,清理用户信息,跳转到登录页
|
||||
tokenStore.logout()
|
||||
toLoginPage()
|
||||
return reject(res)
|
||||
}
|
||||
|
||||
/* -------- 无感刷新 token ----------- */
|
||||
const { refreshToken } = tokenStore.tokenInfo as IDoubleTokenRes || {}
|
||||
// token 失效的,且有刷新 token 的,才放到请求队列里
|
||||
if (refreshToken) {
|
||||
taskQueue.push(() => {
|
||||
resolve(http<T>(options))
|
||||
})
|
||||
}
|
||||
|
||||
// 如果有 refreshToken 且未在刷新中,发起刷新 token 请求
|
||||
if (refreshToken && !refreshing) {
|
||||
refreshing = true
|
||||
try {
|
||||
// 发起刷新 token 请求(使用 store 的 refreshToken 方法)
|
||||
await tokenStore.refreshToken()
|
||||
// 刷新 token 成功
|
||||
refreshing = false
|
||||
nextTick(() => {
|
||||
// 关闭其他弹窗
|
||||
uni.hideToast()
|
||||
uni.showToast({
|
||||
title: 'token 刷新成功',
|
||||
icon: 'none',
|
||||
})
|
||||
})
|
||||
// 将任务队列的所有任务重新请求
|
||||
taskQueue.forEach(task => task())
|
||||
}
|
||||
catch (refreshErr) {
|
||||
console.error('刷新 token 失败:', refreshErr)
|
||||
refreshing = false
|
||||
// 刷新 token 失败,跳转到登录页
|
||||
nextTick(() => {
|
||||
// 关闭其他弹窗
|
||||
uni.hideToast()
|
||||
uni.showToast({
|
||||
title: '登录已过期,请重新登录',
|
||||
icon: 'none',
|
||||
})
|
||||
})
|
||||
// 清除用户信息
|
||||
await tokenStore.logout()
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
toLoginPage()
|
||||
}, 2000)
|
||||
}
|
||||
finally {
|
||||
// 不管刷新 token 成功与否,都清空任务队列
|
||||
taskQueue = []
|
||||
}
|
||||
}
|
||||
|
||||
return reject(res)
|
||||
}
|
||||
|
||||
// 处理其他成功状态(HTTP状态码200-299)
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
// 处理业务逻辑错误
|
||||
if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) {
|
||||
uni.showToast({
|
||||
icon: 'none',
|
||||
title: responseData.msg || responseData.message || '请求错误',
|
||||
})
|
||||
}
|
||||
return resolve(responseData.data)
|
||||
}
|
||||
|
||||
// 处理其他错误
|
||||
!options.hideErrorToast
|
||||
&& uni.showToast({
|
||||
icon: 'none',
|
||||
title: (res.data as any).msg || '请求错误',
|
||||
})
|
||||
reject(res)
|
||||
},
|
||||
// 响应失败
|
||||
fail(err) {
|
||||
uni.showToast({
|
||||
icon: 'none',
|
||||
title: '网络错误,换个网络试试',
|
||||
})
|
||||
reject(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
* @param url 后台地址
|
||||
* @param query 请求query参数
|
||||
* @param header 请求头,默认为json格式
|
||||
* @returns
|
||||
*/
|
||||
export function httpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||
return http<T>({
|
||||
url,
|
||||
query,
|
||||
method: 'GET',
|
||||
header,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
* @param url 后台地址
|
||||
* @param data 请求body参数
|
||||
* @param query 请求query参数,post请求也支持query,很多微信接口都需要
|
||||
* @param header 请求头,默认为json格式
|
||||
* @returns
|
||||
*/
|
||||
export function httpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||
return http<T>({
|
||||
url,
|
||||
query,
|
||||
data,
|
||||
method: 'POST',
|
||||
header,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
/**
|
||||
* PUT 请求
|
||||
*/
|
||||
export function httpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||
return http<T>({
|
||||
url,
|
||||
data,
|
||||
query,
|
||||
method: 'PUT',
|
||||
header,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求(无请求体,仅 query)
|
||||
*/
|
||||
export function httpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||
return http<T>({
|
||||
url,
|
||||
query,
|
||||
method: 'DELETE',
|
||||
header,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// 支持与 axios 类似的API调用
|
||||
http.get = httpGet
|
||||
http.post = httpPost
|
||||
http.put = httpPut
|
||||
http.delete = httpDelete
|
||||
|
||||
// 支持与 alovaJS 类似的API调用
|
||||
http.Get = httpGet
|
||||
http.Post = httpPost
|
||||
http.Put = httpPut
|
||||
http.Delete = httpDelete
|
||||
69
src/http/interceptor.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { CustomRequestOptions } from '@/http/types'
|
||||
import { useTokenStore } from '@/store'
|
||||
import { getEnvBaseUrl } from '@/utils'
|
||||
import { stringifyQuery } from './tools/queryString'
|
||||
|
||||
// 请求基准地址
|
||||
const baseUrl = getEnvBaseUrl()
|
||||
|
||||
// 拦截器配置
|
||||
const httpInterceptor = {
|
||||
// 拦截前触发
|
||||
invoke(options: CustomRequestOptions) {
|
||||
// 如果您使用了alova,则请把下面的代码放开注释
|
||||
// alova 执行流程:alova beforeRequest --> 本拦截器 --> alova responded
|
||||
// return options
|
||||
|
||||
// 非 alova 请求,正常执行
|
||||
// 接口请求支持通过 query 参数配置 queryString
|
||||
if (options.query) {
|
||||
const queryStr = stringifyQuery(options.query)
|
||||
if (options.url.includes('?')) {
|
||||
options.url += `&${queryStr}`
|
||||
}
|
||||
else {
|
||||
options.url += `?${queryStr}`
|
||||
}
|
||||
}
|
||||
// 非 http 开头需拼接地址
|
||||
if (!options.url.startsWith('http')) {
|
||||
// #ifdef H5
|
||||
if (JSON.parse(import.meta.env.VITE_APP_PROXY_ENABLE)) {
|
||||
// 自动拼接代理前缀
|
||||
options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url
|
||||
}
|
||||
else {
|
||||
options.url = baseUrl + options.url
|
||||
}
|
||||
// #endif
|
||||
// 非H5正常拼接
|
||||
// #ifndef H5
|
||||
options.url = baseUrl + options.url
|
||||
// #endif
|
||||
// TIPS: 如果需要对接多个后端服务,也可以在这里处理,拼接成所需要的地址
|
||||
}
|
||||
// 1. 请求超时
|
||||
options.timeout = 60000 // 60s
|
||||
// 2. (可选)添加小程序端请求头标识
|
||||
options.header = {
|
||||
...options.header,
|
||||
}
|
||||
// 3. 添加 token 请求头标识
|
||||
const tokenStore = useTokenStore()
|
||||
const token = tokenStore.validToken
|
||||
|
||||
if (token) {
|
||||
options.header.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return options
|
||||
},
|
||||
}
|
||||
|
||||
export const requestInterceptor = {
|
||||
install() {
|
||||
// 拦截 request 请求
|
||||
uni.addInterceptor('request', httpInterceptor)
|
||||
// 拦截 uploadFile 文件上传
|
||||
uni.addInterceptor('uploadFile', httpInterceptor)
|
||||
},
|
||||
}
|
||||
68
src/http/tools/enum.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export enum ResultEnum {
|
||||
// 0和200当做成功都很普遍,这里直接兼容两者(PS:0和200通常都不会当做错误码,但是有的接口会返回0,有的接口会返回200)
|
||||
Success0 = 0, // 成功
|
||||
Success200 = 200, // 成功
|
||||
Error = 400, // 错误
|
||||
Unauthorized = 401, // 未授权
|
||||
Forbidden = 403, // 禁止访问(原为forbidden)
|
||||
NotFound = 404, // 未找到(原为notFound)
|
||||
MethodNotAllowed = 405, // 方法不允许(原为methodNotAllowed)
|
||||
RequestTimeout = 408, // 请求超时(原为requestTimeout)
|
||||
InternalServerError = 500, // 服务器错误(原为internalServerError)
|
||||
NotImplemented = 501, // 未实现(原为notImplemented)
|
||||
BadGateway = 502, // 网关错误(原为badGateway)
|
||||
ServiceUnavailable = 503, // 服务不可用(原为serviceUnavailable)
|
||||
GatewayTimeout = 504, // 网关超时(原为gatewayTimeout)
|
||||
HttpVersionNotSupported = 505, // HTTP版本不支持(原为httpVersionNotSupported)
|
||||
}
|
||||
export enum ContentTypeEnum {
|
||||
JSON = 'application/json;charset=UTF-8',
|
||||
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
FORM_DATA = 'multipart/form-data;charset=UTF-8',
|
||||
}
|
||||
/**
|
||||
* 根据状态码,生成对应的错误信息
|
||||
* @param {number|string} status 状态码
|
||||
* @returns {string} 错误信息
|
||||
*/
|
||||
export function ShowMessage(status: number | string): string {
|
||||
let message: string
|
||||
switch (status) {
|
||||
case 400:
|
||||
message = '请求错误(400)'
|
||||
break
|
||||
case 401:
|
||||
message = '未授权,请重新登录(401)'
|
||||
break
|
||||
case 403:
|
||||
message = '拒绝访问(403)'
|
||||
break
|
||||
case 404:
|
||||
message = '请求出错(404)'
|
||||
break
|
||||
case 408:
|
||||
message = '请求超时(408)'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器错误(500)'
|
||||
break
|
||||
case 501:
|
||||
message = '服务未实现(501)'
|
||||
break
|
||||
case 502:
|
||||
message = '网络错误(502)'
|
||||
break
|
||||
case 503:
|
||||
message = '服务不可用(503)'
|
||||
break
|
||||
case 504:
|
||||
message = '网络超时(504)'
|
||||
break
|
||||
case 505:
|
||||
message = 'HTTP版本不受支持(505)'
|
||||
break
|
||||
default:
|
||||
message = `连接出错(${status})!`
|
||||
}
|
||||
return `${message},请检查网络或联系管理员!`
|
||||
}
|
||||
29
src/http/tools/queryString.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 将对象序列化为URL查询字符串,用于替代第三方的 qs 库,节省宝贵的体积
|
||||
* 支持基本类型值和数组,不支持嵌套对象
|
||||
* @param obj 要序列化的对象
|
||||
* @returns 序列化后的查询字符串
|
||||
*/
|
||||
export function stringifyQuery(obj: Record<string, any>): string {
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj))
|
||||
return ''
|
||||
|
||||
return Object.entries(obj)
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => {
|
||||
// 对键进行编码
|
||||
const encodedKey = encodeURIComponent(key)
|
||||
|
||||
// 处理数组类型
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter(item => item !== undefined && item !== null)
|
||||
.map(item => `${encodedKey}=${encodeURIComponent(item)}`)
|
||||
.join('&')
|
||||
}
|
||||
|
||||
// 处理基本类型
|
||||
return `${encodedKey}=${encodeURIComponent(value)}`
|
||||
})
|
||||
.join('&')
|
||||
}
|
||||
44
src/http/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 在 uniapp 的 RequestOptions 和 IUniUploadFileOptions 基础上,添加自定义参数
|
||||
*/
|
||||
export type CustomRequestOptions = UniApp.RequestOptions & {
|
||||
query?: Record<string, any>
|
||||
/** 出错时是否隐藏错误提示 */
|
||||
hideErrorToast?: boolean
|
||||
} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
|
||||
|
||||
/** 主要提供给 openapi-ts-request 生成的代码使用 */
|
||||
export type CustomRequestOptions_ = Omit<CustomRequestOptions, 'url'>
|
||||
|
||||
export interface HttpRequestResult<T> {
|
||||
promise: Promise<T>
|
||||
requestTask: UniApp.RequestTask
|
||||
}
|
||||
|
||||
// 通用响应格式(兼容 msg + message 字段)
|
||||
export type IResponse<T = any> = {
|
||||
code: number
|
||||
data: T
|
||||
message: string
|
||||
[key: string]: any // 允许额外属性
|
||||
} | {
|
||||
code: number
|
||||
data: T
|
||||
msg: string
|
||||
[key: string]: any // 允许额外属性
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
export interface PageParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 分页响应数据
|
||||
export interface PageResult<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
30
src/http/vue-query.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { CustomRequestOptions } from '@/http/types'
|
||||
import { http } from './http'
|
||||
|
||||
/*
|
||||
* openapi-ts-request 工具的 request 跨客户端适配方法
|
||||
*/
|
||||
export default function request<T extends { data?: any }>(
|
||||
url: string,
|
||||
options: Omit<CustomRequestOptions, 'url'> & {
|
||||
params?: Record<string, unknown>
|
||||
headers?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
const requestOptions = {
|
||||
url,
|
||||
...options,
|
||||
}
|
||||
|
||||
if (options.params) {
|
||||
requestOptions.query = requestOptions.params
|
||||
delete requestOptions.params
|
||||
}
|
||||
|
||||
if (options.headers) {
|
||||
requestOptions.header = options.headers
|
||||
delete requestOptions.headers
|
||||
}
|
||||
|
||||
return http<T['data']>(requestOptions)
|
||||
}
|
||||
3
src/layouts/default.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
19
src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { requestInterceptor } from './http/interceptor'
|
||||
import { routeInterceptor } from './router/interceptor'
|
||||
|
||||
import store from './store'
|
||||
import '@/style/index.scss'
|
||||
import 'virtual:uno.css'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
app.use(store)
|
||||
app.use(routeInterceptor)
|
||||
app.use(requestInterceptor)
|
||||
|
||||
return {
|
||||
app,
|
||||
}
|
||||
}
|
||||
53
src/pages/index/index.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: 'Home',
|
||||
})
|
||||
definePage({
|
||||
// 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page
|
||||
type: 'home',
|
||||
style: {
|
||||
// 'custom' 表示开启自定义导航栏,默认 'default'
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '首页',
|
||||
},
|
||||
})
|
||||
|
||||
const description = ref(
|
||||
'unibest 是一个集成了多种工具和技术的 uniapp 开发模板,由 uniapp + Vue3 + Ts + Vite5 + UnoCss + VSCode 构建,模板具有代码提示、自动格式化、统一配置、代码片段等功能,并内置了许多常用的基本组件和基本功能,让你编写 uniapp 拥有 best 体验。',
|
||||
)
|
||||
console.log('index/index 首页打印了')
|
||||
|
||||
onLoad(() => {
|
||||
console.log('测试 uni API 自动引入: onLoad')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="bg-white px-4 pt-safe">
|
||||
<view class="mt-10">
|
||||
<image src="/static/logo.svg" alt="" class="mx-auto block h-28 w-28" />
|
||||
</view>
|
||||
<view class="mt-4 text-center text-4xl text-[#d14328]">
|
||||
unibest
|
||||
</view>
|
||||
<view class="mb-8 mt-2 text-center text-2xl">
|
||||
最好用的 uniapp 开发模板
|
||||
</view>
|
||||
|
||||
<view class="m-auto mb-2 max-w-100 text-justify indent text-4">
|
||||
{{ description }}
|
||||
</view>
|
||||
<view class="mt-4 text-center">
|
||||
作者:
|
||||
<text class="text-green-500">
|
||||
菲鸽
|
||||
</text>
|
||||
</view>
|
||||
<view class="mt-4 text-center">
|
||||
官网地址:
|
||||
<text class="text-green-500">
|
||||
https://unibest.tech
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
13
src/pages/me/me.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '我的',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="mt-10 text-center text-green-500">
|
||||
我的页面
|
||||
</view>
|
||||
</template>
|
||||
55
src/router/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 登录 说明
|
||||
|
||||
## 登录 2种策略
|
||||
- 默认无需登录策略: DEFAULT_NO_NEED_LOGIN
|
||||
- 默认需要登录策略: DEFAULT_NEED_LOGIN
|
||||
|
||||
### 默认无需登录策略: DEFAULT_NO_NEED_LOGIN
|
||||
进入任何页面都不需要登录,只有进入到黑名单中的页面/或者页面中某些动作需要登录,才需要登录。
|
||||
|
||||
比如大部分2C的应用,美团、今日头条、抖音等,都可以直接浏览,只有点赞、评论、分享等操作或者去特殊页面(比如个人中心),才需要登录。
|
||||
|
||||
### 默认需要登录策略: DEFAULT_NEED_LOGIN
|
||||
|
||||
进入任何页面都需要登录,只有进入到白名单中的页面,才不需要登录。默认进入应用需要先去登录页。
|
||||
|
||||
比如大部分2B和后台管理类的应用,比如企业微信、钉钉、飞书、内部报表系统、CMS系统等,都需要登录,只有登录后,才能使用。
|
||||
|
||||
### EXCLUDE_LOGIN_PATH_LIST
|
||||
`EXCLUDE_LOGIN_PATH_LIST` 表示排除的路由列表。
|
||||
|
||||
在 `默认无需登录策略: DEFAULT_NO_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才需要登录,相当于黑名单。
|
||||
|
||||
在 `默认需要登录策略: DEFAULT_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才不需要登录,相当于白名单。
|
||||
|
||||
### excludeLoginPath
|
||||
definePage 中可以通过 `excludeLoginPath` 来配置路由是否需要登录。(类似过去的 needLogin 的功能)
|
||||
|
||||
```ts
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '关于',
|
||||
},
|
||||
// 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹
|
||||
excludeLoginPath: true,
|
||||
// 角色授权(可选):如果需要根据角色授权,就配置这个
|
||||
roleAuth: {
|
||||
field: 'role',
|
||||
value: 'admin',
|
||||
redirect: '/pages/auth/403',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 登录注册页路由
|
||||
|
||||
登录页 `login.vue` 对应路由是 `/pages/login/login`.
|
||||
注册页 `register.vue` 对应路由是 `/pages/login/register`.
|
||||
|
||||
## 登录注册页适用性
|
||||
|
||||
登录注册页主要适用于 `h5` 和 `App`,默认不适用于 `小程序`,因为 `小程序` 通常会使用平台提供的快捷登录。
|
||||
|
||||
特殊情况例外,如业务需要跨平台复用登录注册页时,也可以用在 `小程序` 上,所以主要还是看业务需求。
|
||||
|
||||
通过一个参数 `LOGIN_PAGE_ENABLE_IN_MP` 来控制是否在 `小程序` 中使用 `H5登录页` 的登录逻辑。
|
||||
53
src/router/interceptor.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* by 菲鸽 on 2025-08-19
|
||||
* 路由拦截,通常也是登录拦截
|
||||
* 黑、白名单的配置,请看 config.ts 文件, EXCLUDE_LOGIN_PATH_LIST
|
||||
*/
|
||||
import { tabbarStore } from '@/tabbar/store'
|
||||
import { getAllPages, getLastPage, parseUrlToObj } from '@/utils/index'
|
||||
|
||||
export const FG_LOG_ENABLE = false
|
||||
|
||||
export const navigateToInterceptor = {
|
||||
// 注意,这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同
|
||||
// 增加对相对路径的处理,BY 网友 @ideal
|
||||
invoke({ url, query }: { url: string, query?: Record<string, string> }) {
|
||||
if (url === undefined) {
|
||||
return
|
||||
}
|
||||
let { path, query: _query } = parseUrlToObj(url)
|
||||
|
||||
FG_LOG_ENABLE && console.log('\n\n路由拦截器:-------------------------------------')
|
||||
FG_LOG_ENABLE && console.log('路由拦截器 1: url->', url, ', query ->', query)
|
||||
const myQuery = { ..._query, ...query }
|
||||
// /pages/route-interceptor/index?name=feige&age=30
|
||||
FG_LOG_ENABLE && console.log('路由拦截器 2: path->', path, ', _query ->', _query)
|
||||
FG_LOG_ENABLE && console.log('路由拦截器 3: myQuery ->', myQuery)
|
||||
|
||||
// 处理相对路径
|
||||
if (!path.startsWith('/')) {
|
||||
const currentPath = getLastPage()?.route || ''
|
||||
const normalizedCurrentPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}`
|
||||
const baseDir = normalizedCurrentPath.substring(0, normalizedCurrentPath.lastIndexOf('/'))
|
||||
path = `${baseDir}/${path}`
|
||||
}
|
||||
|
||||
// 处理路由不存在的情况
|
||||
if (path !== '/' && !getAllPages().some(page => page.path !== path)) {
|
||||
console.warn('路由不存在:', path)
|
||||
return false // 明确表示阻止原路由继续执行
|
||||
}
|
||||
|
||||
// 处理直接进入路由非首页时,tabbarIndex 不正确的问题
|
||||
tabbarStore.setAutoCurIdx(path)
|
||||
},
|
||||
}
|
||||
|
||||
export const routeInterceptor = {
|
||||
install() {
|
||||
uni.addInterceptor('navigateTo', navigateToInterceptor)
|
||||
uni.addInterceptor('reLaunch', navigateToInterceptor)
|
||||
uni.addInterceptor('redirectTo', navigateToInterceptor)
|
||||
uni.addInterceptor('switchTab', navigateToInterceptor)
|
||||
},
|
||||
}
|
||||
6
src/service/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/* eslint-disable */
|
||||
// @ts-ignore
|
||||
export * from './types';
|
||||
|
||||
export * from './listAll';
|
||||
export * from './info';
|
||||
14
src/service/info.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable */
|
||||
// @ts-ignore
|
||||
import request from '@/http/vue-query';
|
||||
import { CustomRequestOptions_ } from '@/http/types';
|
||||
|
||||
import * as API from './types';
|
||||
|
||||
/** 用户信息 GET /user/info */
|
||||
export function infoUsingGet({ options }: { options?: CustomRequestOptions_ }) {
|
||||
return request<API.InfoUsingGetResponse>('/user/info', {
|
||||
method: 'GET',
|
||||
...(options || {}),
|
||||
});
|
||||
}
|
||||
18
src/service/listAll.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable */
|
||||
// @ts-ignore
|
||||
import request from '@/http/vue-query';
|
||||
import { CustomRequestOptions_ } from '@/http/types';
|
||||
|
||||
import * as API from './types';
|
||||
|
||||
/** 用户列表 GET /user/listAll */
|
||||
export function listAllUsingGet({
|
||||
options,
|
||||
}: {
|
||||
options?: CustomRequestOptions_;
|
||||
}) {
|
||||
return request<API.ListAllUsingGetResponse>('/user/listAll', {
|
||||
method: 'GET',
|
||||
...(options || {}),
|
||||
});
|
||||
}
|
||||
29
src/service/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable */
|
||||
// @ts-ignore
|
||||
|
||||
export type InfoUsingGetResponse = {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: UserItem;
|
||||
};
|
||||
|
||||
export type InfoUsingGetResponses = {
|
||||
200: InfoUsingGetResponse;
|
||||
};
|
||||
|
||||
export type ListAllUsingGetResponse = {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: UserItem[];
|
||||
};
|
||||
|
||||
export type ListAllUsingGetResponses = {
|
||||
200: ListAllUsingGetResponse;
|
||||
};
|
||||
|
||||
export type UserItem = {
|
||||
userId: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
};
|
||||
BIN
src/static/app/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/static/app/icons/120x120.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src/static/app/icons/144x144.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/static/app/icons/152x152.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/static/app/icons/167x167.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src/static/app/icons/180x180.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src/static/app/icons/192x192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/static/app/icons/20x20.png
Normal file
|
After Width: | Height: | Size: 574 B |
BIN
src/static/app/icons/29x29.png
Normal file
|
After Width: | Height: | Size: 780 B |
BIN
src/static/app/icons/40x40.png
Normal file
|
After Width: | Height: | Size: 985 B |
BIN
src/static/app/icons/58x58.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/app/icons/60x60.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/static/app/icons/72x72.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/static/app/icons/76x76.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/static/app/icons/80x80.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/static/app/icons/87x87.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/static/app/icons/96x96.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/static/images/avatar.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/static/images/default-avatar.png
Normal file
|
After Width: | Height: | Size: 560 B |
33
src/static/logo.svg
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 113.39 113.39">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #d14328;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #2c8d3a;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<rect class="cls-1" width="113.39" height="113.39" />
|
||||
<g>
|
||||
<path class="cls-3"
|
||||
d="M86.31,11.34H25.08c-8.14,0-14.74,6.6-14.74,14.74v61.23c0,8.14,6.6,14.74,14.74,14.74h61.23c.12,0,.24-.02,.37-.02-9.76-.2-17.64-8.18-17.64-17.99,0-.56,.03-1.12,.08-1.67H34.1c-1.57,0-2.83-1.27-2.83-2.83V32.43c0-.78,.63-1.42,1.42-1.42h9.17c.78,0,1.42,.63,1.42,1.42v36.52c0,.78,.63,1.42,1.42,1.42h22.02c.78,0,1.42-.63,1.42-1.42V32.43c0-.78,.63-1.42,1.42-1.42h9.17c.78,0,1.42,.63,1.42,1.42v34.99c2.13-.89,4.47-1.39,6.92-1.39,5.66,0,10.7,2.63,14.01,6.72V26.08c0-8.14-6.6-14.74-14.74-14.74Z" />
|
||||
<g>
|
||||
<path class="cls-2"
|
||||
d="M87.04,68.03c-8.83,0-16.01,7.18-16.01,16.01s7.18,16.01,16.01,16.01,16.01-7.18,16.01-16.01-7.18-16.01-16.01-16.01Zm-.27,24.84h-7.2v-3h1.18v-10.48h4.58v2.81h1.42c.84,0,1.46-.16,1.88-.48s.62-.87,.62-1.64c0-.69-.25-1.17-.74-1.45s-1.19-.42-2.09-.42h-6.84v-3h7.2c2.38,0,4.15,.38,5.31,1.15,1.16,.77,1.74,1.93,1.74,3.48,0,1.71-.83,2.93-2.5,3.64,1.07,.4,1.87,.95,2.39,1.65s.79,1.56,.79,2.58c0,3.44-2.58,5.16-7.73,5.16Z" />
|
||||
<path class="cls-2"
|
||||
d="M86.49,85.17h-1.16v4.7h1.8c.81,0,1.46-.18,1.94-.55s.72-.95,.72-1.73c0-.86-.25-1.48-.74-1.85s-1.35-.56-2.56-.56Z" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
src/static/my-icons/copyright.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1762219859937" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8816" id="mx_n_1762219859938" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z" p-id="8817"></path><path d="M517.6 351.3c53 0 89 33.8 93 83.4 0.3 4.2 3.8 7.4 8 7.4h56.7c2.6 0 4.7-2.1 4.7-4.7 0-86.7-68.4-147.4-162.7-147.4C407.4 290 344 364.2 344 486.8v52.3C344 660.8 407.4 734 517.3 734c94 0 162.7-58.8 162.7-141.4 0-2.6-2.1-4.7-4.7-4.7h-56.8c-4.2 0-7.6 3.2-8 7.3-4.2 46.1-40.1 77.8-93 77.8-65.3 0-102.1-47.9-102.1-133.6v-52.6c0.1-87 37-135.5 102.2-135.5z" p-id="8818"></path></svg>
|
||||
|
After Width: | Height: | Size: 934 B |
BIN
src/static/tabbar/example.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/tabbar/exampleHL.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/tabbar/home.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/tabbar/homeHL.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/tabbar/personal.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/static/tabbar/personalHL.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src/static/tabbar/scan.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
20
src/store/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createPersistedState } from 'pinia-plugin-persistedstate' // 数据持久化
|
||||
|
||||
const store = createPinia()
|
||||
store.use(
|
||||
createPersistedState({
|
||||
storage: {
|
||||
getItem: uni.getStorageSync,
|
||||
setItem: uni.setStorageSync,
|
||||
},
|
||||
}),
|
||||
)
|
||||
// 立即激活 Pinia 实例, 这样即使在 app.use(store)之前调用 store 也能正常工作 (解决APP端白屏问题)
|
||||
setActivePinia(store)
|
||||
|
||||
export default store
|
||||
|
||||
// 模块统一导出
|
||||
export * from './token'
|
||||
export * from './user'
|
||||
290
src/store/token.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import type {
|
||||
ILoginForm,
|
||||
} from '@/api/login'
|
||||
import type { IAuthLoginRes } from '@/api/types/login'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue' // 修复:导入 computed
|
||||
import {
|
||||
login as _login,
|
||||
logout as _logout,
|
||||
refreshToken as _refreshToken,
|
||||
wxLogin as _wxLogin,
|
||||
getWxCode,
|
||||
} from '@/api/login'
|
||||
import { isDoubleTokenRes, isSingleTokenRes } from '@/api/types/login'
|
||||
import { isDoubleTokenMode } from '@/utils'
|
||||
import { useUserStore } from './user'
|
||||
|
||||
// 初始化状态
|
||||
const tokenInfoState = isDoubleTokenMode
|
||||
? {
|
||||
accessToken: '',
|
||||
accessExpiresIn: 0,
|
||||
refreshToken: '',
|
||||
refreshExpiresIn: 0,
|
||||
}
|
||||
: {
|
||||
token: '',
|
||||
expiresIn: 0,
|
||||
}
|
||||
|
||||
export const useTokenStore = defineStore(
|
||||
'token',
|
||||
() => {
|
||||
// 定义用户信息
|
||||
const tokenInfo = ref<IAuthLoginRes>({ ...tokenInfoState })
|
||||
// 设置用户信息
|
||||
const setTokenInfo = (val: IAuthLoginRes) => {
|
||||
tokenInfo.value = val
|
||||
|
||||
// 计算并存储过期时间
|
||||
const now = Date.now()
|
||||
if (isSingleTokenRes(val)) {
|
||||
// 单token模式
|
||||
const expireTime = now + val.expiresIn * 1000
|
||||
uni.setStorageSync('accessTokenExpireTime', expireTime)
|
||||
}
|
||||
else if (isDoubleTokenRes(val)) {
|
||||
// 双token模式
|
||||
const accessExpireTime = now + val.accessExpiresIn * 1000
|
||||
const refreshExpireTime = now + val.refreshExpiresIn * 1000
|
||||
uni.setStorageSync('accessTokenExpireTime', accessExpireTime)
|
||||
uni.setStorageSync('refreshTokenExpireTime', refreshExpireTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断token是否过期
|
||||
*/
|
||||
const isTokenExpired = computed(() => {
|
||||
if (!tokenInfo.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const expireTime = uni.getStorageSync('accessTokenExpireTime')
|
||||
|
||||
if (!expireTime)
|
||||
return true
|
||||
return now >= expireTime
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断refreshToken是否过期
|
||||
*/
|
||||
const isRefreshTokenExpired = computed(() => {
|
||||
if (!isDoubleTokenMode)
|
||||
return true
|
||||
|
||||
const now = Date.now()
|
||||
const refreshExpireTime = uni.getStorageSync('refreshTokenExpireTime')
|
||||
|
||||
if (!refreshExpireTime)
|
||||
return true
|
||||
return now >= refreshExpireTime
|
||||
})
|
||||
|
||||
/**
|
||||
* 登录成功后处理逻辑
|
||||
* @param tokenInfo 登录返回的token信息
|
||||
*/
|
||||
async function _postLogin(tokenInfo: IAuthLoginRes) {
|
||||
setTokenInfo(tokenInfo)
|
||||
const userStore = useUserStore()
|
||||
await userStore.fetchUserInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* 有的时候后端会用一个接口返回token和用户信息,有的时候会分开2个接口,一个获取token,一个获取用户信息
|
||||
* (各有利弊,看业务场景和系统复杂度),这里使用2个接口返回的来模拟
|
||||
* @param loginForm 登录参数
|
||||
* @returns 登录结果
|
||||
*/
|
||||
const login = async (loginForm: ILoginForm) => {
|
||||
try {
|
||||
const res = await _login(loginForm)
|
||||
console.log('普通登录-res: ', res)
|
||||
await _postLogin(res)
|
||||
uni.showToast({
|
||||
title: '登录成功',
|
||||
icon: 'success',
|
||||
})
|
||||
return res
|
||||
}
|
||||
catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
uni.showToast({
|
||||
title: '登录失败,请重试',
|
||||
icon: 'error',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信登录
|
||||
* 有的时候后端会用一个接口返回token和用户信息,有的时候会分开2个接口,一个获取token,一个获取用户信息
|
||||
* (各有利弊,看业务场景和系统复杂度),这里使用2个接口返回的来模拟
|
||||
* @returns 登录结果
|
||||
*/
|
||||
const wxLogin = async () => {
|
||||
try {
|
||||
// 获取微信小程序登录的code
|
||||
const code = await getWxCode()
|
||||
console.log('微信登录-code: ', code)
|
||||
const res = await _wxLogin(code)
|
||||
console.log('微信登录-res: ', res)
|
||||
await _postLogin(res)
|
||||
uni.showToast({
|
||||
title: '登录成功',
|
||||
icon: 'success',
|
||||
})
|
||||
return res
|
||||
}
|
||||
catch (error) {
|
||||
console.error('微信登录失败:', error)
|
||||
uni.showToast({
|
||||
title: '微信登录失败,请重试',
|
||||
icon: 'error',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录 并 删除用户信息
|
||||
*/
|
||||
const logout = async () => {
|
||||
try {
|
||||
// TODO 实现自己的退出登录逻辑
|
||||
await _logout()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
}
|
||||
finally {
|
||||
// 无论成功失败,都需要清除本地token信息
|
||||
// 清除存储的过期时间
|
||||
uni.removeStorageSync('accessTokenExpireTime')
|
||||
uni.removeStorageSync('refreshTokenExpireTime')
|
||||
console.log('退出登录-清除用户信息')
|
||||
tokenInfo.value = { ...tokenInfoState }
|
||||
uni.removeStorageSync('token')
|
||||
const userStore = useUserStore()
|
||||
userStore.clearUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新token
|
||||
* @returns 刷新结果
|
||||
*/
|
||||
const refreshToken = async () => {
|
||||
if (!isDoubleTokenMode) {
|
||||
console.error('单token模式不支持刷新token')
|
||||
throw new Error('单token模式不支持刷新token')
|
||||
}
|
||||
|
||||
try {
|
||||
// 安全检查,确保refreshToken存在
|
||||
if (!isDoubleTokenRes(tokenInfo.value) || !tokenInfo.value.refreshToken) {
|
||||
throw new Error('无效的refreshToken')
|
||||
}
|
||||
|
||||
const refreshToken = tokenInfo.value.refreshToken
|
||||
const res = await _refreshToken(refreshToken)
|
||||
console.log('刷新token-res: ', res)
|
||||
setTokenInfo(res)
|
||||
return res
|
||||
}
|
||||
catch (error) {
|
||||
console.error('刷新token失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有效的token
|
||||
* 注意:在computed中不直接调用异步函数,只做状态判断
|
||||
* 实际的刷新操作应由调用方处理
|
||||
*/
|
||||
const getValidToken = computed(() => {
|
||||
// token已过期,返回空
|
||||
if (isTokenExpired.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!isDoubleTokenMode) {
|
||||
return isSingleTokenRes(tokenInfo.value) ? tokenInfo.value.token : ''
|
||||
}
|
||||
else {
|
||||
return isDoubleTokenRes(tokenInfo.value) ? tokenInfo.value.accessToken : ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 检查是否有登录信息(不考虑token是否过期)
|
||||
*/
|
||||
const hasLoginInfo = computed(() => {
|
||||
if (!tokenInfo.value) {
|
||||
return false
|
||||
}
|
||||
if (isDoubleTokenMode) {
|
||||
return isDoubleTokenRes(tokenInfo.value) && !!tokenInfo.value.accessToken
|
||||
}
|
||||
else {
|
||||
return isSingleTokenRes(tokenInfo.value) && !!tokenInfo.value.token
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 检查是否已登录且token有效
|
||||
*/
|
||||
const hasValidLogin = computed(() => {
|
||||
console.log('hasValidLogin', hasLoginInfo.value, !isTokenExpired.value)
|
||||
return hasLoginInfo.value && !isTokenExpired.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 尝试获取有效的token,如果过期且可刷新,则刷新token
|
||||
* @returns 有效的token或空字符串
|
||||
*/
|
||||
const tryGetValidToken = async (): Promise<string> => {
|
||||
if (!getValidToken.value && isDoubleTokenMode && !isRefreshTokenExpired.value) {
|
||||
try {
|
||||
await refreshToken()
|
||||
return getValidToken.value
|
||||
}
|
||||
catch (error) {
|
||||
console.error('尝试刷新token失败:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return getValidToken.value
|
||||
}
|
||||
|
||||
return {
|
||||
// 核心API方法
|
||||
login,
|
||||
wxLogin,
|
||||
logout,
|
||||
|
||||
// 认证状态判断(最常用的)
|
||||
hasLogin: hasValidLogin,
|
||||
|
||||
// 内部系统使用的方法
|
||||
refreshToken,
|
||||
tryGetValidToken,
|
||||
validToken: getValidToken,
|
||||
|
||||
// 调试或特殊场景可能需要直接访问的信息
|
||||
tokenInfo,
|
||||
setTokenInfo,
|
||||
}
|
||||
},
|
||||
{
|
||||
// 添加持久化配置,确保刷新页面后token信息不丢失
|
||||
persist: true,
|
||||
},
|
||||
)
|
||||
61
src/store/user.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { IUserInfoRes } from '@/api/types/login'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
getUserInfo,
|
||||
} from '@/api/login'
|
||||
|
||||
// 初始化状态
|
||||
const userInfoState: IUserInfoRes = {
|
||||
userId: -1,
|
||||
username: '',
|
||||
nickname: '',
|
||||
avatar: '/static/images/default-avatar.png',
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore(
|
||||
'user',
|
||||
() => {
|
||||
// 定义用户信息
|
||||
const userInfo = ref<IUserInfoRes>({ ...userInfoState })
|
||||
// 设置用户信息
|
||||
const setUserInfo = (val: IUserInfoRes) => {
|
||||
console.log('设置用户信息', val)
|
||||
// 若头像为空 则使用默认头像
|
||||
if (!val.avatar) {
|
||||
val.avatar = userInfoState.avatar
|
||||
}
|
||||
userInfo.value = val
|
||||
}
|
||||
const setUserAvatar = (avatar: string) => {
|
||||
userInfo.value.avatar = avatar
|
||||
console.log('设置用户头像', avatar)
|
||||
console.log('userInfo', userInfo.value)
|
||||
}
|
||||
// 删除用户信息
|
||||
const clearUserInfo = () => {
|
||||
userInfo.value = { ...userInfoState }
|
||||
uni.removeStorageSync('user')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
const fetchUserInfo = async () => {
|
||||
const res = await getUserInfo()
|
||||
setUserInfo(res)
|
||||
return res
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
clearUserInfo,
|
||||
fetchUserInfo,
|
||||
setUserInfo,
|
||||
setUserAvatar,
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
},
|
||||
)
|
||||
28
src/style/iconfont.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@font-face {
|
||||
font-family: 'iconfont'; /* Project id 4543091 */
|
||||
src:
|
||||
url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAOwAAsAAAAAB9AAAANjAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACDHAqDBIJqATYCJAMQCwoABCAFhGcHPRvnBsgusG3kMyE15/44PsBX09waBHv0REDt97oHAQDFrOIyPirRiULQ+TJcXV0hCYTuVFcBC915/2vX/32Q80hkZ5PZGZ9snvwruVLloidKqYN6iKC53bOtbKwVLSIi3W6zCWZbs3VbER3j9JpGX3ySYcc94IQRTK5s4epS/jSqIgvg37qlY2/jwQN7D9ADpfRCmIknQByTscVZPTBr+hnnCKg2o4bjakvXEPjuY65DJGeJNtBUhn1JxOBuB2UZmUpBOXdsFp4oxOv4GHgs3h/+wRDcicqSZJG1q9kK1z/Af9NpqxjpC2QaAdpHlCFh4spcYXs5sMWpSk5wUj31G2dLQKVKkZ/w7f/8/i/A3JVUSZK9f7xIKJeU14IFpBI/Qfkkz46GT/CuaGREfCtKJUougWeQWHvVC5Lcz2BGS+SePR99vj3yjJx7h574tp7uWcOh4yfaTjS/245TT/vkQrN+a7RLkK8+Vd+bz+FSGh+9srDQKPeJ2s29z7ah4+efdoxefRbbGwfy7ht+SuIWukzsu1b6ePP+6kN1aamb47qsPim1Ia3xdEpDcl1dckPKGYnneI23+57r2W1Mmkqs6ajrChRCs5qyQ66rTVWhgZaG7toOeHm5cxn0sSQuNDEgcUTdNTSupKI1JRZih/JssAUKezPeOJJzbNozF6zWJuuVavVU5Tgtkop/SDzHa7ytvnCTq0PhkEfi4xLLtb0PuwyOAYqmrYQApFJyoJjTnfz+ve94vvv2f/yWgxl8Jd8Di2DRDPuob59mU/+VfDCROQyR8xSnmP9fXm7liagmN39OlmbvjqG0sMsJKrU0EFXogaRSH5bNY1CmxhyUq7QC1cY1T67RwuQk5CoM2RUQNLoEUb03kDS6h2XzcyjT7iOUa/QXqq1Hn6/GUBAaGcGcWJFlGUmCoVOp8kLvABHnVczGYiOE2SVEUH5OXj/TSnTCDjHAviAWcE4RZYaGWszNiKoayGSGTASeY+PcrMjNpVMvyREMDRoxBMYRVojFMkQiMOhohubdzxtAiOapMMbERpKMnQT9SL4ceQysVdJZVa9kEbsFogIcRyEUE2kN0mL7CDVIGhBzupWMEHA5bDvipgq5hKJcKef8ivbx1kC15KgcYkghhzLxYNntxoKCReJ82jAHAAA=')
|
||||
format('woff2'),
|
||||
url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.woff?t=1715485842402') format('woff'),
|
||||
url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.ttf?t=1715485842402') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-family: 'iconfont' !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-my:before {
|
||||
content: '\e78c';
|
||||
}
|
||||
|
||||
.icon-package:before {
|
||||
content: '\e9c2';
|
||||
}
|
||||
|
||||
.icon-chat:before {
|
||||
content: '\e600';
|
||||
}
|
||||
36
src/style/index.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
// 测试用的 iconfont,可生效
|
||||
// @import './iconfont.css';
|
||||
|
||||
.test {
|
||||
// 可以通过 @apply 多个样式封装整体样式
|
||||
@apply mt-4 ml-4;
|
||||
|
||||
padding-top: 4px;
|
||||
color: red;
|
||||
}
|
||||
|
||||
:root,
|
||||
page {
|
||||
// 修改按主题色
|
||||
// --wot-color-theme: #37c2bc;
|
||||
|
||||
// 修改按钮背景色
|
||||
// --wot-button-primary-bg-color: green;
|
||||
}
|
||||
|
||||
/*
|
||||
border-t-1
|
||||
由于uniapp中无法使用*选择器,使用魔法代替*,加上此规则可以简化border与divide的使用,并提升布局的兼容性
|
||||
1. 防止padding和border影响元素宽度。 (https://github.com/mozdevs/cssremedy/issues/4)
|
||||
2. 允许仅通过添加边框宽度来向元素添加边框。 (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||
3. [UnoCSS]: 允许使用css变量'--un-default-border-color'覆盖默认边框颜色
|
||||
*/
|
||||
// 这个样式有重大BUG,先去掉!!(2025-08-15)
|
||||
// :not(not),
|
||||
// ::before,
|
||||
// ::after {
|
||||
// box-sizing: border-box; /* 1 */
|
||||
// border-width: 0; /* 2 */
|
||||
// border-style: solid; /* 2 */
|
||||
// border-color: var(--un-default-border-color, #e5e7eb); /* 3 */
|
||||
// }
|
||||
84
src/tabbar/README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# tabbar 说明
|
||||
|
||||
## tabbar 4种策略
|
||||
|
||||
`tabbar` 分为 `4 种` 情况:
|
||||
|
||||
- 0 `无 tabbar`,只有一个页面入口,底部无 `tabbar` 显示;常用语临时活动页。
|
||||
|
||||
- 1 `原生 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。
|
||||
- 优势:原生自带的 `tabbar`,最先渲染,有缓存。
|
||||
- 劣势:只能使用 2 组图片来切换选中和非选中状态,修改颜色只能重新换图片(或者用 iconfont)。
|
||||
|
||||
- 2 `有缓存自定义 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。使用了第三方 UI 库的 `tabbar` 组件,并隐藏了原生 `tabbar` 的显示。
|
||||
- 优势:可以随意配置自己想要的 `svg icon`,切换字体颜色方便。有缓存。可以实现各种花里胡哨的动效等。
|
||||
- 劣势:首次点击 `tabbar` 会闪烁。
|
||||
|
||||
- 3 `无缓存自定义 tabbar`,使用 `navigateTo` 切换 `tabbar`,`tabbar` 页面无缓存。使用了第三方 UI 库的 `tabbar` 组件。
|
||||
- 优势:可以随意配置自己想要的 svg icon,切换字体颜色方便。可以实现各种花里胡哨的动效等。
|
||||
- 劣势:首次点击 `tababr` 会闪烁,无缓存。
|
||||
|
||||
> 注意:花里胡哨的效果需要自己实现,本模版不提供。
|
||||
|
||||
## tabbar 配置说明
|
||||
|
||||
- 如果使用的是 `原生tabbar`,需要配置 `nativeTabbarList`,每个 `item` 需要配置 `path`、`text`、`iconPath`、`selectedIconPath` 等属性。
|
||||
- 如果使用的是 `自定义tabbar`,需要配置 `customTabbarList`,每个 `item` 需要配置 `path`、`text`、`icon` 、`iconType` 等属性(如果是 `image` 图片还需要配置2种图片)。
|
||||
|
||||
## 文件说明
|
||||
|
||||
`config.ts` 专门配置 `nativeTabbarList` 和 `customTabbarList` 的相关信息,请按照文件里面的注释配置相关项。
|
||||
|
||||
使用 `原生tabbar` 时,不需要关心下面2个文件:
|
||||
|
||||
- `store.ts` ,专门给 `自定义 tabbar` 提供状态管理,代码几乎不需要修改。
|
||||
- `index.vue` ,专门给 `自定义 tabbar` 提供渲染逻辑,代码可以稍微修改,以符合自己的需求。
|
||||
|
||||
## 自定义tabbar的不同类型的配置
|
||||
|
||||
- uniUi 图标
|
||||
|
||||
```js
|
||||
{
|
||||
// ... 其他配置
|
||||
"iconType": "uniUi",
|
||||
"icon": "home",
|
||||
}
|
||||
```
|
||||
|
||||
- unocss 图标
|
||||
|
||||
```js
|
||||
{
|
||||
// ... 其他配置
|
||||
// 注意 unocss 图标需要如下处理:(二选一)
|
||||
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
|
||||
// 2)配置到 unocss.config.ts 的 safelist 中
|
||||
iconType: 'unocss',
|
||||
icon: 'i-carbon-code',
|
||||
}
|
||||
```
|
||||
|
||||
- iconfont 图标
|
||||
|
||||
```js
|
||||
{
|
||||
// ... 其他配置
|
||||
// 注意 iconfont 图标需要额外加上 'iconfont',如下
|
||||
iconType: 'iconfont',
|
||||
icon: 'iconfont icon-my',
|
||||
}
|
||||
```
|
||||
|
||||
- image 本地图片
|
||||
|
||||
```js
|
||||
{
|
||||
// ... 其他配置
|
||||
// 使用 ‘image’时,需要配置 icon + iconActive 2张图片(不推荐)
|
||||
// 既然已经用了自定义tabbar了,就不建议用图片了,所以不推荐
|
||||
iconType: 'image',
|
||||
icon: '/static/tabbar/home.png',
|
||||
iconActive: '/static/tabbar/homeHL.png',
|
||||
}
|
||||
```
|
||||
128
src/tabbar/config.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
|
||||
import type { CustomTabBarItem, NativeTabBarItem } from './types'
|
||||
|
||||
/**
|
||||
* tabbar 选择的策略,更详细的介绍见 tabbar.md 文件
|
||||
* 0: 'NO_TABBAR' `无 tabbar`
|
||||
* 1: 'NATIVE_TABBAR' `完全原生 tabbar`
|
||||
* 2: 'CUSTOM_TABBAR_WITH_CACHE' `有缓存自定义 tabbar`
|
||||
* 3: 'CUSTOM_TABBAR_WITHOUT_CACHE' `无缓存自定义 tabbar`
|
||||
*
|
||||
* 温馨提示:本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致配置不生效
|
||||
*/
|
||||
export const TABBAR_STRATEGY_MAP = {
|
||||
NO_TABBAR: 0,
|
||||
NATIVE_TABBAR: 1,
|
||||
CUSTOM_TABBAR_WITH_CACHE: 2,
|
||||
CUSTOM_TABBAR_WITHOUT_CACHE: 3,
|
||||
}
|
||||
|
||||
// TODO: 1/3. 通过这里切换使用tabbar的策略
|
||||
// 如果是使用 NO_TABBAR(0),nativeTabbarList 和 customTabbarList 都不生效(里面的配置不用管)
|
||||
// 如果是使用 NATIVE_TABBAR(1),只需要配置 nativeTabbarList,customTabbarList 不生效
|
||||
// 如果是使用 CUSTOM_TABBAR(2,3),只需要配置 customTabbarList,nativeTabbarList 不生效
|
||||
export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE
|
||||
|
||||
// TODO: 2/3. 使用 NATIVE_TABBAR 时,更新下面的 tabbar 配置
|
||||
export const nativeTabbarList: NativeTabBarItem[] = [
|
||||
{
|
||||
iconPath: 'static/tabbar/home.png',
|
||||
selectedIconPath: 'static/tabbar/homeHL.png',
|
||||
pagePath: 'pages/index/index',
|
||||
text: '首页',
|
||||
},
|
||||
{
|
||||
iconPath: 'static/tabbar/personal.png',
|
||||
selectedIconPath: 'static/tabbar/personalHL.png',
|
||||
pagePath: 'pages/me/me',
|
||||
text: '个人',
|
||||
},
|
||||
]
|
||||
|
||||
// TODO: 3/3. 使用 CUSTOM_TABBAR(2,3) 时,更新下面的 tabbar 配置
|
||||
// 如果需要配置鼓包,需要在 'tabbar/store.ts' 里面设置,最后在 `tabbar/index.vue` 里面更改鼓包的图片
|
||||
export const customTabbarList: CustomTabBarItem[] = [
|
||||
{
|
||||
text: '首页',
|
||||
pagePath: 'pages/index/index',
|
||||
// 注意 unocss 图标需要如下处理:(二选一)
|
||||
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
|
||||
// 2)配置到 unocss.config.ts 的 safelist 中
|
||||
iconType: 'unocss',
|
||||
icon: 'i-carbon-home',
|
||||
// badge: 'dot',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/me/me',
|
||||
text: '我的',
|
||||
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
|
||||
// 2)配置到 unocss.config.ts 的 safelist 中
|
||||
iconType: 'unocss',
|
||||
icon: 'i-carbon-user',
|
||||
// badge: 10,
|
||||
},
|
||||
// 其他类型演示
|
||||
// 1、uiLib
|
||||
// {
|
||||
// pagePath: 'pages/index/index',
|
||||
// text: '首页',
|
||||
// iconType: 'uiLib',
|
||||
// icon: 'home',
|
||||
// },
|
||||
// 2、iconfont
|
||||
// {
|
||||
// pagePath: 'pages/index/index',
|
||||
// text: '首页',
|
||||
// // 注意 iconfont 图标需要额外加上 'iconfont',如下
|
||||
// iconType: 'iconfont',
|
||||
// icon: 'iconfont icon-my',
|
||||
// },
|
||||
// 3、image
|
||||
// {
|
||||
// pagePath: 'pages/index/index',
|
||||
// text: '首页',
|
||||
// // 使用 ‘image’时,需要配置 icon + iconActive 2张图片
|
||||
// iconType: 'image',
|
||||
// icon: '/static/tabbar/home.png',
|
||||
// iconActive: '/static/tabbar/homeHL.png',
|
||||
// },
|
||||
]
|
||||
|
||||
/**
|
||||
* 是否启用 tabbar 缓存
|
||||
* NATIVE_TABBAR(1) 和 CUSTOM_TABBAR_WITH_CACHE(2) 时,需要tabbar缓存
|
||||
*/
|
||||
export const tabbarCacheEnable
|
||||
= [TABBAR_STRATEGY_MAP.NATIVE_TABBAR, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE].includes(selectedTabbarStrategy)
|
||||
|
||||
/**
|
||||
* 是否启用自定义 tabbar
|
||||
* CUSTOM_TABBAR(2,3) 时,启用自定义tabbar
|
||||
*/
|
||||
export const customTabbarEnable
|
||||
= [TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITHOUT_CACHE].includes(selectedTabbarStrategy)
|
||||
|
||||
/**
|
||||
* 是否需要隐藏原生 tabbar
|
||||
* CUSTOM_TABBAR_WITH_CACHE(2) 时,需要隐藏原生tabbar
|
||||
*/
|
||||
export const needHideNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE
|
||||
|
||||
const _tabbarList = customTabbarEnable ? customTabbarList.map(item => ({ text: item.text, pagePath: item.pagePath })) : nativeTabbarList
|
||||
export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList
|
||||
|
||||
const _tabbar: TabBar = {
|
||||
// 只有微信小程序支持 custom。App 和 H5 不生效
|
||||
custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE,
|
||||
color: '#999999',
|
||||
selectedColor: '#018d71',
|
||||
backgroundColor: '#F8F8F8',
|
||||
borderStyle: 'black',
|
||||
height: '50px',
|
||||
fontSize: '10px',
|
||||
iconWidth: '24px',
|
||||
spacing: '3px',
|
||||
list: _tabbarList as unknown as TabBar['list'],
|
||||
}
|
||||
|
||||
export const tabBar = tabbarCacheEnable ? _tabbar : undefined
|
||||
172
src/tabbar/index.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
// i-carbon-code
|
||||
import type { CustomTabBarItem } from './types'
|
||||
import { customTabbarEnable, needHideNativeTabbar, tabbarCacheEnable } from './config'
|
||||
import { tabbarList, tabbarStore } from './store'
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 将自定义节点设置成虚拟的(去掉自定义组件包裹层),更加接近Vue组件的表现,能更好的使用flex属性
|
||||
defineOptions({
|
||||
virtualHost: true,
|
||||
})
|
||||
// #endif
|
||||
|
||||
/**
|
||||
* 中间的鼓包tabbarItem的点击事件
|
||||
*/
|
||||
function handleClickBulge() {
|
||||
uni.showToast({
|
||||
title: '点击了中间的鼓包tabbarItem',
|
||||
icon: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
function handleClick(index: number) {
|
||||
// 点击原来的不做操作
|
||||
if (index === tabbarStore.curIdx) {
|
||||
return
|
||||
}
|
||||
if (tabbarList[index].isBulge) {
|
||||
handleClickBulge()
|
||||
return
|
||||
}
|
||||
const url = tabbarList[index].pagePath
|
||||
tabbarStore.setCurIdx(index)
|
||||
if (tabbarCacheEnable) {
|
||||
uni.switchTab({ url })
|
||||
}
|
||||
else {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
}
|
||||
// #ifndef MP-WEIXIN || MP-ALIPAY
|
||||
// 因为有了 custom:true, 微信里面不需要多余的hide操作
|
||||
onLoad(() => {
|
||||
// 解决原生 tabBar 未隐藏导致有2个 tabBar 的问题
|
||||
needHideNativeTabbar
|
||||
&& uni.hideTabBar({
|
||||
fail(err) {
|
||||
console.log('hideTabBar fail: ', err)
|
||||
},
|
||||
success(res) {
|
||||
// console.log('hideTabBar success: ', res)
|
||||
},
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-ALIPAY
|
||||
onMounted(() => {
|
||||
// 解决支付宝自定义tabbar 未隐藏导致有2个 tabBar 的问题; 注意支付宝很特别,需要在 onMounted 钩子调用
|
||||
customTabbarEnable // 另外,支付宝里面,只要是 customTabbar 都需要隐藏
|
||||
&& uni.hideTabBar({
|
||||
fail(err) {
|
||||
console.log('hideTabBar fail: ', err)
|
||||
},
|
||||
success(res) {
|
||||
// console.log('hideTabBar success: ', res)
|
||||
},
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
const activeColor = 'var(--wot-color-theme, #1890ff)'
|
||||
const inactiveColor = '#666'
|
||||
function getColorByIndex(index: number) {
|
||||
return tabbarStore.curIdx === index ? activeColor : inactiveColor
|
||||
}
|
||||
|
||||
function getImageByIndex(index: number, item: CustomTabBarItem) {
|
||||
if (!item.iconActive) {
|
||||
console.warn('image 模式下,需要配置 iconActive (高亮时的图片),否则无法切换高亮图片')
|
||||
return item.icon
|
||||
}
|
||||
return tabbarStore.curIdx === index ? item.iconActive : item.icon
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view v-if="customTabbarEnable" class="h-50px pb-safe">
|
||||
<view class="border-and-fixed bg-white" @touchmove.stop.prevent>
|
||||
<view class="h-50px flex items-center">
|
||||
<view
|
||||
v-for="(item, index) in tabbarList" :key="index"
|
||||
class="flex flex-1 flex-col items-center justify-center"
|
||||
:style="{ color: getColorByIndex(index) }"
|
||||
@click="handleClick(index)"
|
||||
>
|
||||
<view v-if="item.isBulge" class="relative">
|
||||
<!-- 中间一个鼓包tabbarItem的处理 -->
|
||||
<view class="bulge">
|
||||
<!-- TODO 2/2: 中间鼓包tabbarItem配置:通常是一个图片,或者icon,点击触发业务逻辑 -->
|
||||
<!-- 常见的是:扫描按钮、发布按钮、更多按钮等 -->
|
||||
<image class="mt-6rpx h-200rpx w-200rpx" src="/static/tabbar/scan.png" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="relative px-3 text-center">
|
||||
<template v-if="item.iconType === 'uiLib'">
|
||||
<!-- TODO: 以下内容请根据选择的UI库自行替换 -->
|
||||
<!-- 如:<wd-icon name="home" /> (https://wot-design-uni.cn/component/icon.html) -->
|
||||
<!-- 如:<uv-icon name="home" /> (https://www.uvui.cn/components/icon.html) -->
|
||||
<!-- 如:<sar-icon name="image" /> (https://sard.wzt.zone/sard-uniapp-docs/components/icon)(sar没有home图标^_^) -->
|
||||
<!-- <wd-icon :name="item.icon" size="20" /> -->
|
||||
</template>
|
||||
<template v-if="item.iconType === 'unocss' || item.iconType === 'iconfont'">
|
||||
<view :class="item.icon" class="text-20px" />
|
||||
</template>
|
||||
<template v-if="item.iconType === 'image'">
|
||||
<image :src="getImageByIndex(index, item)" mode="scaleToFill" class="h-20px w-20px" />
|
||||
</template>
|
||||
<view class="mt-2px text-12px">
|
||||
{{ item.text }}
|
||||
</view>
|
||||
<!-- 角标显示 -->
|
||||
<view v-if="item.badge">
|
||||
<template v-if="item.badge === 'dot'">
|
||||
<view class="absolute right-0 top-0 h-2 w-2 rounded-full bg-#f56c6c" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="absolute top-0 box-border h-5 min-w-5 center rounded-full bg-#f56c6c px-1 text-center text-xs text-white -right-3">
|
||||
{{ item.badge > 99 ? '99+' : item.badge }}
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pb-safe" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.border-and-fixed {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
border-top: 1px solid #eee;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
// 中间鼓包的样式
|
||||
.bulge {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
transform-origin: top center;
|
||||
transform: translateX(-50%) scale(0.5) translateY(-33%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 250rpx;
|
||||
height: 250rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: inset 0 0 0 1px #fefefe;
|
||||
|
||||
&:active {
|
||||
// opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
78
src/tabbar/store.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { CustomTabBarItem, CustomTabBarItemBadge } from './types'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { tabbarList as _tabbarList, customTabbarEnable, selectedTabbarStrategy, TABBAR_STRATEGY_MAP } from './config'
|
||||
|
||||
// TODO 1/2: 中间的鼓包tabbarItem的开关
|
||||
const BULGE_ENABLE = false
|
||||
|
||||
/** tabbarList 里面的 path 从 pages.config.ts 得到 */
|
||||
const tabbarList = reactive<CustomTabBarItem[]>(_tabbarList.map(item => ({
|
||||
...item,
|
||||
pagePath: item.pagePath.startsWith('/') ? item.pagePath : `/${item.pagePath}`,
|
||||
})))
|
||||
|
||||
if (customTabbarEnable && BULGE_ENABLE) {
|
||||
if (tabbarList.length % 2) {
|
||||
console.error('有鼓包时 tabbar 数量必须是偶数,否则样式很奇怪!!')
|
||||
}
|
||||
tabbarList.splice(tabbarList.length / 2, 0, {
|
||||
isBulge: true,
|
||||
} as CustomTabBarItem)
|
||||
}
|
||||
|
||||
export function isPageTabbar(path: string) {
|
||||
if (selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NO_TABBAR) {
|
||||
return false
|
||||
}
|
||||
const _path = path.split('?')[0]
|
||||
return tabbarList.some(item => item.pagePath === _path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 tabbar 的状态管理,原生 tabbar 无需关注本文件
|
||||
* tabbar 状态,增加 storageSync 保证刷新浏览器时在正确的 tabbar 页面
|
||||
* 使用reactive简单状态,而不是 pinia 全局状态
|
||||
*/
|
||||
const tabbarStore = reactive({
|
||||
curIdx: uni.getStorageSync('app-tabbar-index') || 0,
|
||||
prevIdx: uni.getStorageSync('app-tabbar-index') || 0,
|
||||
setCurIdx(idx: number) {
|
||||
this.curIdx = idx
|
||||
uni.setStorageSync('app-tabbar-index', idx)
|
||||
},
|
||||
setTabbarItemBadge(idx: number, badge: CustomTabBarItemBadge) {
|
||||
if (tabbarList[idx]) {
|
||||
tabbarList[idx].badge = badge
|
||||
}
|
||||
},
|
||||
setAutoCurIdx(path: string) {
|
||||
// '/' 当做首页
|
||||
if (path === '/') {
|
||||
this.setCurIdx(0)
|
||||
return
|
||||
}
|
||||
const index = tabbarList.findIndex(item => item.pagePath === path)
|
||||
// console.log('tabbarList:', tabbarList)
|
||||
if (index === -1) {
|
||||
const pagesPathList = getCurrentPages().map(item => item.route.startsWith('/') ? item.route : `/${item.route}`)
|
||||
// console.log(pagesPathList)
|
||||
const flag = tabbarList.some(item => pagesPathList.includes(item.pagePath))
|
||||
if (!flag) {
|
||||
this.setCurIdx(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.setCurIdx(index)
|
||||
}
|
||||
},
|
||||
restorePrevIdx() {
|
||||
if (this.prevIdx === this.curIdx)
|
||||
return
|
||||
this.setCurIdx(this.prevIdx)
|
||||
this.prevIdx = uni.getStorageSync('app-tabbar-index') || 0
|
||||
},
|
||||
})
|
||||
|
||||
export { tabbarList, tabbarStore }
|
||||
34
src/tabbar/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
|
||||
import type { RemoveLeadingSlashFromUnion } from '@/typings'
|
||||
|
||||
/**
|
||||
* 原生 tabbar 的单个选项配置
|
||||
*/
|
||||
export type NativeTabBarItem = TabBar['list'][number] & {
|
||||
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
|
||||
}
|
||||
|
||||
/** badge 显示一个数字或 小红点(样式可以直接在 tabbar/index.vue 里面修改) */
|
||||
export type CustomTabBarItemBadge = number | 'dot'
|
||||
|
||||
/** 自定义 tabbar 的单个选项配置 */
|
||||
export interface CustomTabBarItem {
|
||||
text: string
|
||||
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
|
||||
/** 图标类型,不建议用 image 模式,因为需要配置 2 张图,更麻烦 */
|
||||
iconType: 'uiLib' | 'unocss' | 'iconfont' | 'image'
|
||||
/**
|
||||
* icon 的路径
|
||||
* - uiLib: wot-design-uni 图标的 icon prop
|
||||
* - unocss: unocss 图标的类名
|
||||
* - iconfont: iconfont 图标的类名
|
||||
* - image: 图片的路径
|
||||
*/
|
||||
icon: string
|
||||
/** 只有在 image 模式下才需要,传递的是高亮的图片(PS: 不建议用 image 模式) */
|
||||
iconActive?: string
|
||||
/** badge 显示一个数字或 小红点 */
|
||||
badge?: CustomTabBarItemBadge
|
||||
/** 是否是中间的鼓包tabbarItem */
|
||||
isBulge?: boolean
|
||||
}
|
||||
171
src/typings.d.ts
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
// 全局要用的类型放到这里
|
||||
|
||||
declare global {
|
||||
interface IResData<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// uni.uploadFile文件上传参数
|
||||
interface IUniUploadFileOptions {
|
||||
file?: File
|
||||
files?: UniApp.UploadFileOptionFiles[]
|
||||
filePath?: string
|
||||
name?: string
|
||||
formData?: any
|
||||
}
|
||||
|
||||
interface IUserInfo {
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
/** 微信的 openid,非微信没有这个字段 */
|
||||
openid?: string
|
||||
}
|
||||
|
||||
interface IUserToken {
|
||||
token: string
|
||||
refreshToken?: string
|
||||
refreshExpire?: number
|
||||
}
|
||||
}
|
||||
|
||||
// 扩展 @uni-helper/vite-plugin-uni-pages 的 definePage 参数类型
|
||||
declare module '@uni-helper/vite-plugin-uni-pages' {
|
||||
interface UserPageMeta {
|
||||
/**
|
||||
* 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page
|
||||
*
|
||||
* 尽量保证一个项目 只有一个 这个配置,如果有多个,会按照字母顺序来排列,最终可能不是您想要的效果。
|
||||
*/
|
||||
type?: 'home'
|
||||
/**
|
||||
* 页面布局类型, 模板默认只有 default, 如果在 src/layouts 下新增了 layout, 可以扩展当前属性
|
||||
* @default 'default'
|
||||
*
|
||||
* 当前属性供 https://github.com/uni-helper/vite-plugin-uni-layouts 插件使用
|
||||
*/
|
||||
layout?: 'default'
|
||||
/**
|
||||
* 是否从需要登录的路径中排除
|
||||
*
|
||||
* 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹
|
||||
*/
|
||||
excludeLoginPath?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// patch uni 类型
|
||||
// 1. 补全 uni.hideToast() 的 options 类型
|
||||
// 2. 补全 uni.hideLoading() 的 options 类型
|
||||
// 3. 使用方式见:https://github.com/unibest-tech/unibest/pull/241
|
||||
declare global {
|
||||
declare namespace UniNamespace {
|
||||
/** 接口调用结束的回调函数(调用成功、失败都会执行) */
|
||||
type HideLoadingCompleteCallback = (res: GeneralCallbackResult) => void
|
||||
/** 接口调用失败的回调函数 */
|
||||
type HideLoadingFailCallback = (res: GeneralCallbackResult) => void
|
||||
/** 接口调用成功的回调函数 */
|
||||
type HideLoadingSuccessCallback = (res: GeneralCallbackResult) => void
|
||||
|
||||
interface HideLoadingOption {
|
||||
/** 接口调用结束的回调函数(调用成功、失败都会执行) */
|
||||
complete?: HideLoadingCompleteCallback
|
||||
/** 接口调用失败的回调函数 */
|
||||
fail?: HideLoadingFailCallback
|
||||
test: UniNamespace.GeneralCallbackResult
|
||||
/**
|
||||
* 微信小程序:需要基础库: `2.22.1`
|
||||
*
|
||||
* 微信小程序:目前 toast 和 loading 相关接口可以相互混用,此参数可用于取消混用特性
|
||||
*/
|
||||
noConflict?: boolean
|
||||
/** 接口调用成功的回调函数 */
|
||||
success?: HideLoadingSuccessCallback
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 接口调用结束的回调函数(调用成功、失败都会执行) */
|
||||
type HideToastCompleteCallback = (res: GeneralCallbackResult) => void
|
||||
/** 接口调用失败的回调函数 */
|
||||
type HideToastFailCallback = (res: GeneralCallbackResult) => void
|
||||
/** 接口调用成功的回调函数 */
|
||||
type HideToastSuccessCallback = (res: GeneralCallbackResult) => void
|
||||
interface HideToastOption {
|
||||
/** 接口调用结束的回调函数(调用成功、失败都会执行) */
|
||||
complete?: HideToastCompleteCallback
|
||||
/** 接口调用失败的回调函数 */
|
||||
fail?: HideToastFailCallback
|
||||
/**
|
||||
* 微信小程序:需要基础库: `2.22.1`
|
||||
*
|
||||
* 微信小程序:目前 toast 和 loading 相关接口可以相互混用,此参数可用于取消混用特性
|
||||
*/
|
||||
noConflict?: boolean
|
||||
/** 接口调用成功的回调函数 */
|
||||
success?: HideToastSuccessCallback
|
||||
}
|
||||
}
|
||||
interface Uni {
|
||||
/**
|
||||
* 隐藏 loading 提示框
|
||||
*
|
||||
* 文档: [http://uniapp.dcloud.io/api/ui/prompt?id=hideloading](http://uniapp.dcloud.io/api/ui/prompt?id=hideloading)
|
||||
* @example ```typescript
|
||||
* uni.showLoading({
|
||||
* title: '加载中'
|
||||
* });
|
||||
*
|
||||
* setTimeout(function () {
|
||||
* uni.hideLoading();
|
||||
* }, 2000);
|
||||
*
|
||||
* ```
|
||||
* @tutorial [](https://uniapp.dcloud.net.cn/api/ui/prompt.html#hideloading)
|
||||
* @uniPlatform {
|
||||
* "app": {
|
||||
* "android": {
|
||||
* "osVer": "4.4.4",
|
||||
* "uniVer": "√",
|
||||
* "unixVer": "3.9.0"
|
||||
* },
|
||||
* "ios": {
|
||||
* "osVer": "9.0",
|
||||
* "uniVer": "√",
|
||||
* "unixVer": "3.9.0"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
// eslint-disable-next-line ts/method-signature-style
|
||||
hideLoading<T extends UniNamespace.HideToastOption = UniNamespace.HideToastOption>(options?: T): void
|
||||
/**
|
||||
* 隐藏消息提示框
|
||||
*
|
||||
* 文档: [http://uniapp.dcloud.io/api/ui/prompt?id=hidetoast](http://uniapp.dcloud.io/api/ui/prompt?id=hidetoast)
|
||||
* @example ```typescript
|
||||
* uni.hideToast();
|
||||
* ```
|
||||
* @tutorial [](https://uniapp.dcloud.net.cn/api/ui/prompt.html#hidetoast)
|
||||
* @uniPlatform {
|
||||
* "app": {
|
||||
* "android": {
|
||||
* "osVer": "4.4.4",
|
||||
* "uniVer": "√",
|
||||
* "unixVer": "3.9.0"
|
||||
* },
|
||||
* "ios": {
|
||||
* "osVer": "9.0",
|
||||
* "uniVer": "√",
|
||||
* "unixVer": "3.9.0"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
// eslint-disable-next-line ts/method-signature-style
|
||||
hideToast<T extends UniNamespace.HideLoadingOption = UniNamespace.HideLoadingOption>(options?: T): void
|
||||
}
|
||||
}
|
||||
|
||||
export {} // 防止模块污染
|
||||
21
src/typings.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// 枚举定义
|
||||
|
||||
export enum TestEnum {
|
||||
A = '1',
|
||||
B = '2',
|
||||
}
|
||||
|
||||
// uni.uploadFile文件上传参数
|
||||
export interface IUniUploadFileOptions {
|
||||
file?: File
|
||||
files?: UniApp.UploadFileOptionFiles[]
|
||||
filePath?: string
|
||||
name?: string
|
||||
formData?: any
|
||||
}
|
||||
|
||||
/** 工具类型:删除字符串开头的第一个斜杠 */
|
||||
export type RemoveLeadingSlash<S extends string> = S extends `/${infer Rest}` ? Rest : S
|
||||
|
||||
/** 工具类型:删除联合类型中每个字符串的第一个斜杠 */
|
||||
export type RemoveLeadingSlashFromUnion<T extends string> = T extends any ? RemoveLeadingSlash<T> : never
|
||||
77
src/uni.scss
Normal file
@@ -0,0 +1,77 @@
|
||||
/* stylelint-disable comment-empty-line-before */
|
||||
/**
|
||||
* 这里是uni-app内置的常用样式变量
|
||||
*
|
||||
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
|
||||
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
||||
*
|
||||
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
|
||||
*/
|
||||
|
||||
/* 颜色变量 */
|
||||
|
||||
/* 行为相关颜色 */
|
||||
$uni-color-primary: #007aff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 文字基本颜色 */
|
||||
$uni-text-color: #333; // 基本色
|
||||
$uni-text-color-inverse: #fff; // 反色
|
||||
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
|
||||
$uni-text-color-placeholder: #808080;
|
||||
$uni-text-color-disable: #c0c0c0;
|
||||
|
||||
/* 背景颜色 */
|
||||
$uni-bg-color: #fff;
|
||||
$uni-bg-color-grey: #f8f8f8;
|
||||
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
|
||||
$uni-bg-color-mask: rgb(0 0 0 / 40%); // 遮罩颜色
|
||||
|
||||
/* 边框颜色 */
|
||||
$uni-border-color: #c8c7cc;
|
||||
|
||||
/* 尺寸变量 */
|
||||
|
||||
/* 文字尺寸 */
|
||||
$uni-font-size-sm: 12px;
|
||||
$uni-font-size-base: 14px;
|
||||
$uni-font-size-lg: 16;
|
||||
|
||||
/* 图片尺寸 */
|
||||
$uni-img-size-sm: 20px;
|
||||
$uni-img-size-base: 26px;
|
||||
$uni-img-size-lg: 40px;
|
||||
|
||||
/* Border Radius */
|
||||
$uni-border-radius-sm: 2px;
|
||||
$uni-border-radius-base: 3px;
|
||||
$uni-border-radius-lg: 6px;
|
||||
$uni-border-radius-circle: 50%;
|
||||
|
||||
/* 水平间距 */
|
||||
$uni-spacing-row-sm: 5px;
|
||||
$uni-spacing-row-base: 10px;
|
||||
$uni-spacing-row-lg: 15px;
|
||||
|
||||
/* 垂直间距 */
|
||||
$uni-spacing-col-sm: 4px;
|
||||
$uni-spacing-col-base: 8px;
|
||||
$uni-spacing-col-lg: 12px;
|
||||
|
||||
/* 透明度 */
|
||||
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
|
||||
|
||||
/* 文章场景相关 */
|
||||
$uni-color-title: #2c405a; // 文章标题颜色
|
||||
$uni-font-size-title: 20px;
|
||||
$uni-color-subtitle: #555; // 二级标题颜色
|
||||
$uni-font-size-subtitle: 18px;
|
||||
$uni-color-paragraph: #3f536e; // 文章段落颜色
|
||||
$uni-font-size-paragraph: 15px;
|
||||
0
src/uni_modules/.gitkeep
Normal file
166
src/utils/debounce.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// fork from https://github.com/toss/es-toolkit/blob/main/src/function/debounce.ts
|
||||
// 文档可查看:https://es-toolkit.dev/reference/function/debounce.html
|
||||
// 如需要 throttle 功能,可 copy https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts
|
||||
|
||||
interface DebounceOptions {
|
||||
/**
|
||||
* An optional AbortSignal to cancel the debounced function.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
|
||||
/**
|
||||
* An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both.
|
||||
* If `edges` includes "leading", the function will be invoked at the start of the delay period.
|
||||
* If `edges` includes "trailing", the function will be invoked at the end of the delay period.
|
||||
* If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period.
|
||||
* @default ["trailing"]
|
||||
*/
|
||||
edges?: Array<'leading' | 'trailing'>
|
||||
}
|
||||
|
||||
export interface DebouncedFunction<F extends (...args: any[]) => void> {
|
||||
(...args: Parameters<F>): void
|
||||
|
||||
/**
|
||||
* Schedules the execution of the debounced function after the specified debounce delay.
|
||||
* This method resets any existing timer, ensuring that the function is only invoked
|
||||
* after the delay has elapsed since the last call to the debounced function.
|
||||
* It is typically called internally whenever the debounced function is invoked.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
schedule: () => void
|
||||
|
||||
/**
|
||||
* Cancels any pending execution of the debounced function.
|
||||
* This method clears the active timer and resets any stored context or arguments.
|
||||
*/
|
||||
cancel: () => void
|
||||
|
||||
/**
|
||||
* Immediately invokes the debounced function if there is a pending execution.
|
||||
* This method executes the function right away if there is a pending execution.
|
||||
*/
|
||||
flush: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a debounced function that delays invoking the provided function until after `debounceMs` milliseconds
|
||||
* have elapsed since the last time the debounced function was invoked. The debounced function also has a `cancel`
|
||||
* method to cancel any pending execution.
|
||||
*
|
||||
* @template F - The type of function.
|
||||
* @param {F} func - The function to debounce.
|
||||
* @param {number} debounceMs - The number of milliseconds to delay.
|
||||
* @param {DebounceOptions} options - The options object
|
||||
* @param {AbortSignal} options.signal - An optional AbortSignal to cancel the debounced function.
|
||||
* @returns A new debounced function with a `cancel` method.
|
||||
*
|
||||
* @example
|
||||
* const debouncedFunction = debounce(() => {
|
||||
* console.log('Function executed');
|
||||
* }, 1000);
|
||||
*
|
||||
* // Will log 'Function executed' after 1 second if not called again in that time
|
||||
* debouncedFunction();
|
||||
*
|
||||
* // Will not log anything as the previous call is canceled
|
||||
* debouncedFunction.cancel();
|
||||
*
|
||||
* // With AbortSignal
|
||||
* const controller = new AbortController();
|
||||
* const signal = controller.signal;
|
||||
* const debouncedWithSignal = debounce(() => {
|
||||
* console.log('Function executed');
|
||||
* }, 1000, { signal });
|
||||
*
|
||||
* debouncedWithSignal();
|
||||
*
|
||||
* // Will cancel the debounced function call
|
||||
* controller.abort();
|
||||
*/
|
||||
export function debounce<F extends (...args: any[]) => void>(
|
||||
func: F,
|
||||
debounceMs: number,
|
||||
{ signal, edges }: DebounceOptions = {},
|
||||
): DebouncedFunction<F> {
|
||||
let pendingThis: any
|
||||
let pendingArgs: Parameters<F> | null = null
|
||||
|
||||
const leading = edges != null && edges.includes('leading')
|
||||
const trailing = edges == null || edges.includes('trailing')
|
||||
|
||||
const invoke = () => {
|
||||
if (pendingArgs !== null) {
|
||||
func.apply(pendingThis, pendingArgs)
|
||||
pendingThis = undefined
|
||||
pendingArgs = null
|
||||
}
|
||||
}
|
||||
|
||||
const onTimerEnd = () => {
|
||||
if (trailing) {
|
||||
invoke()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line ts/no-use-before-define
|
||||
cancel()
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const schedule = () => {
|
||||
if (timeoutId != null) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null
|
||||
|
||||
onTimerEnd()
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
const cancelTimer = () => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
cancelTimer()
|
||||
pendingThis = undefined
|
||||
pendingArgs = null
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
invoke()
|
||||
}
|
||||
|
||||
const debounced = function (this: any, ...args: Parameters<F>) {
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line ts/no-this-alias
|
||||
pendingThis = this
|
||||
pendingArgs = args
|
||||
|
||||
const isFirstCall = timeoutId == null
|
||||
|
||||
schedule()
|
||||
|
||||
if (leading && isFirstCall) {
|
||||
invoke()
|
||||
}
|
||||
}
|
||||
|
||||
debounced.schedule = schedule
|
||||
debounced.cancel = cancel
|
||||
debounced.flush = flush
|
||||
|
||||
signal?.addEventListener('abort', cancel, { once: true })
|
||||
|
||||
return debounced
|
||||
}
|
||||
159
src/utils/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { PageMetaDatum, SubPackages } from '@uni-helper/vite-plugin-uni-pages'
|
||||
import { isMpWeixin } from '@uni-helper/uni-env'
|
||||
import { pages, subPackages } from '@/pages.json'
|
||||
|
||||
export type PageInstance = Page.PageInstance<AnyObject, object> & { $page: Page.PageInstance<AnyObject, object> & { fullPath: string } }
|
||||
|
||||
export function getLastPage() {
|
||||
// getCurrentPages() 至少有1个元素,所以不再额外判断
|
||||
// const lastPage = getCurrentPages().at(-1)
|
||||
// 上面那个在低版本安卓中打包会报错,所以改用下面这个【虽然我加了 src/interceptions/prototype.ts,但依然报错】
|
||||
const pages = getCurrentPages()
|
||||
return pages[pages.length - 1] as PageInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前页面路由的 path 路径和 redirectPath 路径
|
||||
* path 如 '/pages/login/login'
|
||||
* redirectPath 如 '/pages/demo/base/route-interceptor'
|
||||
*/
|
||||
export function currRoute() {
|
||||
const lastPage = getLastPage() as PageInstance
|
||||
if (!lastPage) {
|
||||
return {
|
||||
path: '',
|
||||
query: {},
|
||||
}
|
||||
}
|
||||
const currRoute = lastPage.$page
|
||||
// console.log('lastPage.$page:', currRoute)
|
||||
// console.log('lastPage.$page.fullpath:', currRoute.fullPath)
|
||||
// console.log('lastPage.$page.options:', currRoute.options)
|
||||
// console.log('lastPage.options:', (lastPage as any).options)
|
||||
// 经过多端测试,只有 fullPath 靠谱,其他都不靠谱
|
||||
const { fullPath } = currRoute
|
||||
// console.log(fullPath)
|
||||
// eg: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor (小程序)
|
||||
// eg: /pages/login/login?redirect=%2Fpages%2Froute-interceptor%2Findex%3Fname%3Dfeige%26age%3D30(h5)
|
||||
return parseUrlToObj(fullPath)
|
||||
}
|
||||
|
||||
export function ensureDecodeURIComponent(url: string) {
|
||||
if (url.startsWith('%')) {
|
||||
return ensureDecodeURIComponent(decodeURIComponent(url))
|
||||
}
|
||||
return url
|
||||
}
|
||||
/**
|
||||
* 解析 url 得到 path 和 query
|
||||
* 比如输入url: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor
|
||||
* 输出: {path: /pages/login/login, query: {redirect: /pages/demo/base/route-interceptor}}
|
||||
*/
|
||||
export function parseUrlToObj(url: string) {
|
||||
const [path, queryStr] = url.split('?')
|
||||
// console.log(path, queryStr)
|
||||
|
||||
if (!queryStr) {
|
||||
return {
|
||||
path,
|
||||
query: {},
|
||||
}
|
||||
}
|
||||
const query: Record<string, string> = {}
|
||||
queryStr.split('&').forEach((item) => {
|
||||
const [key, value] = item.split('=')
|
||||
// console.log(key, value)
|
||||
query[key] = ensureDecodeURIComponent(value) // 这里需要统一 decodeURIComponent 一下,可以兼容h5和微信y
|
||||
})
|
||||
return { path, query }
|
||||
}
|
||||
/**
|
||||
* 得到所有的需要登录的 pages,包括主包和分包的
|
||||
* 这里设计得通用一点,可以传递 key 作为判断依据,默认是 excludeLoginPath, 与 route-block 配对使用
|
||||
* 如果没有传 key,则表示所有的 pages,如果传递了 key, 则表示通过 key 过滤
|
||||
*/
|
||||
export function getAllPages(key?: string) {
|
||||
// 这里处理主包
|
||||
const mainPages = (pages as PageMetaDatum[])
|
||||
.filter(page => !key || page[key])
|
||||
.map(page => ({
|
||||
...page,
|
||||
path: `/${page.path}`,
|
||||
}))
|
||||
|
||||
// 这里处理分包
|
||||
const subPages: PageMetaDatum[] = []
|
||||
;(subPackages as SubPackages).forEach((subPageObj) => {
|
||||
// console.log(subPageObj)
|
||||
const { root } = subPageObj
|
||||
|
||||
subPageObj.pages
|
||||
.filter(page => !key || page[key])
|
||||
.forEach((page) => {
|
||||
subPages.push({
|
||||
...page,
|
||||
path: `/${root}/${page.path}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
const result = [...mainPages, ...subPages]
|
||||
// console.log(`getAllPages by ${key} result: `, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export function getCurrentPageI18nKey() {
|
||||
const routeObj = currRoute()
|
||||
const currPage = (pages as PageMetaDatum[]).find(page => `/${page.path}` === routeObj.path)
|
||||
if (!currPage) {
|
||||
console.warn('路由不正确')
|
||||
return ''
|
||||
}
|
||||
console.log(currPage)
|
||||
console.log(currPage.style.navigationBarTitleText)
|
||||
return currPage.style?.navigationBarTitleText || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据微信小程序当前环境,判断应该获取的 baseUrl
|
||||
*/
|
||||
export function getEnvBaseUrl() {
|
||||
// 请求基准地址
|
||||
let baseUrl = import.meta.env.VITE_SERVER_BASEURL
|
||||
|
||||
// # 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。
|
||||
const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'https://ukw0y1.laf.run'
|
||||
const VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'https://ukw0y1.laf.run'
|
||||
const VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'https://ukw0y1.laf.run'
|
||||
|
||||
// 微信小程序端环境区分
|
||||
if (isMpWeixin) {
|
||||
const {
|
||||
miniProgram: { envVersion },
|
||||
} = uni.getAccountInfoSync()
|
||||
|
||||
switch (envVersion) {
|
||||
case 'develop':
|
||||
baseUrl = VITE_SERVER_BASEURL__WEIXIN_DEVELOP || baseUrl
|
||||
break
|
||||
case 'trial':
|
||||
baseUrl = VITE_SERVER_BASEURL__WEIXIN_TRIAL || baseUrl
|
||||
break
|
||||
case 'release':
|
||||
baseUrl = VITE_SERVER_BASEURL__WEIXIN_RELEASE || baseUrl
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是双token模式
|
||||
*/
|
||||
export const isDoubleTokenMode = import.meta.env.VITE_AUTH_MODE === 'double'
|
||||
|
||||
/**
|
||||
* 首页路径,通过 page.json 里面的 type 为 home 的页面获取,如果没有,则默认是第一个页面
|
||||
* 通常为 /pages/index/index
|
||||
*/
|
||||
export const HOME_PAGE = `/${(pages as PageMetaDatum[]).find(page => page.type === 'home')?.path || (pages as PageMetaDatum[])[0].path}`
|
||||
38
src/utils/systemInfo.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable import/no-mutable-exports */
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let systemInfo
|
||||
let safeAreaInsets
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序使用新的API
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
// 其他平台继续使用uni API
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
console.log('systemInfo', systemInfo)
|
||||
// 微信里面打印
|
||||
// pixelRatio: 3
|
||||
// safeArea: {top: 47, left: 0, right: 390, bottom: 810, width: 390, …}
|
||||
// safeAreaInsets: {top: 47, left: 0, right: 0, bottom: 34}
|
||||
// screenHeight: 844
|
||||
// screenTop: 91
|
||||
// screenWidth: 390
|
||||
// statusBarHeight: 47
|
||||
// windowBottom: 0
|
||||
// windowHeight: 753
|
||||
// windowTop: 0
|
||||
// windowWidth: 390
|
||||
export { safeAreaInsets, systemInfo }
|
||||
44
src/utils/toLoginPage.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getLastPage } from '@/utils'
|
||||
import { debounce } from '@/utils/debounce'
|
||||
|
||||
interface ToLoginPageOptions {
|
||||
/**
|
||||
* 跳转模式, uni.navigateTo | uni.reLaunch
|
||||
* @default 'navigateTo'
|
||||
*/
|
||||
mode?: 'navigateTo' | 'reLaunch'
|
||||
/**
|
||||
* 查询参数
|
||||
* @example '?redirect=/pages/home/index'
|
||||
*/
|
||||
queryString?: string
|
||||
}
|
||||
|
||||
// TODO: 自己增加登录页
|
||||
const LOGIN_PAGE = '/pages/login/index'
|
||||
|
||||
/**
|
||||
* 跳转到登录页, 带防抖处理
|
||||
*
|
||||
* 如果要立即跳转,不做延时,可以使用 `toLoginPage.flush()` 方法
|
||||
*/
|
||||
export const toLoginPage = debounce((options: ToLoginPageOptions = {}) => {
|
||||
const { mode = 'navigateTo', queryString = '' } = options
|
||||
|
||||
const url = `${LOGIN_PAGE}${queryString}`
|
||||
|
||||
// 获取当前页面路径
|
||||
const currentPage = getLastPage()
|
||||
const currentPath = `/${currentPage.route}`
|
||||
// 如果已经在登录页,则不跳转
|
||||
if (currentPath === LOGIN_PAGE) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'navigateTo') {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
else {
|
||||
uni.reLaunch({ url })
|
||||
}
|
||||
}, 500)
|
||||
29
src/utils/updateManager.wx.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default () => {
|
||||
if (!wx.canIUse('getUpdateManager')) {
|
||||
return
|
||||
}
|
||||
|
||||
const updateManager = wx.getUpdateManager()
|
||||
|
||||
updateManager.onCheckForUpdate((res) => {
|
||||
// 请求完新版本信息的回调
|
||||
console.log('版本信息', res)
|
||||
})
|
||||
|
||||
updateManager.onUpdateReady(() => {
|
||||
wx.showModal({
|
||||
title: '更新提示',
|
||||
content: '新版本已经准备好,是否重启应用?',
|
||||
success(res) {
|
||||
if (res.confirm) {
|
||||
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
|
||||
updateManager.applyUpdate()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
updateManager.onUpdateFailed(() => {
|
||||
// 新版本下载失败
|
||||
})
|
||||
}
|
||||
325
src/utils/uploadFile.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 文件上传钩子函数使用示例
|
||||
* @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 function 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: Record<string, any>) => void
|
||||
/** 上传失败回调函数 */
|
||||
onError?: (err: Error | UniApp.GeneralCallbackResult) => void
|
||||
/** 上传完成回调函数(无论成功失败) */
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传钩子函数
|
||||
* @template T 上传成功后返回的数据类型
|
||||
* @param url 上传地址
|
||||
* @param formData 额外的表单数据
|
||||
* @param options 上传选项
|
||||
* @returns 上传状态和控制对象
|
||||
*/
|
||||
export function 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) {
|
||||
uni.showToast({
|
||||
title: `文件大小不能超过${maxSize}MB`,
|
||||
icon: 'none',
|
||||
})
|
||||
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: Record<string, any>) => 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) => {
|
||||
console.log('上传文件成功:', uploadFileRes)
|
||||
try {
|
||||
// 解析响应数据
|
||||
const { data: _data } = JSON.parse(uploadFileRes.data)
|
||||
// 上传成功
|
||||
data.value = _data as T
|
||||
onSuccess?.(_data)
|
||||
}
|
||||
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('创建上传任务失败'))
|
||||
}
|
||||
}
|
||||