初始化=商城+金融,用于演示.
3
.commitlintrc.cjs
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
}
|
||||
51
.cursor/rules/api-http-patterns.mdc
Normal file
@@ -0,0 +1,51 @@
|
||||
# API 和 HTTP 请求规范
|
||||
|
||||
## HTTP 请求封装
|
||||
- 可以使用 `简单http` 或者 `alova` 或者 `@tanstack/vue-query` 进行请求管理
|
||||
- HTTP 配置在 [src/http/](mdc:src/http/) 目录下
|
||||
- `简单http` - [src/http/http.ts](mdc:src/http/http.ts)
|
||||
- `alova` - [src/http/alova.ts](mdc:src/http/alova.ts)
|
||||
- `vue-query` - [src/http/vue-query.ts](mdc:src/http/vue-query.ts)
|
||||
- 请求拦截器在 [src/http/interceptor.ts](mdc:src/http/interceptor.ts)
|
||||
- 支持请求重试、缓存、错误处理
|
||||
|
||||
## API 接口规范
|
||||
- API 接口定义在 [src/api/](mdc:src/api/) 目录下
|
||||
- 按功能模块组织 API 文件
|
||||
- 使用 TypeScript 定义请求和响应类型
|
||||
- 支持 `简单http`、`alova` 和 `vue-query` 三种请求方式
|
||||
|
||||
|
||||
## 示例代码结构
|
||||
```typescript
|
||||
// API 接口定义
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
userInfo: UserInfo
|
||||
}
|
||||
|
||||
// alova 方式
|
||||
export const login = (params: LoginParams) =>
|
||||
http.Post<LoginResponse>('/api/login', params)
|
||||
|
||||
// vue-query 方式
|
||||
export const useLogin = () => {
|
||||
return useMutation({
|
||||
mutationFn: (params: LoginParams) =>
|
||||
http.post<LoginResponse>('/api/login', params)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
- 统一错误处理在拦截器中配置
|
||||
- 支持网络错误、业务错误、认证错误等
|
||||
- 自动处理 token 过期和刷新
|
||||
---
|
||||
globs: src/api/*.ts,src/http/*.ts
|
||||
---
|
||||
43
.cursor/rules/development-workflow.mdc
Normal file
@@ -0,0 +1,43 @@
|
||||
# 开发工作流程
|
||||
|
||||
## 项目启动
|
||||
1. 安装依赖:`pnpm install`
|
||||
2. 开发环境:
|
||||
- H5: `pnpm dev` 或 `pnpm dev:h5`
|
||||
- 微信小程序: `pnpm dev:mp`
|
||||
- 支付宝小程序: `pnpm dev:mp-alipay`
|
||||
- APP: `pnpm dev:app`
|
||||
|
||||
## 代码规范
|
||||
- 使用 ESLint 进行代码检查:`pnpm lint`
|
||||
- 自动修复代码格式:`pnpm lint:fix`
|
||||
- 使用 eslint 格式化代码
|
||||
- 遵循 TypeScript 严格模式
|
||||
|
||||
## 构建和部署
|
||||
- H5 构建:`pnpm build:h5`
|
||||
- 微信小程序构建:`pnpm build:mp`
|
||||
- 支付宝小程序构建:`pnpm build:mp-alipay`
|
||||
- APP 构建:`pnpm build:app`
|
||||
- 类型检查:`pnpm type-check`
|
||||
|
||||
## 开发工具
|
||||
- 推荐使用 VSCode 编辑器
|
||||
- 安装 Vue 和 TypeScript 相关插件
|
||||
- 使用 uni-app 开发者工具调试小程序
|
||||
- 使用 HBuilderX 调试 APP
|
||||
|
||||
## 调试技巧
|
||||
- 使用 console.log 和 uni.showToast 调试
|
||||
- 利用 Vue DevTools 调试组件状态
|
||||
- 使用网络面板调试 API 请求
|
||||
- 平台差异测试和兼容性检查
|
||||
|
||||
## 性能优化
|
||||
- 使用懒加载和代码分割
|
||||
- 优化图片和静态资源
|
||||
- 减少不必要的重渲染
|
||||
- 合理使用缓存策略
|
||||
---
|
||||
description: 开发工作流程和最佳实践指南
|
||||
---
|
||||
36
.cursor/rules/project-overview.mdc
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
# unibest 项目概览
|
||||
|
||||
这是一个基于 uniapp + Vue3 + TypeScript + Vite5 + UnoCSS 的跨平台开发框架。
|
||||
|
||||
## 项目特点
|
||||
- 支持 H5、小程序、APP 多平台开发
|
||||
- 使用最新的前端技术栈
|
||||
- 内置约定式路由、layout布局、请求封装、登录拦截、自定义tabbar等功能
|
||||
- 无需依赖 HBuilderX,支持命令行开发
|
||||
|
||||
## 核心配置文件
|
||||
- [package.json](mdc:package.json) - 项目依赖和脚本配置
|
||||
- [vite.config.ts](mdc:vite.config.ts) - Vite 构建配置
|
||||
- [pages.config.ts](mdc:pages.config.ts) - 页面路由配置
|
||||
- [manifest.config.ts](mdc:manifest.config.ts) - 应用清单配置
|
||||
- [uno.config.ts](mdc:uno.config.ts) - UnoCSS 配置
|
||||
|
||||
## 主要目录结构
|
||||
- `src/pages/` - 页面文件
|
||||
- `src/components/` - 组件文件
|
||||
- `src/layouts/` - 布局文件
|
||||
- `src/api/` - API 接口
|
||||
- `src/http/` - HTTP 请求封装
|
||||
- `src/store/` - 状态管理
|
||||
- `src/tabbar/` - 底部导航栏
|
||||
- `src/App.ku.vue` - 全局根组件(类似 App.vue 里面的 template作用)
|
||||
|
||||
## 开发命令
|
||||
- `pnpm dev` - 开发 H5 版本
|
||||
- `pnpm dev:mp` - 开发微信小程序
|
||||
- `pnpm dev:mp-alipay` - 开发支付宝小程序(含钉钉)
|
||||
- `pnpm dev:app` - 开发 APP 版本
|
||||
- `pnpm build` - 构建生产版本
|
||||
54
.cursor/rules/styling-css-patterns.mdc
Normal file
@@ -0,0 +1,54 @@
|
||||
# 样式和 CSS 开发规范
|
||||
|
||||
## UnoCSS 原子化 CSS
|
||||
- 项目使用 UnoCSS 作为原子化 CSS 框架
|
||||
- 配置在 [uno.config.ts](mdc:uno.config.ts)
|
||||
- 支持预设和自定义规则
|
||||
- 优先使用原子化类名,减少自定义 CSS
|
||||
|
||||
## SCSS 规范
|
||||
- 使用 SCSS 预处理器
|
||||
- 样式文件使用 `lang="scss"` 和 `scoped` 属性
|
||||
- 遵循 BEM 命名规范
|
||||
- 使用变量和混入提高复用性
|
||||
|
||||
## 样式组织
|
||||
- 全局样式在 [src/style/](mdc:src/style/) 目录下
|
||||
- 组件样式使用 scoped 作用域
|
||||
- 图标字体在 [src/style/iconfont.css](mdc:src/style/iconfont.css)
|
||||
- 主题变量在 [src/uni_modules/uni-scss/](mdc:src/uni_modules/uni-scss/) 目录下
|
||||
|
||||
## 示例代码结构
|
||||
```vue
|
||||
<template>
|
||||
<view class="container flex flex-col items-center p-4">
|
||||
<text class="title text-lg font-bold mb-2">标题</text>
|
||||
<view class="content bg-gray-100 rounded-lg p-3">
|
||||
<!-- 内容 -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
|
||||
.title {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 600rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
## 响应式设计
|
||||
- 使用 rpx 单位适配不同屏幕
|
||||
- 支持横屏和竖屏布局
|
||||
- 使用 flexbox 和 grid 布局
|
||||
- 考虑不同平台的样式差异
|
||||
---
|
||||
globs: *.vue,*.scss,*.css
|
||||
---
|
||||
62
.cursor/rules/uni-app-patterns.mdc
Normal file
@@ -0,0 +1,62 @@
|
||||
# uni-app 开发规范
|
||||
|
||||
## 页面开发
|
||||
- 页面文件放在 [src/pages/](mdc:src/pages/) 目录下
|
||||
- 使用约定式路由,文件名即路由路径
|
||||
- 页面配置在仅需要在 宏`definePage` 中配置标题等内容即可,会自动生成到 `pages.json` 中
|
||||
|
||||
## 组件开发
|
||||
- 组件文件放在 [src/components/](mdc:src/components/) 或者 [src/pages/xx/components/](mdc:src/pages/xx/components/) 目录下
|
||||
- 使用 uni-app 内置组件和第三方组件库
|
||||
- 支持 wot-ui\uview-pro\uv-ui\sard-ui\uview-plus 等多种第三方组件库 和 z-paging 组件
|
||||
- 自定义组件遵循 uni-app 组件规范
|
||||
|
||||
## 平台适配
|
||||
- 使用条件编译处理平台差异
|
||||
- 支持 H5、小程序、APP 多平台
|
||||
- 注意各平台的 API 差异
|
||||
- 使用 uni.xxx API 替代原生 API
|
||||
|
||||
## 示例代码结构
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// #ifdef H5
|
||||
import { h5Api } from '@/utils/h5'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
import { mpApi } from '@/utils/mp'
|
||||
// #endif
|
||||
|
||||
const handleClick = () => {
|
||||
// #ifdef H5
|
||||
h5Api.showToast('H5 平台')
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
mpApi.showToast('微信小程序')
|
||||
// #endif
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- uni-app 组件 -->
|
||||
<button @click="handleClick">点击</button>
|
||||
|
||||
<!-- 条件渲染 -->
|
||||
<!-- #ifdef H5 -->
|
||||
<view>H5 特有内容</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 生命周期
|
||||
- 使用 uni-app 页面生命周期
|
||||
- onLoad、onShow、onReady、onHide、onUnload
|
||||
- 组件生命周期遵循 Vue3 规范
|
||||
- 注意页面栈和导航管理
|
||||
---
|
||||
globs: src/pages/*.vue,src/components/*.vue
|
||||
---
|
||||
53
.cursor/rules/vue-typescript-patterns.mdc
Normal file
@@ -0,0 +1,53 @@
|
||||
# Vue3 + TypeScript 开发规范
|
||||
|
||||
## Vue 组件规范
|
||||
- 使用 Composition API 和 `<script setup>` 语法
|
||||
- 组件文件使用 PascalCase 命名
|
||||
- 页面文件放在 `src/pages/` 目录下
|
||||
- 全局组件文件放在 `src/components/` 目录下
|
||||
- 局部组件文件放在页面的 `/components/` 目录下
|
||||
|
||||
## Vue SFC 组件规范
|
||||
- `<script setup lang="ts">` 标签必须是第一个子元素
|
||||
- `<template>` 标签必须是第二个子元素
|
||||
- `<style scoped>` 标签必须是最后一个子元素(因为推荐使用原子化类名,所以很可能没有)
|
||||
|
||||
## TypeScript 规范
|
||||
- 严格使用 TypeScript,避免使用 `any` 类型
|
||||
- 为 API 响应数据定义接口类型
|
||||
- 使用 `interface` 定义对象类型,`type` 定义联合类型
|
||||
- 导入类型时使用 `import type` 语法
|
||||
|
||||
## 状态管理
|
||||
- 使用 Pinia 进行状态管理
|
||||
- Store 文件放在 `src/store/` 目录下
|
||||
- 使用 `defineStore` 定义 store
|
||||
- 支持持久化存储
|
||||
|
||||
## 示例代码结构
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { UserInfo } from '@/types/user'
|
||||
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化逻辑
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<!-- 模板内容 -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
// 样式
|
||||
}
|
||||
</style>
|
||||
---
|
||||
globs: *.vue,*.ts,*.tsx
|
||||
---
|
||||
13
.editorconfig
Normal file
@@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*] # 表示所有文件适用
|
||||
charset = utf-8 # 设置文件字符集为 utf-8
|
||||
indent_style = space # 缩进风格(tab | space)
|
||||
indent_size = 2 # 缩进大小
|
||||
end_of_line = lf # 控制换行类型(lf | cr | crlf)
|
||||
trim_trailing_whitespace = true # 去除行首的任意空白字符
|
||||
insert_final_newline = true # 始终在文件末尾插入一个新行
|
||||
|
||||
[*.md] # 表示仅 md 文件适用以下规则
|
||||
max_line_length = off # 关闭最大行长度限制
|
||||
trim_trailing_whitespace = false # 关闭末尾空格修剪
|
||||
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.hbuilderx
|
||||
|
||||
.stylelintcache
|
||||
.eslintcache
|
||||
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
src/types
|
||||
# 单独把这个文件排除掉,用以解决部分电脑生成的 auto-import.d.ts 的API不完整导致类型提示报错问题
|
||||
!src/types/auto-import.d.ts
|
||||
src/manifest.json
|
||||
src/pages.json
|
||||
|
||||
# 2025-10-15 by 菲鸽: lock 文件还是需要加入版本管理,今天又遇到版本不一致导致无法运行的问题了。
|
||||
# pnpm-lock.yaml
|
||||
# package-lock.json
|
||||
|
||||
# TIPS:如果某些文件已经加入了版本管理,现在重新加入 .gitignore 是不生效的,需要执行下面的操作
|
||||
# `git rm -r --cached .` 然后提交 commit 即可。
|
||||
|
||||
# git rm -r --cached file1 file2 ## 针对某些文件
|
||||
# git rm -r --cached dir1 dir2 ## 针对某些文件夹
|
||||
# git rm -r --cached . ## 针对所有文件
|
||||
|
||||
# 更新 uni-app 官方版本
|
||||
# npx @dcloudio/uvm@latest
|
||||
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
||||
npx --no-install commitlint --edit "$1"
|
||||
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged --allow-empty
|
||||
9
.npmrc
Normal file
@@ -0,0 +1,9 @@
|
||||
# registry = https://registry.npmjs.org
|
||||
registry = https://registry.npmmirror.com
|
||||
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
shamefully-hoist=true
|
||||
ignore-workspace-root-check=true
|
||||
install-workspace-root=true
|
||||
node-options=--max-old-space-size=8192
|
||||
122
.trae/rules/project_rules.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# unibest 项目概览
|
||||
|
||||
这是一个基于 uniapp + Vue3 + TypeScript + Vite5 + UnoCSS 的跨平台开发框架。
|
||||
|
||||
## 项目特点
|
||||
- 支持 H5、小程序、APP 多平台开发
|
||||
- 使用最新的前端技术栈
|
||||
- 内置约定式路由、layout布局、请求封装等功能
|
||||
- 无需依赖 HBuilderX,支持命令行开发
|
||||
|
||||
## 核心配置文件
|
||||
- [package.json](mdc:package.json) - 项目依赖和脚本配置
|
||||
- [vite.config.ts](mdc:vite.config.ts) - Vite 构建配置
|
||||
- [pages.config.ts](mdc:pages.config.ts) - 页面路由配置
|
||||
- [manifest.config.ts](mdc:manifest.config.ts) - 应用清单配置
|
||||
- [uno.config.ts](mdc:uno.config.ts) - UnoCSS 配置
|
||||
|
||||
## 主要目录结构
|
||||
- `src/pages/` - 页面文件
|
||||
- `src/components/` - 组件文件
|
||||
- `src/layouts/` - 布局文件
|
||||
- `src/api/` - API 接口
|
||||
- `src/http/` - HTTP 请求封装
|
||||
- `src/store/` - 状态管理
|
||||
- `src/tabbar/` - 底部导航栏
|
||||
- `src/App.ku.vue` - 全局根组件(类似 App.vue 里面的 template作用)
|
||||
|
||||
## 开发命令
|
||||
- `pnpm dev` - 开发 H5 版本
|
||||
- `pnpm dev:mp` - 开发微信小程序
|
||||
- `pnpm dev:mp-alipay` - 开发支付宝小程序(含钉钉)
|
||||
- `pnpm dev:app` - 开发 APP 版本
|
||||
- `pnpm build` - 构建生产版本
|
||||
|
||||
## Vue 组件规范
|
||||
- 使用 Composition API 和 `<script setup>` 语法
|
||||
- 组件文件使用 PascalCase 命名
|
||||
- 页面文件放在 `src/pages/` 目录下
|
||||
- 全局组件文件放在 `src/components/` 目录下
|
||||
- 局部组件文件放在页面的 `/components/` 目录下
|
||||
|
||||
## TypeScript 规范
|
||||
- 严格使用 TypeScript,避免使用 `any` 类型
|
||||
- 为 API 响应数据定义接口类型
|
||||
- 使用 `interface` 定义对象类型,`type` 定义联合类型
|
||||
- 导入类型时使用 `import type` 语法
|
||||
|
||||
## 状态管理
|
||||
- 使用 Pinia 进行状态管理
|
||||
- Store 文件放在 `src/store/` 目录下
|
||||
- 使用 `defineStore` 定义 store
|
||||
- 支持持久化存储
|
||||
|
||||
## UnoCSS 原子化 CSS
|
||||
- 项目使用 UnoCSS 作为原子化 CSS 框架
|
||||
- 配置在 [uno.config.ts]
|
||||
- 支持预设和自定义规则
|
||||
- 优先使用原子化类名,减少自定义 CSS
|
||||
|
||||
## Vue SFC 组件规范
|
||||
- `<script setup lang="ts">` 标签必须是第一个子元素
|
||||
- `<template>` 标签必须是第二个子元素
|
||||
- `<style scoped>` 标签必须是最后一个子元素(因为推荐使用原子化类名,所以很可能没有)
|
||||
|
||||
## 页面开发
|
||||
- 页面文件放在 [src/pages/]目录下
|
||||
- 使用约定式路由,文件名即路由路径
|
||||
- 页面配置在仅需要在 宏`definePage` 中配置标题等内容即可,会自动生成到 `pages.json` 中
|
||||
|
||||
## 组件开发
|
||||
- 全局组件文件放在 `src/components/` 目录下
|
||||
- 局部组件文件放在页面的 `/components/` 目录下
|
||||
- 使用 uni-app 内置组件和第三方组件库
|
||||
- 支持 wot-ui\uview-pro\uv-ui\sard-ui\uview-plus 等多种第三方组件库 和 z-paging 组件
|
||||
- 自定义组件遵循 uni-app 组件规范
|
||||
|
||||
## 平台适配
|
||||
- 使用条件编译处理平台差异
|
||||
- 支持 H5、小程序、APP 多平台
|
||||
- 注意各平台的 API 差异
|
||||
- 使用 uni.xxx API 替代原生 API
|
||||
|
||||
## 示例代码结构
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// #ifdef H5
|
||||
import { h5Api } from '@/utils/h5'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
import { mpApi } from '@/utils/mp'
|
||||
// #endif
|
||||
|
||||
const handleClick = () => {
|
||||
// #ifdef H5
|
||||
h5Api.showToast('H5 平台')
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
mpApi.showToast('微信小程序')
|
||||
// #endif
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- uni-app 组件 -->
|
||||
<button @click="handleClick">点击</button>
|
||||
|
||||
<!-- 条件渲染 -->
|
||||
<!-- #ifdef H5 -->
|
||||
<view>H5 特有内容</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 生命周期
|
||||
- 使用 uni-app 页面生命周期
|
||||
- onLoad、onShow、onReady、onHide、onUnload
|
||||
- 组件生命周期遵循 Vue3 规范
|
||||
- 注意页面栈和导航管理
|
||||
15
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"antfu.unocss",
|
||||
"antfu.iconify",
|
||||
"evils.uniapp-vscode",
|
||||
"uni-helper.uni-helper-vscode",
|
||||
"uni-helper.uni-app-schemas-vscode",
|
||||
"uni-helper.uni-highlight-vscode",
|
||||
"uni-helper.uni-ui-snippets-vscode",
|
||||
"uni-helper.uni-app-snippets-vscode",
|
||||
"streetsidesoftware.code-spell-checker"
|
||||
]
|
||||
}
|
||||
96
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
// 配置语言的文件关联
|
||||
"files.associations": {
|
||||
"pages.json": "jsonc", // pages.json 可以写注释
|
||||
"manifest.json": "jsonc" // manifest.json 可以写注释
|
||||
},
|
||||
|
||||
"stylelint.enable": false, // 禁用 stylelint
|
||||
"css.validate": false, // 禁用 CSS 内置验证
|
||||
"scss.validate": false, // 禁用 SCSS 内置验证
|
||||
"less.validate": false, // 禁用 LESS 内置验证
|
||||
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.expand": false,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"README.md": "index.html,favicon.ico,robots.txt,CHANGELOG.md",
|
||||
"docker.md": "Dockerfile,docker*.md,nginx*,.dockerignore",
|
||||
"pages.config.ts": "manifest.config.ts,openapi-ts-request.config.ts",
|
||||
"package.json": "tsconfig.json,pnpm-lock.yaml,pnpm-workspace.yaml,LICENSE,.gitattributes,.gitignore,.gitpod.yml,CNAME,.npmrc,.browserslistrc",
|
||||
"eslint.config.mjs": ".commitlintrc.*,.prettier*,.editorconfig,.commitlint.cjs,.eslint*"
|
||||
},
|
||||
|
||||
// Disable the default formatter, use eslint instead
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
|
||||
// Silent the stylistic rules in you IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "style/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "format/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-indent", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spacing", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spaces", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-order", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-dangle", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-newline", "severity": "off", "fixable": true },
|
||||
{ "rule": "*quotes", "severity": "off", "fixable": true },
|
||||
{ "rule": "*semi", "severity": "off", "fixable": true }
|
||||
],
|
||||
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"gql",
|
||||
"graphql",
|
||||
"astro",
|
||||
"svelte",
|
||||
"css",
|
||||
"less",
|
||||
"scss",
|
||||
"pcss",
|
||||
"postcss"
|
||||
],
|
||||
"cSpell.words": [
|
||||
"alova",
|
||||
"Aplipay",
|
||||
"attributify",
|
||||
"chooseavatar",
|
||||
"climblee",
|
||||
"commitlint",
|
||||
"dcloudio",
|
||||
"iconfont",
|
||||
"oxlint",
|
||||
"qrcode",
|
||||
"refresherrefresh",
|
||||
"scrolltolower",
|
||||
"tabbar",
|
||||
"Toutiao",
|
||||
"uniapp",
|
||||
"unibest",
|
||||
"unocss",
|
||||
"uview",
|
||||
"uvui",
|
||||
"Wechat",
|
||||
"WechatMiniprogram",
|
||||
"Weixin"
|
||||
]
|
||||
}
|
||||
77
.vscode/vue3.code-snippets
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
// Place your unibest 工作区 snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
|
||||
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
|
||||
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
|
||||
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
|
||||
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
|
||||
// Placeholders with the same ids are connected.
|
||||
// Example:
|
||||
// "Print to console": {
|
||||
// "scope": "javascript,typescript",
|
||||
// "prefix": "log",
|
||||
// "body": [
|
||||
// "console.log('$1');",
|
||||
// "$2"
|
||||
// ],
|
||||
// "description": "Log output to console"
|
||||
// }
|
||||
"Print unibest Vue3 SFC": {
|
||||
"scope": "vue",
|
||||
"prefix": "v3",
|
||||
"body": [
|
||||
"<script lang=\"ts\" setup>",
|
||||
"definePage({",
|
||||
" style: {",
|
||||
" navigationBarTitleText: '$1',",
|
||||
" },",
|
||||
"})",
|
||||
"</script>\n",
|
||||
"<template>",
|
||||
" <view class=\"\">$3</view>",
|
||||
"</template>\n",
|
||||
"<style lang=\"scss\" scoped>",
|
||||
"//$4",
|
||||
"</style>\n",
|
||||
],
|
||||
},
|
||||
"Print unibest style": {
|
||||
"scope": "vue",
|
||||
"prefix": "st",
|
||||
"body": [
|
||||
"<style lang=\"scss\" scoped>",
|
||||
"//",
|
||||
"</style>\n"
|
||||
],
|
||||
},
|
||||
"Print unibest script": {
|
||||
"scope": "vue",
|
||||
"prefix": "sc",
|
||||
"body": [
|
||||
"<script lang=\"ts\" setup>",
|
||||
"//$1",
|
||||
"</script>\n"
|
||||
],
|
||||
},
|
||||
"Print unibest script with definePage": {
|
||||
"scope": "vue",
|
||||
"prefix": "scdp",
|
||||
"body": [
|
||||
"<script lang=\"ts\" setup>",
|
||||
"definePage({",
|
||||
" style: {",
|
||||
" navigationBarTitleText: '$1',",
|
||||
" },",
|
||||
"})",
|
||||
"</script>\n"
|
||||
],
|
||||
},
|
||||
"Print unibest template": {
|
||||
"scope": "vue",
|
||||
"prefix": "te",
|
||||
"body": [
|
||||
"<template>",
|
||||
" <view class=\"\">$1</view>",
|
||||
"</template>\n"
|
||||
],
|
||||
},
|
||||
}
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 菲鸽
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
98
README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/unibest-tech/unibest">
|
||||
<img width="160" src="./src/static/logo.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">
|
||||
<a href="https://github.com/unibest-tech/unibest" target="_blank">unibest - 最好的 uniapp 开发框架</a>
|
||||
</h1>
|
||||
|
||||
<div align="center">
|
||||
旧仓库 codercup 进不去了,star 也拿不回来,这里也展示一下那个地址的 star.
|
||||
|
||||
[](https://github.com/codercup/unibest)
|
||||
[](https://github.com/codercup/unibest)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/feige996/unibest)
|
||||
[](https://github.com/feige996/unibest)
|
||||
[](https://gitee.com/feige996/unibest/stargazers)
|
||||
[](https://gitee.com/feige996/unibest/members)
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
`unibest` —— 最好的 `uniapp` 开发模板,由 `uniapp` + `Vue3` + `Ts` + `Vite5` + `UnoCss` + `wot-ui` + `z-paging` 构成,使用了最新的前端技术栈,无需依靠 `HBuilderX`,通过命令行方式运行 `web`、`小程序` 和 `App`(编辑器推荐 `VSCode`,可选 `webstorm`)。
|
||||
|
||||
`unibest` 内置了 `约定式路由`、`layout布局`、`请求封装`、`请求拦截`、`登录拦截`、`UnoCSS`、`i18n多语言` 等基础功能,提供了 `代码提示`、`自动格式化`、`统一配置`、`代码片段` 等辅助功能,让你编写 `uniapp` 拥有 `best` 体验 ( `unibest 的由来`)。
|
||||
|
||||

|
||||
|
||||
<p align="center">
|
||||
<a href="https://unibest.tech/" target="_blank">📖 文档地址(new)</a>
|
||||
<span style="margin:0 10px;">|</span>
|
||||
<a href="https://unibest-tech.github.io/hello-unibest" target="_blank">📱 DEMO 地址</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
注意旧的地址 [codercup](https://github.com/codercup/unibest) 我进不去了,使用新的 [feige996](https://github.com/feige996/unibest)。PR和 issue 也请使用新地址,否则无法合并。
|
||||
|
||||
## 平台兼容性
|
||||
|
||||
| H5 | IOS | 安卓 | 微信小程序 | 字节小程序 | 快手小程序 | 支付宝小程序 | 钉钉小程序 | 百度小程序 |
|
||||
| --- | --- | ---- | ---------- | ---------- | ---------- | ------------ | ---------- | ---------- |
|
||||
| √ | √ | √ | √ | √ | √ | √ | √ | √ |
|
||||
|
||||
注意每种 `UI框架` 支持的平台有所不同,详情请看各 `UI框架` 的官网,也可以看 `unibest` 文档。
|
||||
|
||||
## ⚙️ 环境
|
||||
|
||||
- node>=18
|
||||
- pnpm>=7.30
|
||||
- Vue Official>=2.1.10
|
||||
- TypeScript>=5.0
|
||||
|
||||
## 新版分支
|
||||
- main == base
|
||||
- base --> base-i18n
|
||||
- base-login --> base-login-i18n
|
||||
|
||||
## 📂 快速开始
|
||||
|
||||
执行 `pnpm create unibest` 创建项目
|
||||
执行 `pnpm i` 安装依赖
|
||||
执行 `pnpm dev` 运行 `H5`
|
||||
执行 `pnpm dev:mp` 运行 `微信小程序`
|
||||
|
||||
## 📦 运行(支持热更新)
|
||||
|
||||
- web平台: `pnpm dev:h5`, 然后打开 [http://localhost:9000/](http://localhost:9000/)。
|
||||
- weixin平台:`pnpm dev:mp` 然后打开微信开发者工具,导入本地文件夹,选择本项目的`dist/dev/mp-weixin` 文件。
|
||||
- APP平台:`pnpm dev:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/dev/app` 文件夹,选择运行到模拟器(开发时优先使用),或者运行的安卓/ios基座。(如果是 `安卓` 和 `鸿蒙` 平台,则不用这个方式,可以把整个unibest项目导入到hbx,通过hbx的菜单来运行到对应的平台。)
|
||||
|
||||
## 🔗 发布
|
||||
|
||||
- web平台: `pnpm build:h5`,打包后的文件在 `dist/build/h5`,可以放到web服务器,如nginx运行。如果最终不是放在根目录,可以在 `manifest.config.ts` 文件的 `h5.router.base` 属性进行修改。
|
||||
- weixin平台:`pnpm build:mp`, 打包后的文件在 `dist/build/mp-weixin`,然后通过微信开发者工具导入,并点击右上角的“上传”按钮进行上传。
|
||||
- APP平台:`pnpm build:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/build/app` 文件夹,选择发行 - APP云打包。(如果是 `安卓` 和 `鸿蒙` 平台,则不用这个方式,可以把整个unibest项目导入到hbx,通过hbx的菜单来发行到对应的平台。)
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](https://opensource.org/license/mit/)
|
||||
|
||||
Copyright (c) 2025 菲鸽
|
||||
|
||||
## 捐赠
|
||||
|
||||
<p align='center'>
|
||||
<img alt="special sponsor appwrite" src="https://oss.laf.run/ukw0y1-site/pay/wepay.png" height="330" style="display:inline-block; height:330px;">
|
||||
<img alt="special sponsor appwrite" src="https://oss.laf.run/ukw0y1-site/pay/alipay.jpg" height="330" style="display:inline-block; height:330px; margin-left:10px;">
|
||||
</p>
|
||||
30
env/.env
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
VITE_APP_TITLE = 'unibest'
|
||||
VITE_APP_PORT = 9000
|
||||
|
||||
VITE_UNI_APPID = '__UNI__D1E5001'
|
||||
VITE_WX_APPID = 'wxa2abb91f64032a2b'
|
||||
|
||||
# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base
|
||||
# https://uniapp.dcloud.net.cn/collocation/manifest.html#h5-router
|
||||
# 比如你要部署到 https://unibest.tech/doc/ ,则配置为 /doc/
|
||||
VITE_APP_PUBLIC_BASE=/
|
||||
|
||||
# 后台请求地址
|
||||
VITE_SERVER_BASEURL = 'https://ukw0y1.laf.run'
|
||||
# 备注:如果后台带统一前缀,则也要加到后面,eg: https://ukw0y1.laf.run/api
|
||||
|
||||
# 注意,如果是微信小程序,还有一套请求地址的配置,根据 develop、trial、release 分别设置上传地址,见 `src/utils/index.ts`。
|
||||
|
||||
# h5是否需要配置代理
|
||||
VITE_APP_PROXY_ENABLE = false
|
||||
# 下面的不用修改,只要不跟你后台的统一前缀冲突就行。如果修改了,记得修改 `nginx` 里面的配置
|
||||
VITE_APP_PROXY_PREFIX = '/fg-api'
|
||||
|
||||
# 第二个请求地址 (目前alova中可以使用)
|
||||
VITE_SERVER_BASEURL_SECONDARY = 'https://ukw0y1.laf.run'
|
||||
|
||||
# 认证模式,'single' | 'double' ==> 单token | 双token
|
||||
VITE_AUTH_MODE = 'single'
|
||||
|
||||
# 原生插件资源复制开关,控制是否启用 copy-native-resources 插件
|
||||
VITE_COPY_NATIVE_RES_ENABLE = false
|
||||
9
env/.env.development
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
||||
NODE_ENV = 'development'
|
||||
# 是否去除console 和 debugger
|
||||
VITE_DELETE_CONSOLE = false
|
||||
# 是否开启sourcemap
|
||||
VITE_SHOW_SOURCEMAP = false
|
||||
|
||||
# 后台请求地址
|
||||
# VITE_SERVER_BASEURL = 'https://dev.xxx.com'
|
||||
9
env/.env.production
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
||||
NODE_ENV = 'production'
|
||||
# 是否去除console 和 debugger
|
||||
VITE_DELETE_CONSOLE = true
|
||||
# 是否开启sourcemap
|
||||
VITE_SHOW_SOURCEMAP = false
|
||||
|
||||
# 后台请求地址
|
||||
# VITE_SERVER_BASEURL = 'https://prod.xxx.com'
|
||||
9
env/.env.test
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
||||
NODE_ENV = 'development'
|
||||
# 是否去除console 和 debugger
|
||||
VITE_DELETE_CONSOLE = false
|
||||
# 是否开启sourcemap
|
||||
VITE_SHOW_SOURCEMAP = false
|
||||
|
||||
# 后台请求地址
|
||||
# VITE_SERVER_BASEURL = 'https://test.xxx.com'
|
||||
58
eslint.config.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import uniHelper from '@uni-helper/eslint-config'
|
||||
|
||||
export default uniHelper({
|
||||
unocss: true,
|
||||
vue: true,
|
||||
markdown: false,
|
||||
ignores: [
|
||||
// 忽略uni_modules目录
|
||||
'**/uni_modules/',
|
||||
// 忽略原生插件目录
|
||||
'**/nativeplugins/',
|
||||
'dist',
|
||||
// unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用
|
||||
'auto-import.d.ts',
|
||||
// vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore
|
||||
'uni-pages.d.ts',
|
||||
// 插件生成的文件
|
||||
'src/pages.json',
|
||||
'src/manifest.json',
|
||||
// 忽略自动生成文件
|
||||
'src/service/**',
|
||||
],
|
||||
// https://eslint-config.antfu.me/rules
|
||||
rules: {
|
||||
'no-useless-return': 'off',
|
||||
'no-console': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'vue/no-unused-refs': 'off',
|
||||
'unused-imports/no-unused-vars': 'off',
|
||||
'eslint-comments/no-unlimited-disable': 'off',
|
||||
'jsdoc/check-param-names': 'off',
|
||||
'jsdoc/require-returns-description': 'off',
|
||||
'ts/no-empty-object-type': 'off',
|
||||
'no-extend-native': 'off',
|
||||
'vue/singleline-html-element-content-newline': [
|
||||
'error',
|
||||
{
|
||||
externalIgnores: ['text'],
|
||||
},
|
||||
],
|
||||
// vue SFC 调换顺序改这里
|
||||
'vue/block-order': ['error', {
|
||||
order: [['script', 'template'], 'style'],
|
||||
}],
|
||||
},
|
||||
formatters: {
|
||||
/**
|
||||
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
|
||||
* By default uses Prettier
|
||||
*/
|
||||
css: true,
|
||||
/**
|
||||
* Format HTML files
|
||||
* By default uses Prettier
|
||||
*/
|
||||
html: true,
|
||||
},
|
||||
})
|
||||
BIN
favicon.ico
Normal file
|
After Width: | Height: | Size: 14 KiB |
26
index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html build-time="%BUILD_TIME%">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||
<script>
|
||||
var coverSupport =
|
||||
'CSS' in window &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
(CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') +
|
||||
'" />',
|
||||
)
|
||||
</script>
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
164
manifest.config.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
// manifest.config.ts
|
||||
import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
// 手动解析命令行参数获取 mode
|
||||
function getMode() {
|
||||
const args = process.argv.slice(2)
|
||||
const modeFlagIndex = args.findIndex(arg => arg === '--mode')
|
||||
return modeFlagIndex !== -1 ? args[modeFlagIndex + 1] : args[0] === 'build' ? 'production' : 'development' // 默认 development
|
||||
}
|
||||
// 获取环境变量的范例
|
||||
const env = loadEnv(getMode(), path.resolve(process.cwd(), 'env'))
|
||||
const {
|
||||
VITE_APP_TITLE,
|
||||
VITE_UNI_APPID,
|
||||
VITE_WX_APPID,
|
||||
VITE_APP_PUBLIC_BASE,
|
||||
VITE_FALLBACK_LOCALE,
|
||||
} = env
|
||||
// console.log('manifest.config.ts env:', env)
|
||||
|
||||
export default defineManifestConfig({
|
||||
'name': VITE_APP_TITLE,
|
||||
'appid': VITE_UNI_APPID,
|
||||
'description': '',
|
||||
'versionName': '1.0.0',
|
||||
'versionCode': '100',
|
||||
'transformPx': false,
|
||||
'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
|
||||
'h5': {
|
||||
router: {
|
||||
base: VITE_APP_PUBLIC_BASE,
|
||||
},
|
||||
},
|
||||
/* 5+App特有相关 */
|
||||
'app-plus': {
|
||||
usingComponents: true,
|
||||
nvueStyleCompiler: 'uni-app',
|
||||
compilerVersion: 3,
|
||||
compatible: {
|
||||
ignoreVersion: true,
|
||||
},
|
||||
splashscreen: {
|
||||
alwaysShowBeforeRender: true,
|
||||
waiting: true,
|
||||
autoclose: true,
|
||||
delay: 0,
|
||||
},
|
||||
/* 模块配置 */
|
||||
modules: {},
|
||||
/* 应用发布信息 */
|
||||
distribute: {
|
||||
/* android打包配置 */
|
||||
android: {
|
||||
minSdkVersion: 21,
|
||||
targetSdkVersion: 30,
|
||||
abiFilters: ['armeabi-v7a', 'arm64-v8a'],
|
||||
permissions: [
|
||||
'<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>',
|
||||
'<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>',
|
||||
'<uses-permission android:name="android.permission.VIBRATE"/>',
|
||||
'<uses-permission android:name="android.permission.READ_LOGS"/>',
|
||||
'<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>',
|
||||
'<uses-feature android:name="android.hardware.camera.autofocus"/>',
|
||||
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
|
||||
'<uses-permission android:name="android.permission.CAMERA"/>',
|
||||
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
|
||||
'<uses-permission android:name="android.permission.READ_PHONE_STATE"/>',
|
||||
'<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>',
|
||||
'<uses-permission android:name="android.permission.WAKE_LOCK"/>',
|
||||
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
|
||||
'<uses-feature android:name="android.hardware.camera"/>',
|
||||
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
|
||||
],
|
||||
},
|
||||
/* ios打包配置 */
|
||||
ios: {},
|
||||
/* SDK配置 */
|
||||
sdkConfigs: {},
|
||||
/* 图标配置 */
|
||||
icons: {
|
||||
android: {
|
||||
hdpi: 'static/app/icons/72x72.png',
|
||||
xhdpi: 'static/app/icons/96x96.png',
|
||||
xxhdpi: 'static/app/icons/144x144.png',
|
||||
xxxhdpi: 'static/app/icons/192x192.png',
|
||||
},
|
||||
ios: {
|
||||
appstore: 'static/app/icons/1024x1024.png',
|
||||
ipad: {
|
||||
'app': 'static/app/icons/76x76.png',
|
||||
'app@2x': 'static/app/icons/152x152.png',
|
||||
'notification': 'static/app/icons/20x20.png',
|
||||
'notification@2x': 'static/app/icons/40x40.png',
|
||||
'proapp@2x': 'static/app/icons/167x167.png',
|
||||
'settings': 'static/app/icons/29x29.png',
|
||||
'settings@2x': 'static/app/icons/58x58.png',
|
||||
'spotlight': 'static/app/icons/40x40.png',
|
||||
'spotlight@2x': 'static/app/icons/80x80.png',
|
||||
},
|
||||
iphone: {
|
||||
'app@2x': 'static/app/icons/120x120.png',
|
||||
'app@3x': 'static/app/icons/180x180.png',
|
||||
'notification@2x': 'static/app/icons/40x40.png',
|
||||
'notification@3x': 'static/app/icons/60x60.png',
|
||||
'settings@2x': 'static/app/icons/58x58.png',
|
||||
'settings@3x': 'static/app/icons/87x87.png',
|
||||
'spotlight@2x': 'static/app/icons/80x80.png',
|
||||
'spotlight@3x': 'static/app/icons/120x120.png',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
/* 快应用特有相关 */
|
||||
'quickapp': {},
|
||||
/* 小程序特有相关 */
|
||||
'mp-weixin': {
|
||||
appid: VITE_WX_APPID,
|
||||
setting: {
|
||||
urlCheck: false,
|
||||
// 是否启用 ES6 转 ES5
|
||||
es6: true,
|
||||
minified: true,
|
||||
},
|
||||
optimization: {
|
||||
subPackages: true,
|
||||
},
|
||||
// 是否合并组件虚拟节点外层属性,uni-app 3.5.1+ 开始支持。目前仅支持 style、class 属性。
|
||||
// 默认不开启(undefined),这里设置为开启。
|
||||
mergeVirtualHostAttributes: true,
|
||||
// styleIsolation: 'shared',
|
||||
usingComponents: true,
|
||||
// __usePrivacyCheck__: true,
|
||||
},
|
||||
'mp-alipay': {
|
||||
usingComponents: true,
|
||||
styleIsolation: 'shared',
|
||||
optimization: {
|
||||
subPackages: true,
|
||||
},
|
||||
// 解决支付宝小程序开发工具报错 【globalThis is not defined】
|
||||
compileOptions: {
|
||||
globalObjectMode: 'enable',
|
||||
transpile: {
|
||||
script: {
|
||||
ignore: ['node_modules/**'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'mp-baidu': {
|
||||
usingComponents: true,
|
||||
},
|
||||
'mp-toutiao': {
|
||||
usingComponents: true,
|
||||
},
|
||||
'uniStatistics': {
|
||||
enable: false,
|
||||
},
|
||||
'vueVersion': '3',
|
||||
})
|
||||
14
openapi-ts-request.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'openapi-ts-request'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
describe: 'unibest-openapi-test',
|
||||
schemaPath: 'https://ukw0y1.laf.run/unibest-opapi-test.json',
|
||||
serversPath: './src/service',
|
||||
requestLibPath: `import request from '@/http/vue-query';\n import { CustomRequestOptions_ } from '@/http/types';`,
|
||||
requestOptionsType: 'CustomRequestOptions_',
|
||||
isGenReactQuery: false,
|
||||
reactQueryMode: 'vue',
|
||||
isGenJavaScript: false,
|
||||
},
|
||||
])
|
||||
196
package.json
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"name": "shop-toy",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"unibest-version": "4.1.0",
|
||||
"unibest-update-time": "2025-11-07",
|
||||
"packageManager": "pnpm@10.10.0",
|
||||
"description": "unibest - 最好的 uniapp 开发模板",
|
||||
"generate-time": "用户创建项目时生成",
|
||||
"author": {
|
||||
"name": "feige996",
|
||||
"zhName": "菲鸽",
|
||||
"email": "1020103647@qq.com",
|
||||
"github": "https://github.com/feige996",
|
||||
"gitee": "https://gitee.com/feige996"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://unibest.tech",
|
||||
"repository": "https://github.com/feige996/unibest",
|
||||
"bugs": {
|
||||
"url": "https://github.com/feige996/unibest/issues",
|
||||
"url-old": "https://github.com/codercup/unibest/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": ">=9"
|
||||
},
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"uvm": "npx @dcloudio/uvm@latest",
|
||||
"uvm-rm": "node ./scripts/postupgrade.js",
|
||||
"postuvm": "echo upgrade uni-app success!",
|
||||
"dev:app": "uni -p app",
|
||||
"dev:app:test": "uni -p app --mode test",
|
||||
"dev:app:prod": "uni -p app --mode production",
|
||||
"dev:app-android": "uni -p app-android",
|
||||
"dev:app-ios": "uni -p app-ios",
|
||||
"dev:custom": "uni -p",
|
||||
"predev": "pnpm init-baseFiles",
|
||||
"dev": "uni",
|
||||
"dev:test": "uni --mode test",
|
||||
"dev:prod": "uni --mode production",
|
||||
"dev:h5": "uni",
|
||||
"dev:h5:test": "uni --mode test",
|
||||
"dev:h5:prod": "uni --mode production",
|
||||
"dev:h5:ssr": "uni --ssr",
|
||||
"dev:mp": "uni -p mp-weixin",
|
||||
"dev:mp:test": "uni -p mp-weixin --mode test",
|
||||
"dev:mp:prod": "uni -p mp-weixin --mode production",
|
||||
"dev:mp-alipay": "uni -p mp-alipay",
|
||||
"dev:mp-baidu": "uni -p mp-baidu",
|
||||
"dev:mp-jd": "uni -p mp-jd",
|
||||
"dev:mp-kuaishou": "uni -p mp-kuaishou",
|
||||
"dev:mp-lark": "uni -p mp-lark",
|
||||
"dev:mp-qq": "uni -p mp-qq",
|
||||
"dev:mp-toutiao": "uni -p mp-toutiao",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"dev:mp-xhs": "uni -p mp-xhs",
|
||||
"dev:quickapp-webview": "uni -p quickapp-webview",
|
||||
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
|
||||
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
|
||||
"build:app": "uni build -p app",
|
||||
"build:app:test": "uni build -p app --mode test",
|
||||
"build:app:prod": "uni build -p app --mode production",
|
||||
"build:app-android": "uni build -p app-android",
|
||||
"build:app-ios": "uni build -p app-ios",
|
||||
"build:custom": "uni build -p",
|
||||
"build:h5": "uni build",
|
||||
"build:h5:test": "uni build --mode test",
|
||||
"build:h5:prod": "uni build --mode production",
|
||||
"build": "uni build",
|
||||
"build:test": "uni build --mode test",
|
||||
"build:prod": "uni build --mode production",
|
||||
"build:h5:ssr": "uni build --ssr",
|
||||
"build:mp-alipay": "uni build -p mp-alipay",
|
||||
"build:mp": "uni build -p mp-weixin",
|
||||
"build:mp:test": "uni build -p mp-weixin --mode test",
|
||||
"build:mp:prod": "uni build -p mp-weixin --mode production",
|
||||
"build:mp-baidu": "uni build -p mp-baidu",
|
||||
"build:mp-jd": "uni build -p mp-jd",
|
||||
"build:mp-kuaishou": "uni build -p mp-kuaishou",
|
||||
"build:mp-lark": "uni build -p mp-lark",
|
||||
"build:mp-qq": "uni build -p mp-qq",
|
||||
"build:mp-toutiao": "uni build -p mp-toutiao",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"build:mp-xhs": "uni build -p mp-xhs",
|
||||
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"openapi": "openapi-ts",
|
||||
"init-husky": "git init && husky",
|
||||
"init-baseFiles": "node ./scripts/create-base-files.js",
|
||||
"init-json": "pnpm init-baseFiles",
|
||||
"prepare": "pnpm init-husky & pnpm init-baseFiles",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alova/adapter-uniapp": "^2.0.14",
|
||||
"@alova/shared": "^1.3.1",
|
||||
"@dcloudio/uni-app": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-app-harmony": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-components": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-alipay": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-baidu": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-harmony": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-jd": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-kuaishou": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-lark": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-qq": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-toutiao": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"alova": "^3.3.3",
|
||||
"dayjs": "1.11.10",
|
||||
"js-cookie": "^3.0.5",
|
||||
"pinia": "2.0.36",
|
||||
"pinia-plugin-persistedstate": "3.2.1",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "9.1.9",
|
||||
"vue-router": "4.5.1",
|
||||
"z-paging": "2.8.7",
|
||||
"wot-design-uni": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4070620250821001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4070620250821001",
|
||||
"@esbuild/darwin-arm64": "0.20.2",
|
||||
"@esbuild/darwin-x64": "0.20.2",
|
||||
"@iconify-json/carbon": "^1.2.4",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@rollup/rollup-darwin-x64": "^4.28.0",
|
||||
"@types/node": "^20.17.9",
|
||||
"@uni-helper/eslint-config": "0.5.0",
|
||||
"@uni-helper/plugin-uni": "0.1.0",
|
||||
"@uni-helper/uni-env": "0.1.8",
|
||||
"@uni-helper/uni-types": "1.0.0-alpha.6",
|
||||
"@uni-helper/unocss-preset-uni": "0.2.11",
|
||||
"@uni-helper/vite-plugin-uni-components": "0.2.3",
|
||||
"@uni-helper/vite-plugin-uni-layouts": "0.1.11",
|
||||
"@uni-helper/vite-plugin-uni-manifest": "0.2.8",
|
||||
"@uni-helper/vite-plugin-uni-pages": "0.3.19",
|
||||
"@uni-helper/vite-plugin-uni-platform": "0.0.5",
|
||||
"@uni-ku/bundle-optimizer": "v1.3.15-beta.2",
|
||||
"@uni-ku/root": "1.4.1",
|
||||
"@unocss/eslint-plugin": "^66.2.3",
|
||||
"@unocss/preset-legacy-compat": "66.0.0",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^10.0.0",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-plugin-format": "^1.0.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.10",
|
||||
"miniprogram-api-typings": "^4.1.0",
|
||||
"openapi-ts-request": "^1.10.0",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-html": "^1.8.0",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"rollup-plugin-visualizer": "^6.0.3",
|
||||
"sass": "1.77.8",
|
||||
"std-env": "^3.9.0",
|
||||
"typescript": "~5.8.0",
|
||||
"unocss": "66.0.0",
|
||||
"unplugin-auto-import": "^20.0.0",
|
||||
"vite": "5.2.8",
|
||||
"vite-plugin-restart": "^1.0.0",
|
||||
"vue-tsc": "^3.0.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"unconfig": "7.3.2"
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"unconfig": "7.3.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"bin-wrapper": "npm:bin-wrapper-china",
|
||||
"unconfig": "7.3.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "eslint --fix"
|
||||
}
|
||||
}
|
||||
23
pages.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
|
||||
import { tabBar } from './src/tabbar/config'
|
||||
|
||||
export default defineUniPages({
|
||||
globalStyle: {
|
||||
navigationStyle: 'default',
|
||||
navigationBarTitleText: 'unibest',
|
||||
navigationBarBackgroundColor: '#f8f8f8',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
easycom: {
|
||||
autoscan: true,
|
||||
custom: {
|
||||
'^fg-(.*)': '@/components/fg-$1/fg-$1.vue',
|
||||
'^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
|
||||
'z-paging/components/z-paging$1/z-paging$1.vue',
|
||||
'^wd-(.*)': 'wot-design-uni/components/wd-$1/wd-$1.vue',
|
||||
},
|
||||
},
|
||||
// tabbar 的配置统一在 “./src/tabbar/config.ts” 文件中
|
||||
tabBar: tabBar as any,
|
||||
})
|
||||
13972
pnpm-lock.yaml
generated
Normal file
53
scripts/create-base-files.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// 基础配置文件生成脚本
|
||||
// 此脚本用于生成 src/manifest.json 和 src/pages.json 基础文件
|
||||
// 由于这两个配置文件会被添加到 .gitignore 中,因此需要通过此脚本确保项目能正常运行
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// 获取当前文件的目录路径(替代 CommonJS 中的 __dirname)
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// 最简可运行配置
|
||||
const manifest = { }
|
||||
const pages = {
|
||||
pages: [
|
||||
{
|
||||
path: 'pages/index/index',
|
||||
type: 'home',
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '首页',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'pages/me/me',
|
||||
type: 'page',
|
||||
style: {
|
||||
navigationBarTitleText: '我的',
|
||||
},
|
||||
},
|
||||
],
|
||||
subPackages: [],
|
||||
}
|
||||
|
||||
// 使用修复后的 __dirname 来解析文件路径
|
||||
const manifestPath = path.resolve(__dirname, '../src/manifest.json')
|
||||
const pagesPath = path.resolve(__dirname, '../src/pages.json')
|
||||
|
||||
// 确保 src 目录存在
|
||||
const srcDir = path.resolve(__dirname, '../src')
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
fs.mkdirSync(srcDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 如果 src/manifest.json 不存在,就创建它;存在就不处理,以免覆盖
|
||||
if (!fs.existsSync(manifestPath) || fs.statSync(manifestPath).size === 0) {
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
||||
}
|
||||
|
||||
// 如果 src/pages.json 不存在,就创建它;存在就不处理,以免覆盖
|
||||
if (!fs.existsSync(pagesPath) || fs.statSync(pagesPath).size === 0) {
|
||||
fs.writeFileSync(pagesPath, JSON.stringify(pages, null, 2))
|
||||
}
|
||||
83
scripts/open-dev-tools.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { exec } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
/**
|
||||
* 打开开发者工具
|
||||
*/
|
||||
function _openDevTools() {
|
||||
const platform = process.platform // darwin, win32, linux
|
||||
const { UNI_PLATFORM } = process.env // mp-weixin, mp-alipay
|
||||
|
||||
const uniPlatformText = UNI_PLATFORM === 'mp-weixin' ? '微信小程序' : UNI_PLATFORM === 'mp-alipay' ? '支付宝小程序' : '小程序'
|
||||
|
||||
// 项目路径(构建输出目录)
|
||||
const projectPath = path.resolve(process.cwd(), `dist/dev/${UNI_PLATFORM}`)
|
||||
|
||||
// 检查构建输出目录是否存在
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
console.log(`❌ ${uniPlatformText}构建目录不存在:`, projectPath)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`🚀 正在打开${uniPlatformText}开发者工具...`)
|
||||
|
||||
// 根据不同操作系统执行不同命令
|
||||
let command = ''
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS
|
||||
if (UNI_PLATFORM === 'mp-weixin') {
|
||||
command = `/Applications/wechatwebdevtools.app/Contents/MacOS/cli -o "${projectPath}"`
|
||||
}
|
||||
else if (UNI_PLATFORM === 'mp-alipay') {
|
||||
command = `/Applications/小程序开发者工具.app/Contents/MacOS/小程序开发者工具 --p "${projectPath}"`
|
||||
}
|
||||
}
|
||||
else if (platform === 'win32' || platform === 'win64') {
|
||||
// Windows
|
||||
if (UNI_PLATFORM === 'mp-weixin') {
|
||||
command = `"C:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat" -o "${projectPath}"`
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Linux 或其他系统
|
||||
console.log('❌ 当前系统不支持自动打开微信开发者工具')
|
||||
return
|
||||
}
|
||||
|
||||
exec(command, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.log(`❌ 打开${uniPlatformText}开发者工具失败:`, error.message)
|
||||
console.log(`💡 请确保${uniPlatformText}开发者工具服务端口已启用`)
|
||||
console.log(`💡 可以手动打开${uniPlatformText}开发者工具并导入项目:`, projectPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.log('⚠️ 警告:', stderr)
|
||||
}
|
||||
|
||||
console.log(`✅ ${uniPlatformText}开发者工具已打开`)
|
||||
|
||||
if (stdout) {
|
||||
console.log(stdout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default function openDevTools() {
|
||||
// 首次构建标记
|
||||
let isFirstBuild = true
|
||||
|
||||
return {
|
||||
name: 'uni-devtools',
|
||||
writeBundle() {
|
||||
if (isFirstBuild && process.env.UNI_PLATFORM?.includes('mp')) {
|
||||
isFirstBuild = false
|
||||
_openDevTools()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
95
scripts/postupgrade.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// # 执行 `pnpm upgrade` 后会升级 `uniapp` 相关依赖
|
||||
// # 在升级完后,会自动添加很多无用依赖,这需要删除以减小依赖包体积
|
||||
// # 只需要执行下面的命令即可
|
||||
|
||||
import { exec } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
// 日志控制开关,设置为 true 可以启用所有日志输出
|
||||
const FG_LOG_ENABLE = true
|
||||
|
||||
// 将 exec 转换为返回 Promise 的函数
|
||||
const execPromise = promisify(exec)
|
||||
|
||||
// 定义要执行的命令
|
||||
const dependencies = [
|
||||
// TODO: 如果不需要某个平台的小程序,请手动删除或注释掉
|
||||
'@dcloudio/uni-mp-baidu',
|
||||
'@dcloudio/uni-mp-jd',
|
||||
'@dcloudio/uni-mp-kuaishou',
|
||||
'@dcloudio/uni-mp-qq',
|
||||
'@dcloudio/uni-mp-xhs',
|
||||
'@dcloudio/uni-quickapp-webview',
|
||||
]
|
||||
|
||||
/**
|
||||
* 带开关的日志输出函数
|
||||
* @param {string} message 日志消息
|
||||
* @param {string} type 日志类型 (log, error)
|
||||
*/
|
||||
function log(message, type = 'log') {
|
||||
if (FG_LOG_ENABLE) {
|
||||
if (type === 'error') {
|
||||
console.error(message)
|
||||
}
|
||||
else {
|
||||
console.log(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载单个依赖包
|
||||
* @param {string} dep 依赖包名
|
||||
* @returns {Promise<boolean>} 是否成功卸载
|
||||
*/
|
||||
async function uninstallDependency(dep) {
|
||||
try {
|
||||
log(`开始卸载依赖: ${dep}`)
|
||||
const { stdout, stderr } = await execPromise(`pnpm un ${dep}`)
|
||||
if (stdout) {
|
||||
log(`stdout [${dep}]: ${stdout}`)
|
||||
}
|
||||
if (stderr) {
|
||||
log(`stderr [${dep}]: ${stderr}`, 'error')
|
||||
}
|
||||
log(`成功卸载依赖: ${dep}`)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
// 单个依赖卸载失败不影响其他依赖
|
||||
log(`卸载依赖 ${dep} 失败: ${error.message}`, 'error')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 串行卸载所有依赖包
|
||||
*/
|
||||
async function uninstallAllDependencies() {
|
||||
log(`开始串行卸载 ${dependencies.length} 个依赖包...`)
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
// 串行执行所有卸载命令
|
||||
for (const dep of dependencies) {
|
||||
const success = await uninstallDependency(dep)
|
||||
if (success) {
|
||||
successCount++
|
||||
}
|
||||
else {
|
||||
failedCount++
|
||||
}
|
||||
|
||||
// 为了避免命令执行过快导致的问题,添加短暂延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
log(`卸载操作完成: 成功 ${successCount} 个, 失败 ${failedCount} 个`)
|
||||
}
|
||||
|
||||
// 执行串行卸载
|
||||
uninstallAllDependencies().catch((err) => {
|
||||
log(`串行卸载过程中出现未捕获的错误: ${err}`, 'error')
|
||||
})
|
||||
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 }
|
||||