miaofuhao 3 weeks ago
parent
commit
b532952929
100 changed files with 18928 additions and 0 deletions
  1. 3 0
      .commitlintrc.cjs
  2. 51 0
      .cursor/rules/api-http-patterns.mdc
  3. 43 0
      .cursor/rules/development-workflow.mdc
  4. 36 0
      .cursor/rules/project-overview.mdc
  5. 54 0
      .cursor/rules/styling-css-patterns.mdc
  6. 62 0
      .cursor/rules/uni-app-patterns.mdc
  7. 53 0
      .cursor/rules/vue-typescript-patterns.mdc
  8. 13 0
      .editorconfig
  9. 48 0
      .gitignore
  10. 1 0
      .husky/commit-msg
  11. 1 0
      .husky/pre-commit
  12. 9 0
      .npmrc
  13. 9 0
      .prettierrc
  14. 122 0
      .trae/rules/project_rules.md
  15. 15 0
      .vscode/extensions.json
  16. 100 0
      .vscode/settings.json
  17. 77 0
      .vscode/vue3.code-snippets
  18. 21 0
      LICENSE
  19. 98 0
      README.md
  20. 3 0
      codes/README.md
  21. 31 0
      codes/docker/.dockerignore
  22. 38 0
      codes/docker/Dockerfile
  23. 28 0
      codes/docker/docker.md
  24. 145 0
      codes/docker/nginx.conf
  25. 30 0
      env/.env
  26. 9 0
      env/.env.development
  27. 9 0
      env/.env.production
  28. 9 0
      env/.env.test
  29. 58 0
      eslint.config.mjs
  30. BIN
      favicon.ico
  31. 26 0
      index.html
  32. 164 0
      manifest.config.ts
  33. 14 0
      openapi-ts-request.config.ts
  34. 200 0
      package.json
  35. 23 0
      pages.config.ts
  36. 14286 0
      pnpm-lock.yaml
  37. 53 0
      scripts/create-base-files.js
  38. 83 0
      scripts/open-dev-tools.js
  39. 95 0
      scripts/postupgrade.js
  40. 41 0
      src/App.ku.vue
  41. 56 0
      src/App.vue
  42. 28 0
      src/api/auth.ts
  43. 17 0
      src/api/foo-alova.ts
  44. 43 0
      src/api/foo.ts
  45. 117 0
      src/api/login.ts
  46. 105 0
      src/api/types/login.ts
  47. 0 0
      src/components/.gitkeep
  48. 35 0
      src/env.d.ts
  49. 54 0
      src/hooks/useRequest.ts
  50. 116 0
      src/hooks/useScroll.md
  51. 74 0
      src/hooks/useScroll.ts
  52. 171 0
      src/hooks/useUpload.ts
  53. 13 0
      src/http/README.md
  54. 119 0
      src/http/alova.ts
  55. 199 0
      src/http/http.ts
  56. 69 0
      src/http/interceptor.ts
  57. 68 0
      src/http/tools/enum.ts
  58. 29 0
      src/http/tools/queryString.ts
  59. 44 0
      src/http/types.ts
  60. 30 0
      src/http/vue-query.ts
  61. 15 0
      src/layouts/default.vue
  62. 12 0
      src/locale/README.md
  63. 10 0
      src/locale/en.json
  64. 87 0
      src/locale/index.ts
  65. 10 0
      src/locale/zh-Hans.json
  66. 21 0
      src/main.ts
  67. 3 0
      src/pages-fg/404/README.md
  68. 30 0
      src/pages-fg/404/index.vue
  69. 3 0
      src/pages-fg/REAME.md
  70. 20 0
      src/pages-fg/login/README.md
  71. 371 0
      src/pages-fg/login/login.vue
  72. 34 0
      src/pages-fg/login/register.vue
  73. 166 0
      src/pages/index/index.vue
  74. 275 0
      src/pages/me/me.vue
  75. 55 0
      src/router/README.md
  76. 31 0
      src/router/config.ts
  77. 170 0
      src/router/interceptor.ts
  78. 6 0
      src/service/index.ts
  79. 14 0
      src/service/info.ts
  80. 18 0
      src/service/listAll.ts
  81. 29 0
      src/service/types.ts
  82. BIN
      src/static/app/icons/1024x1024.png
  83. BIN
      src/static/app/icons/120x120.png
  84. BIN
      src/static/app/icons/144x144.png
  85. BIN
      src/static/app/icons/152x152.png
  86. BIN
      src/static/app/icons/167x167.png
  87. BIN
      src/static/app/icons/180x180.png
  88. BIN
      src/static/app/icons/192x192.png
  89. BIN
      src/static/app/icons/20x20.png
  90. BIN
      src/static/app/icons/29x29.png
  91. BIN
      src/static/app/icons/40x40.png
  92. BIN
      src/static/app/icons/58x58.png
  93. BIN
      src/static/app/icons/60x60.png
  94. BIN
      src/static/app/icons/72x72.png
  95. BIN
      src/static/app/icons/76x76.png
  96. BIN
      src/static/app/icons/80x80.png
  97. BIN
      src/static/app/icons/87x87.png
  98. BIN
      src/static/app/icons/96x96.png
  99. BIN
      src/static/images/avatar.jpg
  100. 0 0
      src/static/images/default-avatar.png

+ 3 - 0
.commitlintrc.cjs

@@ -0,0 +1,3 @@
1
+module.exports = {
2
+  extends: ['@commitlint/config-conventional'],
3
+}

+ 51 - 0
.cursor/rules/api-http-patterns.mdc

@@ -0,0 +1,51 @@
1
+# API 和 HTTP 请求规范
2
+
3
+## HTTP 请求封装
4
+- 可以使用 `简单http` 或者 `alova` 或者 `@tanstack/vue-query` 进行请求管理
5
+- HTTP 配置在 [src/http/](mdc:src/http/) 目录下
6
+- `简单http` - [src/http/http.ts](mdc:src/http/http.ts)
7
+- `alova` - [src/http/alova.ts](mdc:src/http/alova.ts)
8
+- `vue-query` - [src/http/vue-query.ts](mdc:src/http/vue-query.ts)
9
+- 请求拦截器在 [src/http/interceptor.ts](mdc:src/http/interceptor.ts)
10
+- 支持请求重试、缓存、错误处理
11
+
12
+## API 接口规范
13
+- API 接口定义在 [src/api/](mdc:src/api/) 目录下
14
+- 按功能模块组织 API 文件
15
+- 使用 TypeScript 定义请求和响应类型
16
+- 支持 `简单http`、`alova` 和 `vue-query` 三种请求方式
17
+
18
+
19
+## 示例代码结构
20
+```typescript
21
+// API 接口定义
22
+export interface LoginParams {
23
+  username: string
24
+  password: string
25
+}
26
+
27
+export interface LoginResponse {
28
+  token: string
29
+  userInfo: UserInfo
30
+}
31
+
32
+// alova 方式
33
+export const login = (params: LoginParams) => 
34
+  http.Post<LoginResponse>('/api/login', params)
35
+
36
+// vue-query 方式
37
+export const useLogin = () => {
38
+  return useMutation({
39
+    mutationFn: (params: LoginParams) => 
40
+      http.post<LoginResponse>('/api/login', params)
41
+  })
42
+}
43
+```
44
+
45
+## 错误处理
46
+- 统一错误处理在拦截器中配置
47
+- 支持网络错误、业务错误、认证错误等
48
+- 自动处理 token 过期和刷新
49
+---
50
+globs: src/api/*.ts,src/http/*.ts
51
+---

+ 43 - 0
.cursor/rules/development-workflow.mdc

@@ -0,0 +1,43 @@
1
+# 开发工作流程
2
+
3
+## 项目启动
4
+1. 安装依赖:`pnpm install`
5
+2. 开发环境:
6
+   - H5: `pnpm dev` 或 `pnpm dev:h5`
7
+   - 微信小程序: `pnpm dev:mp`
8
+   - 支付宝小程序: `pnpm dev:mp-alipay`
9
+   - APP: `pnpm dev:app`
10
+
11
+## 代码规范
12
+- 使用 ESLint 进行代码检查:`pnpm lint`
13
+- 自动修复代码格式:`pnpm lint:fix`
14
+- 使用 eslint 格式化代码
15
+- 遵循 TypeScript 严格模式
16
+
17
+## 构建和部署
18
+- H5 构建:`pnpm build:h5`
19
+- 微信小程序构建:`pnpm build:mp`
20
+- 支付宝小程序构建:`pnpm build:mp-alipay`
21
+- APP 构建:`pnpm build:app`
22
+- 类型检查:`pnpm type-check`
23
+
24
+## 开发工具
25
+- 推荐使用 VSCode 编辑器
26
+- 安装 Vue 和 TypeScript 相关插件
27
+- 使用 uni-app 开发者工具调试小程序
28
+- 使用 HBuilderX 调试 APP
29
+
30
+## 调试技巧
31
+- 使用 console.log 和 uni.showToast 调试
32
+- 利用 Vue DevTools 调试组件状态
33
+- 使用网络面板调试 API 请求
34
+- 平台差异测试和兼容性检查
35
+
36
+## 性能优化
37
+- 使用懒加载和代码分割
38
+- 优化图片和静态资源
39
+- 减少不必要的重渲染
40
+- 合理使用缓存策略
41
+---
42
+description: 开发工作流程和最佳实践指南
43
+---

+ 36 - 0
.cursor/rules/project-overview.mdc

@@ -0,0 +1,36 @@
1
+---
2
+alwaysApply: true
3
+---
4
+# unibest 项目概览
5
+
6
+这是一个基于 uniapp + Vue3 + TypeScript + Vite5 + UnoCSS 的跨平台开发框架。
7
+
8
+## 项目特点
9
+- 支持 H5、小程序、APP 多平台开发
10
+- 使用最新的前端技术栈
11
+- 内置约定式路由、layout布局、请求封装、登录拦截、自定义tabbar等功能
12
+- 无需依赖 HBuilderX,支持命令行开发
13
+
14
+## 核心配置文件
15
+- [package.json](mdc:package.json) - 项目依赖和脚本配置
16
+- [vite.config.ts](mdc:vite.config.ts) - Vite 构建配置
17
+- [pages.config.ts](mdc:pages.config.ts) - 页面路由配置
18
+- [manifest.config.ts](mdc:manifest.config.ts) - 应用清单配置
19
+- [uno.config.ts](mdc:uno.config.ts) - UnoCSS 配置
20
+
21
+## 主要目录结构
22
+- `src/pages/` - 页面文件
23
+- `src/components/` - 组件文件
24
+- `src/layouts/` - 布局文件
25
+- `src/api/` - API 接口
26
+- `src/http/` - HTTP 请求封装
27
+- `src/store/` - 状态管理
28
+- `src/tabbar/` - 底部导航栏
29
+- `src/App.ku.vue` - 全局根组件(类似 App.vue 里面的 template作用)
30
+
31
+## 开发命令
32
+- `pnpm dev` - 开发 H5 版本
33
+- `pnpm dev:mp` - 开发微信小程序
34
+- `pnpm dev:mp-alipay` - 开发支付宝小程序(含钉钉)
35
+- `pnpm dev:app` - 开发 APP 版本
36
+- `pnpm build` - 构建生产版本

+ 54 - 0
.cursor/rules/styling-css-patterns.mdc

@@ -0,0 +1,54 @@
1
+# 样式和 CSS 开发规范
2
+
3
+## UnoCSS 原子化 CSS
4
+- 项目使用 UnoCSS 作为原子化 CSS 框架
5
+- 配置在 [uno.config.ts](mdc:uno.config.ts)
6
+- 支持预设和自定义规则
7
+- 优先使用原子化类名,减少自定义 CSS
8
+
9
+## SCSS 规范
10
+- 使用 SCSS 预处理器
11
+- 样式文件使用 `lang="scss"` 和 `scoped` 属性
12
+- 遵循 BEM 命名规范
13
+- 使用变量和混入提高复用性
14
+
15
+## 样式组织
16
+- 全局样式在 [src/style/](mdc:src/style/) 目录下
17
+- 组件样式使用 scoped 作用域
18
+- 图标字体在 [src/style/iconfont.css](mdc:src/style/iconfont.css)
19
+- 主题变量在 [src/uni_modules/uni-scss/](mdc:src/uni_modules/uni-scss/) 目录下
20
+
21
+## 示例代码结构
22
+```vue
23
+<template>
24
+  <view class="container flex flex-col items-center p-4">
25
+    <text class="title text-lg font-bold mb-2">标题</text>
26
+    <view class="content bg-gray-100 rounded-lg p-3">
27
+      <!-- 内容 -->
28
+    </view>
29
+  </view>
30
+</template>
31
+
32
+<style lang="scss" scoped>
33
+.container {
34
+  min-height: 100vh;
35
+  
36
+  .title {
37
+    color: var(--primary-color);
38
+  }
39
+  
40
+  .content {
41
+    width: 100%;
42
+    max-width: 600rpx;
43
+  }
44
+}
45
+</style>
46
+
47
+## 响应式设计
48
+- 使用 rpx 单位适配不同屏幕
49
+- 支持横屏和竖屏布局
50
+- 使用 flexbox 和 grid 布局
51
+- 考虑不同平台的样式差异
52
+---
53
+globs: *.vue,*.scss,*.css
54
+---

+ 62 - 0
.cursor/rules/uni-app-patterns.mdc

@@ -0,0 +1,62 @@
1
+# uni-app 开发规范
2
+
3
+## 页面开发
4
+- 页面文件放在 [src/pages/](mdc:src/pages/) 目录下
5
+- 使用约定式路由,文件名即路由路径
6
+- 页面配置在仅需要在 宏`definePage` 中配置标题等内容即可,会自动生成到 `pages.json` 中
7
+
8
+## 组件开发
9
+- 组件文件放在 [src/components/](mdc:src/components/) 或者 [src/pages/xx/components/](mdc:src/pages/xx/components/) 目录下
10
+- 使用 uni-app 内置组件和第三方组件库
11
+- 支持 wot-ui\uview-pro\uv-ui\sard-ui\uview-plus 等多种第三方组件库 和 z-paging 组件
12
+- 自定义组件遵循 uni-app 组件规范
13
+
14
+## 平台适配
15
+- 使用条件编译处理平台差异
16
+- 支持 H5、小程序、APP 多平台
17
+- 注意各平台的 API 差异
18
+- 使用 uni.xxx API 替代原生 API
19
+
20
+## 示例代码结构
21
+```vue
22
+<script setup lang="ts">
23
+// #ifdef H5
24
+import { h5Api } from '@/utils/h5'
25
+// #endif
26
+
27
+// #ifdef MP-WEIXIN
28
+import { mpApi } from '@/utils/mp'
29
+// #endif
30
+
31
+const handleClick = () => {
32
+  // #ifdef H5
33
+  h5Api.showToast('H5 平台')
34
+  // #endif
35
+  
36
+  // #ifdef MP-WEIXIN
37
+  mpApi.showToast('微信小程序')
38
+  // #endif
39
+}
40
+</script>
41
+
42
+<template>
43
+  <view class="page">
44
+    <!-- uni-app 组件 -->
45
+    <button @click="handleClick">点击</button>
46
+    
47
+    <!-- 条件渲染 -->
48
+    <!-- #ifdef H5 -->
49
+    <view>H5 特有内容</view>
50
+    <!-- #endif -->
51
+  </view>
52
+</template>
53
+```
54
+
55
+## 生命周期
56
+- 使用 uni-app 页面生命周期
57
+- onLoad、onShow、onReady、onHide、onUnload
58
+- 组件生命周期遵循 Vue3 规范
59
+- 注意页面栈和导航管理
60
+---
61
+globs: src/pages/*.vue,src/components/*.vue
62
+---

+ 53 - 0
.cursor/rules/vue-typescript-patterns.mdc

@@ -0,0 +1,53 @@
1
+# Vue3 + TypeScript 开发规范
2
+
3
+## Vue 组件规范
4
+- 使用 Composition API 和 `<script setup>` 语法
5
+- 组件文件使用 PascalCase 命名
6
+- 页面文件放在 `src/pages/` 目录下
7
+- 全局组件文件放在 `src/components/` 目录下
8
+- 局部组件文件放在页面的 `/components/` 目录下
9
+
10
+## Vue SFC 组件规范
11
+- `<script setup lang="ts">` 标签必须是第一个子元素
12
+- `<template>` 标签必须是第二个子元素
13
+- `<style scoped>` 标签必须是最后一个子元素(因为推荐使用原子化类名,所以很可能没有)
14
+
15
+## TypeScript 规范
16
+- 严格使用 TypeScript,避免使用 `any` 类型
17
+- 为 API 响应数据定义接口类型
18
+- 使用 `interface` 定义对象类型,`type` 定义联合类型
19
+- 导入类型时使用 `import type` 语法
20
+
21
+## 状态管理
22
+- 使用 Pinia 进行状态管理
23
+- Store 文件放在 `src/store/` 目录下
24
+- 使用 `defineStore` 定义 store
25
+- 支持持久化存储
26
+
27
+## 示例代码结构
28
+```vue
29
+<script setup lang="ts">
30
+import { ref, onMounted } from 'vue'
31
+import type { UserInfo } from '@/types/user'
32
+
33
+const userInfo = ref<UserInfo | null>(null)
34
+
35
+onMounted(() => {
36
+  // 初始化逻辑
37
+})
38
+</script>
39
+
40
+<template>
41
+  <view class="container">
42
+    <!-- 模板内容 -->
43
+  </view>
44
+</template>
45
+
46
+<style lang="scss" scoped>
47
+.container {
48
+  // 样式
49
+}
50
+</style>
51
+---
52
+globs: *.vue,*.ts,*.tsx
53
+---

+ 13 - 0
.editorconfig

@@ -0,0 +1,13 @@
1
+root = true
2
+
3
+[*] # 表示所有文件适用
4
+charset = utf-8 # 设置文件字符集为 utf-8
5
+indent_style = space # 缩进风格(tab | space)
6
+indent_size = 2 # 缩进大小
7
+end_of_line = lf # 控制换行类型(lf | cr | crlf)
8
+trim_trailing_whitespace = true # 去除行首的任意空白字符
9
+insert_final_newline = true # 始终在文件末尾插入一个新行
10
+
11
+[*.md] # 表示仅 md 文件适用以下规则
12
+max_line_length = off # 关闭最大行长度限制
13
+trim_trailing_whitespace = false # 关闭末尾空格修剪

+ 48 - 0
.gitignore

@@ -0,0 +1,48 @@
1
+# Logs
2
+logs
3
+*.log
4
+npm-debug.log*
5
+yarn-debug.log*
6
+yarn-error.log*
7
+pnpm-debug.log*
8
+lerna-debug.log*
9
+
10
+node_modules
11
+.DS_Store
12
+dist
13
+*.local
14
+
15
+# Editor directories and files
16
+.idea
17
+*.suo
18
+*.ntvs*
19
+*.njsproj
20
+*.sln
21
+*.sw?
22
+.hbuilderx
23
+
24
+.stylelintcache
25
+.eslintcache
26
+
27
+docs/.vitepress/dist
28
+docs/.vitepress/cache
29
+
30
+src/types
31
+# 单独把这个文件排除掉,用以解决部分电脑生成的 auto-import.d.ts 的API不完整导致类型提示报错问题
32
+!src/types/auto-import.d.ts
33
+src/manifest.json
34
+src/pages.json
35
+
36
+# 2025-10-15 by 菲鸽: lock 文件还是需要加入版本管理,今天又遇到版本不一致导致无法运行的问题了。
37
+# pnpm-lock.yaml
38
+# package-lock.json
39
+
40
+# TIPS:如果某些文件已经加入了版本管理,现在重新加入 .gitignore 是不生效的,需要执行下面的操作
41
+# `git rm -r --cached .` 然后提交 commit 即可。
42
+
43
+# git rm -r --cached file1 file2  ## 针对某些文件
44
+# git rm -r --cached dir1 dir2  ## 针对某些文件夹
45
+# git rm -r --cached .  ## 针对所有文件
46
+
47
+# 更新 uni-app 官方版本
48
+# npx @dcloudio/uvm@latest

+ 1 - 0
.husky/commit-msg

@@ -0,0 +1 @@
1
+npx --no-install commitlint --edit "$1"

+ 1 - 0
.husky/pre-commit

@@ -0,0 +1 @@
1
+npx lint-staged --allow-empty

+ 9 - 0
.npmrc

@@ -0,0 +1,9 @@
1
+# registry = https://registry.npmjs.org
2
+registry = https://registry.npmmirror.com
3
+
4
+strict-peer-dependencies=false
5
+auto-install-peers=true
6
+shamefully-hoist=true
7
+ignore-workspace-root-check=true
8
+install-workspace-root=true
9
+node-options=--max-old-space-size=8192

+ 9 - 0
.prettierrc

@@ -0,0 +1,9 @@
1
+{
2
+  "semi": false,
3
+  "singleQuote": true,
4
+  "printWidth": 80,
5
+  "trailingComma": "es5",
6
+  "tabWidth": 2,
7
+  "useTabs": false,
8
+  "endOfLine": "auto"
9
+}

+ 122 - 0
.trae/rules/project_rules.md

@@ -0,0 +1,122 @@
1
+# unibest 项目概览
2
+
3
+这是一个基于 uniapp + Vue3 + TypeScript + Vite5 + UnoCSS 的跨平台开发框架。
4
+
5
+## 项目特点
6
+- 支持 H5、小程序、APP 多平台开发
7
+- 使用最新的前端技术栈
8
+- 内置约定式路由、layout布局、请求封装等功能
9
+- 无需依赖 HBuilderX,支持命令行开发
10
+
11
+## 核心配置文件
12
+- [package.json](mdc:package.json) - 项目依赖和脚本配置
13
+- [vite.config.ts](mdc:vite.config.ts) - Vite 构建配置
14
+- [pages.config.ts](mdc:pages.config.ts) - 页面路由配置
15
+- [manifest.config.ts](mdc:manifest.config.ts) - 应用清单配置
16
+- [uno.config.ts](mdc:uno.config.ts) - UnoCSS 配置
17
+
18
+## 主要目录结构
19
+- `src/pages/` - 页面文件
20
+- `src/components/` - 组件文件
21
+- `src/layouts/` - 布局文件
22
+- `src/api/` - API 接口
23
+- `src/http/` - HTTP 请求封装
24
+- `src/store/` - 状态管理
25
+- `src/tabbar/` - 底部导航栏
26
+- `src/App.ku.vue` - 全局根组件(类似 App.vue 里面的 template作用)
27
+
28
+## 开发命令
29
+- `pnpm dev` - 开发 H5 版本
30
+- `pnpm dev:mp` - 开发微信小程序
31
+- `pnpm dev:mp-alipay` - 开发支付宝小程序(含钉钉)
32
+- `pnpm dev:app` - 开发 APP 版本
33
+- `pnpm build` - 构建生产版本
34
+
35
+## Vue 组件规范
36
+- 使用 Composition API 和 `<script setup>` 语法
37
+- 组件文件使用 PascalCase 命名
38
+- 页面文件放在 `src/pages/` 目录下
39
+- 全局组件文件放在 `src/components/` 目录下
40
+- 局部组件文件放在页面的 `/components/` 目录下
41
+
42
+## TypeScript 规范
43
+- 严格使用 TypeScript,避免使用 `any` 类型
44
+- 为 API 响应数据定义接口类型
45
+- 使用 `interface` 定义对象类型,`type` 定义联合类型
46
+- 导入类型时使用 `import type` 语法
47
+
48
+## 状态管理
49
+- 使用 Pinia 进行状态管理
50
+- Store 文件放在 `src/store/` 目录下
51
+- 使用 `defineStore` 定义 store
52
+- 支持持久化存储
53
+
54
+## UnoCSS 原子化 CSS
55
+- 项目使用 UnoCSS 作为原子化 CSS 框架
56
+- 配置在 [uno.config.ts]
57
+- 支持预设和自定义规则
58
+- 优先使用原子化类名,减少自定义 CSS
59
+
60
+## Vue SFC 组件规范
61
+- `<script setup lang="ts">` 标签必须是第一个子元素
62
+- `<template>` 标签必须是第二个子元素
63
+- `<style scoped>` 标签必须是最后一个子元素(因为推荐使用原子化类名,所以很可能没有)
64
+
65
+## 页面开发
66
+- 页面文件放在 [src/pages/]目录下
67
+- 使用约定式路由,文件名即路由路径
68
+- 页面配置在仅需要在 宏`definePage` 中配置标题等内容即可,会自动生成到 `pages.json` 中
69
+
70
+## 组件开发
71
+- 全局组件文件放在 `src/components/` 目录下
72
+- 局部组件文件放在页面的 `/components/` 目录下
73
+- 使用 uni-app 内置组件和第三方组件库
74
+- 支持 wot-ui\uview-pro\uv-ui\sard-ui\uview-plus 等多种第三方组件库 和 z-paging 组件
75
+- 自定义组件遵循 uni-app 组件规范
76
+
77
+## 平台适配
78
+- 使用条件编译处理平台差异
79
+- 支持 H5、小程序、APP 多平台
80
+- 注意各平台的 API 差异
81
+- 使用 uni.xxx API 替代原生 API
82
+
83
+## 示例代码结构
84
+```vue
85
+<script setup lang="ts">
86
+// #ifdef H5
87
+import { h5Api } from '@/utils/h5'
88
+// #endif
89
+
90
+// #ifdef MP-WEIXIN
91
+import { mpApi } from '@/utils/mp'
92
+// #endif
93
+
94
+const handleClick = () => {
95
+  // #ifdef H5
96
+  h5Api.showToast('H5 平台')
97
+  // #endif
98
+  
99
+  // #ifdef MP-WEIXIN
100
+  mpApi.showToast('微信小程序')
101
+  // #endif
102
+}
103
+</script>
104
+
105
+<template>
106
+  <view class="page">
107
+    <!-- uni-app 组件 -->
108
+    <button @click="handleClick">点击</button>
109
+    
110
+    <!-- 条件渲染 -->
111
+    <!-- #ifdef H5 -->
112
+    <view>H5 特有内容</view>
113
+    <!-- #endif -->
114
+  </view>
115
+</template>
116
+```
117
+
118
+## 生命周期
119
+- 使用 uni-app 页面生命周期
120
+- onLoad、onShow、onReady、onHide、onUnload
121
+- 组件生命周期遵循 Vue3 规范
122
+- 注意页面栈和导航管理

+ 15 - 0
.vscode/extensions.json

@@ -0,0 +1,15 @@
1
+{
2
+  "recommendations": [
3
+    "vue.volar",
4
+    "dbaeumer.vscode-eslint",
5
+    "antfu.unocss",
6
+    "antfu.iconify",
7
+    "evils.uniapp-vscode",
8
+    "uni-helper.uni-helper-vscode",
9
+    "uni-helper.uni-app-schemas-vscode",
10
+    "uni-helper.uni-highlight-vscode",
11
+    "uni-helper.uni-ui-snippets-vscode",
12
+    "uni-helper.uni-app-snippets-vscode",
13
+    "streetsidesoftware.code-spell-checker"
14
+  ]
15
+}

+ 100 - 0
.vscode/settings.json

@@ -0,0 +1,100 @@
1
+{
2
+  // 配置语言的文件关联
3
+  "files.associations": {
4
+    "pages.json": "jsonc", // pages.json 可以写注释
5
+    "manifest.json": "jsonc" // manifest.json 可以写注释
6
+  },
7
+
8
+  "stylelint.enable": false, // 禁用 stylelint
9
+  "css.validate": false, // 禁用 CSS 内置验证
10
+  "scss.validate": false, // 禁用 SCSS 内置验证
11
+  "less.validate": false, // 禁用 LESS 内置验证
12
+
13
+  "typescript.tsdk": "node_modules\\typescript\\lib",
14
+  "explorer.fileNesting.enabled": true,
15
+  "explorer.fileNesting.expand": false,
16
+  "explorer.fileNesting.patterns": {
17
+    "README.md": "index.html,favicon.ico,robots.txt,CHANGELOG.md",
18
+    "docker.md": "Dockerfile,docker*.md,nginx*,.dockerignore",
19
+    "pages.config.ts": "manifest.config.ts,openapi-ts-request.config.ts",
20
+    "package.json": "tsconfig.json,pnpm-lock.yaml,pnpm-workspace.yaml,LICENSE,.gitattributes,.gitignore,.gitpod.yml,CNAME,.npmrc,.browserslistrc",
21
+    "eslint.config.mjs": ".commitlintrc.*,.prettier*,.editorconfig,.commitlint.cjs,.eslint*"
22
+  },
23
+
24
+  // Disable the default formatter, use eslint instead
25
+  "prettier.enable": false,
26
+  "editor.formatOnSave": false,
27
+
28
+  // Auto fix
29
+  "editor.codeActionsOnSave": {
30
+    "source.fixAll.eslint": "explicit",
31
+    "source.organizeImports": "never"
32
+  },
33
+
34
+  // Silent the stylistic rules in you IDE, but still auto fix them
35
+  "eslint.rules.customizations": [
36
+    { "rule": "style/*", "severity": "off", "fixable": true },
37
+    { "rule": "format/*", "severity": "off", "fixable": true },
38
+    { "rule": "*-indent", "severity": "off", "fixable": true },
39
+    { "rule": "*-spacing", "severity": "off", "fixable": true },
40
+    { "rule": "*-spaces", "severity": "off", "fixable": true },
41
+    { "rule": "*-order", "severity": "off", "fixable": true },
42
+    { "rule": "*-dangle", "severity": "off", "fixable": true },
43
+    { "rule": "*-newline", "severity": "off", "fixable": true },
44
+    { "rule": "*quotes", "severity": "off", "fixable": true },
45
+    { "rule": "*semi", "severity": "off", "fixable": true }
46
+  ],
47
+
48
+  // Enable eslint for all supported languages
49
+  "eslint.validate": [
50
+    "javascript",
51
+    "javascriptreact",
52
+    "typescript",
53
+    "typescriptreact",
54
+    "vue",
55
+    "html",
56
+    "markdown",
57
+    "json",
58
+    "jsonc",
59
+    "yaml",
60
+    "toml",
61
+    "xml",
62
+    "gql",
63
+    "graphql",
64
+    "astro",
65
+    "svelte",
66
+    "css",
67
+    "less",
68
+    "scss",
69
+    "pcss",
70
+    "postcss"
71
+  ],
72
+  "cSpell.words": [
73
+    "alova",
74
+    "Aplipay",
75
+    "attributify",
76
+    "chooseavatar",
77
+    "climblee",
78
+    "commitlint",
79
+    "dcloudio",
80
+    "iconfont",
81
+    "oxlint",
82
+    "qrcode",
83
+    "refresherrefresh",
84
+    "scrolltolower",
85
+    "tabbar",
86
+    "Toutiao",
87
+    "uniapp",
88
+    "unibest",
89
+    "unocss",
90
+    "uview",
91
+    "uvui",
92
+    "Wechat",
93
+    "WechatMiniprogram",
94
+    "Weixin"
95
+  ],
96
+  "i18n-ally.localesPaths": [
97
+    "src/locale"
98
+  ],
99
+  "i18n-ally.keystyle": "nested"
100
+}

+ 77 - 0
.vscode/vue3.code-snippets

@@ -0,0 +1,77 @@
1
+{
2
+  // Place your unibest 工作区 snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
3
+  // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
4
+  // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
5
+  // used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
6
+  // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
7
+  // Placeholders with the same ids are connected.
8
+  // Example:
9
+  // "Print to console": {
10
+  // 	"scope": "javascript,typescript",
11
+  // 	"prefix": "log",
12
+  // 	"body": [
13
+  // 		"console.log('$1');",
14
+  // 		"$2"
15
+  // 	],
16
+  // 	"description": "Log output to console"
17
+  // }
18
+  "Print unibest Vue3 SFC": {
19
+    "scope": "vue",
20
+    "prefix": "v3",
21
+    "body": [
22
+      "<script lang=\"ts\" setup>",
23
+      "definePage({",
24
+      "  style: {",
25
+      "    navigationBarTitleText: '$1',",
26
+      "  },",
27
+      "})",
28
+      "</script>\n",
29
+      "<template>",
30
+      "  <view class=\"\">$3</view>",
31
+      "</template>\n",
32
+      "<style lang=\"scss\" scoped>",
33
+      "//$4",
34
+      "</style>\n",
35
+    ],
36
+  },
37
+  "Print unibest style": {
38
+    "scope": "vue",
39
+    "prefix": "st",
40
+    "body": [
41
+      "<style lang=\"scss\" scoped>",
42
+      "//",
43
+      "</style>\n"
44
+    ],
45
+  },
46
+  "Print unibest script": {
47
+    "scope": "vue",
48
+    "prefix": "sc",
49
+    "body": [
50
+      "<script lang=\"ts\" setup>",
51
+      "//$1",
52
+      "</script>\n"
53
+    ],
54
+  },
55
+  "Print unibest script with definePage": {
56
+    "scope": "vue",
57
+    "prefix": "scdp",
58
+    "body": [
59
+      "<script lang=\"ts\" setup>",
60
+      "definePage({",
61
+      "  style: {",
62
+      "    navigationBarTitleText: '$1',",
63
+      "  },",
64
+      "})",
65
+      "</script>\n"
66
+    ],
67
+  },
68
+  "Print unibest template": {
69
+    "scope": "vue",
70
+    "prefix": "te",
71
+    "body": [
72
+      "<template>",
73
+      "  <view class=\"\">$1</view>",
74
+      "</template>\n"
75
+    ],
76
+  },
77
+}

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
1
+MIT License
2
+
3
+Copyright (c) 2025 菲鸽
4
+
5
+Permission is hereby granted, free of charge, to any person obtaining a copy
6
+of this software and associated documentation files (the "Software"), to deal
7
+in the Software without restriction, including without limitation the rights
8
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+copies of the Software, and to permit persons to whom the Software is
10
+furnished to do so, subject to the following conditions:
11
+
12
+The above copyright notice and this permission notice shall be included in all
13
+copies or substantial portions of the Software.
14
+
15
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+SOFTWARE.

+ 98 - 0
README.md

@@ -0,0 +1,98 @@
1
+<p align="center">
2
+  <a href="https://github.com/unibest-tech/unibest">
3
+    <img width="160" src="./src/static/logo.svg">
4
+  </a>
5
+</p>
6
+
7
+<h1 align="center">
8
+  <a href="https://github.com/unibest-tech/unibest" target="_blank">unibest - 最好的 uniapp 开发框架</a>
9
+</h1>
10
+
11
+<div align="center">
12
+旧仓库 codercup 进不去了,star 也拿不回来,这里也展示一下那个地址的 star.
13
+
14
+[![GitHub Repo stars](https://img.shields.io/github/stars/codercup/unibest?style=flat&logo=github)](https://github.com/codercup/unibest)
15
+[![GitHub forks](https://img.shields.io/github/forks/codercup/unibest?style=flat&logo=github)](https://github.com/codercup/unibest)
16
+
17
+</div>
18
+
19
+<div align="center">
20
+
21
+[![GitHub Repo stars](https://img.shields.io/github/stars/feige996/unibest?style=flat&logo=github)](https://github.com/feige996/unibest)
22
+[![GitHub forks](https://img.shields.io/github/forks/feige996/unibest?style=flat&logo=github)](https://github.com/feige996/unibest)
23
+[![star](https://gitee.com/feige996/unibest/badge/star.svg?theme=dark)](https://gitee.com/feige996/unibest/stargazers)
24
+[![fork](https://gitee.com/feige996/unibest/badge/fork.svg?theme=dark)](https://gitee.com/feige996/unibest/members)
25
+![node version](https://img.shields.io/badge/node-%3E%3D18-green)
26
+![pnpm version](https://img.shields.io/badge/pnpm-%3E%3D7.30-green)
27
+![GitHub package.json version (subfolder of monorepo)](https://img.shields.io/github/package-json/v/feige996/unibest)
28
+![GitHub License](https://img.shields.io/github/license/feige996/unibest)
29
+
30
+</div>
31
+
32
+`unibest` —— 最好的 `uniapp` 开发模板,由 `uniapp` + `Vue3` + `Ts` + `Vite5` + `UnoCss` + `wot-ui` + `z-paging` 构成,使用了最新的前端技术栈,无需依靠 `HBuilderX`,通过命令行方式运行 `web`、`小程序` 和 `App`(编辑器推荐 `VSCode`,可选 `webstorm`)。
33
+
34
+`unibest` 内置了 `约定式路由`、`layout布局`、`请求封装`、`请求拦截`、`登录拦截`、`UnoCSS`、`i18n多语言` 等基础功能,提供了 `代码提示`、`自动格式化`、`统一配置`、`代码片段` 等辅助功能,让你编写 `uniapp` 拥有 `best` 体验 ( `unibest 的由来`)。
35
+
36
+![](https://raw.githubusercontent.com/andreasbm/readme/master/screenshots/lines/rainbow.png)
37
+
38
+<p align="center">
39
+  <a href="https://unibest.tech/" target="_blank">📖 文档地址(new)</a>
40
+  <span style="margin:0 10px;">|</span>
41
+  <a href="https://feige996.github.io/hello-unibest/" target="_blank">📱 DEMO 地址</a>
42
+</p>
43
+
44
+---
45
+
46
+注意旧的地址 [codercup](https://github.com/codercup/unibest) 我进不去了,使用新的 [feige996](https://github.com/feige996/unibest)。PR和 issue 也请使用新地址,否则无法合并。
47
+
48
+## 平台兼容性
49
+
50
+| H5  | IOS | 安卓 | 微信小程序 | 字节小程序 | 快手小程序 | 支付宝小程序 | 钉钉小程序 | 百度小程序 |
51
+| --- | --- | ---- | ---------- | ---------- | ---------- | ------------ | ---------- | ---------- |
52
+| √   | √   | √    | √          | √          | √          | √            | √          | √          |
53
+
54
+注意每种 `UI框架` 支持的平台有所不同,详情请看各 `UI框架` 的官网,也可以看 `unibest` 文档。
55
+
56
+## ⚙️ 环境
57
+
58
+- node>=18
59
+- pnpm>=7.30
60
+- Vue Official>=2.1.10
61
+- TypeScript>=5.0
62
+
63
+## 新版分支 
64
+- main == base
65
+- base --> base-i18n
66
+- base-login --> base-login-i18n
67
+
68
+## &#x1F4C2; 快速开始
69
+
70
+执行 `pnpm create unibest` 创建项目
71
+执行 `pnpm i` 安装依赖
72
+执行 `pnpm dev` 运行 `H5`
73
+执行 `pnpm dev:mp` 运行 `微信小程序`
74
+
75
+## 📦 运行(支持热更新)
76
+
77
+- web平台: `pnpm dev:h5`, 然后打开 [http://localhost:9000/](http://localhost:9000/)。
78
+- weixin平台:`pnpm dev:mp` 然后打开微信开发者工具,导入本地文件夹,选择本项目的`dist/dev/mp-weixin` 文件。
79
+- APP平台:`pnpm dev:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/dev/app` 文件夹,选择运行到模拟器(开发时优先使用),或者运行的安卓/ios基座。(如果是 `安卓` 和 `鸿蒙` 平台,则不用这个方式,可以把整个unibest项目导入到hbx,通过hbx的菜单来运行到对应的平台。)
80
+
81
+## 🔗 发布
82
+
83
+- web平台: `pnpm build:h5`,打包后的文件在 `dist/build/h5`,可以放到web服务器,如nginx运行。如果最终不是放在根目录,可以在 `manifest.config.ts` 文件的 `h5.router.base` 属性进行修改。
84
+- weixin平台:`pnpm build:mp`, 打包后的文件在 `dist/build/mp-weixin`,然后通过微信开发者工具导入,并点击右上角的“上传”按钮进行上传。
85
+- APP平台:`pnpm build:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/build/app` 文件夹,选择发行 - APP云打包。(如果是 `安卓` 和 `鸿蒙` 平台,则不用这个方式,可以把整个unibest项目导入到hbx,通过hbx的菜单来发行到对应的平台。)
86
+
87
+## 📄 License
88
+
89
+[MIT](https://opensource.org/license/mit/)
90
+
91
+Copyright (c) 2025 菲鸽
92
+
93
+## 捐赠
94
+
95
+<p align='center'>
96
+<img alt="special sponsor appwrite" src="https://oss.laf.run/ukw0y1-site/pay/wepay.png" height="330" style="display:inline-block; height:330px;">
97
+<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;">
98
+</p>

+ 3 - 0
codes/README.md

@@ -0,0 +1,3 @@
1
+# 参考代码
2
+
3
+部分代码片段,供参考。

+ 31 - 0
codes/docker/.dockerignore

@@ -0,0 +1,31 @@
1
+# 依赖目录
2
+node_modules
3
+
4
+# 版本控制
5
+.git
6
+.gitignore
7
+
8
+# 构建产物
9
+/dist
10
+
11
+# 开发工具配置
12
+.vscode/
13
+.idea/
14
+.trae/
15
+.cursor/
16
+
17
+# 其他配置文件
18
+.github/
19
+.husky/
20
+
21
+# 日志文件
22
+logs/
23
+
24
+# 缓存文件
25
+.cache/
26
+
27
+*.swp
28
+*.swo
29
+
30
+# 操作系统文件
31
+.DS_Store

+ 38 - 0
codes/docker/Dockerfile

@@ -0,0 +1,38 @@
1
+# 使用 node:24-alpine 作为基础镜像,固定版本+减少体积
2
+FROM node:24-alpine AS builder
3
+
4
+# 在容器中创建目录
5
+WORKDIR /app
6
+
7
+# 安装pnpm(使用 npm 的 --global-style 可以减少依赖安装体积)
8
+RUN npm install -g pnpm@10.10.0 --global-style
9
+# 设置pnpm镜像源
10
+RUN pnpm config set registry https://registry.npmmirror.com
11
+# 复制依赖文件
12
+COPY package.json pnpm-lock.yaml ./
13
+# 先复制scripts目录,因为prepare脚本需要用到其中的文件
14
+COPY scripts ./scripts
15
+# 安装依赖,但跳过prepare脚本(这一步会缓存,只有 package.json 或 pnpm-lock.yaml 变化时才会重新运行)
16
+RUN pnpm install --ignore-scripts --frozen-lockfile
17
+# 手动执行我们需要的docker:prepare脚本
18
+RUN pnpm run docker:prepare
19
+# 复制其余源代码
20
+COPY . .
21
+# 构建项目
22
+RUN pnpm run build
23
+
24
+
25
+# 使用nginx作为服务
26
+FROM nginx:1.29.1-alpine3.22 AS production-stage
27
+
28
+# 将构建好的项目复制到nginx下
29
+COPY --from=builder /app/dist/build/h5 /usr/share/nginx/html
30
+
31
+COPY nginx.conf /etc/nginx/nginx.conf
32
+
33
+# 暴露端口
34
+EXPOSE 80
35
+EXPOSE 443
36
+
37
+# 启动nginx
38
+CMD ["nginx", "-g", "daemon off;"]

+ 28 - 0
codes/docker/docker.md

@@ -0,0 +1,28 @@
1
+## Docker
2
+
3
+根据提供的 `Dockerfile`,可以通过以下步骤构建并运行镜像:
4
+
5
+### 1. 构建Docker镜像
6
+
7
+在项目根目录执行以下命令:
8
+
9
+- `-t unibest:v1-2025091701`:为镜像指定名称和标签,YYYYMMDD+编号
10
+- `.`:表示使用当前目录的Dockerfile
11
+
12
+```bash
13
+docker build -t unibest:v1-2025091701 .
14
+docker build -t unibest:v1-2025091702 .
15
+```
16
+### 2. 运行Docker容器
17
+使用以下命令运行容器:
18
+
19
+```bash
20
+docker run -d --name unibest-v1-2025091701 -p 80:80 unibest:v1-2025091701
21
+docker run -d --name unibest-v1-2025091702 -p 80:80 unibest:v1-2025091702
22
+```
23
+
24
+- `-d`:表示在后台运行容器
25
+- `-p 80:80`:将容器的80端口映射到主机的80端口
26
+- `--name unibest-v1-2025091701`:为容器指定一个名称
27
+
28
+

+ 145 - 0
codes/docker/nginx.conf

@@ -0,0 +1,145 @@
1
+# 配置工作进程数,通常设置为 CPU 核心数
2
+worker_processes auto;
3
+
4
+# 错误日志配置
5
+error_log /var/log/nginx/error.log warn;
6
+pid /var/run/nginx.pid;
7
+
8
+events {
9
+  worker_connections 1024;
10
+  # 开启多路复用
11
+  use epoll;
12
+}
13
+
14
+# 文件描述符限制 - 移到这里,在http块之前
15
+worker_rlimit_nofile 65535;
16
+
17
+http {
18
+  # 日志格式定义
19
+  log_format main '$remote_addr - $remote_user [$time_local] "$request" '
20
+                  '$status $body_bytes_sent "$http_referer" '
21
+                  '"$http_user_agent" "$http_x_forwarded_for"';
22
+  
23
+  # 访问日志配置
24
+  access_log /var/log/nginx/access.log main;
25
+
26
+  # 高效文件传输设置
27
+  sendfile on;
28
+  tcp_nopush on;
29
+  tcp_nodelay on;
30
+  
31
+  # 连接超时设置
32
+  keepalive_timeout 65;
33
+  keepalive_requests 100;
34
+
35
+  # gzip 压缩优化
36
+  gzip on;
37
+  gzip_vary on;
38
+  gzip_comp_level 6;
39
+  gzip_min_length 1000;
40
+  gzip_buffers 16 8k;
41
+  gzip_http_version 1.1;
42
+  # 增加更多文件类型
43
+  gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon;
44
+
45
+  # 全局设置
46
+  # 合理限制请求体大小,根据实际需求调整
47
+  client_max_body_size 10m;
48
+  client_body_buffer_size 128k;
49
+  client_header_timeout 60s;
50
+  client_body_timeout 60s;
51
+  
52
+  server {
53
+    listen 80;
54
+    server_name _;
55
+    gunzip on;
56
+    gzip_static always;
57
+    include /etc/nginx/mime.types;
58
+    absolute_redirect off;
59
+    root /usr/share/nginx/html;
60
+
61
+    # 安全相关响应头
62
+    add_header X-Frame-Options SAMEORIGIN;
63
+    add_header X-XSS-Protection "1; mode=block";
64
+    add_header X-Content-Type-Options nosniff;
65
+    # 根据实际情况调整 CSP
66
+    # add_header Content-Security-Policy "default-src 'self'";
67
+
68
+    # 处理 SPA 应用路由
69
+    location / {
70
+      try_files $uri $uri/ /index.html;
71
+      index index.html index.htm;
72
+    }
73
+
74
+    # HTML 和 JSON 文件 - 短缓存策略
75
+    location ~ .*\.(html|json)$ {
76
+      add_header Cache-Control "public, max-age=300, must-revalidate";
77
+    }
78
+
79
+    # 静态资源 - 长缓存策略
80
+    location ~ .*\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|ttf|woff|woff2|eot|mp4|mp3|swf)$ {
81
+      add_header Cache-Control "public, max-age=31536000, immutable";
82
+      expires 365d;
83
+      access_log off;
84
+    }
85
+
86
+    # JS 和 CSS - 带版本号的长缓存
87
+    location ~ .*\.(js|css)$ {
88
+      add_header Cache-Control "public, max-age=31536000, immutable";
89
+      expires 365d;
90
+      access_log off;
91
+    }
92
+
93
+    # 接口转发 - 替换为实际后端地址
94
+    # location ^~ /fg-api {
95
+    #   proxy_http_version 1.1;
96
+    #   proxy_set_header X-Real-IP $remote_addr;
97
+    #   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
98
+    #   proxy_set_header X-Forwarded-Proto $scheme;
99
+    #   proxy_set_header Host $host;
100
+
101
+    #   # 后端是HTTPS时的必要配置
102
+    #   proxy_ssl_server_name on;
103
+    #   proxy_ssl_protocols TLSv1.2 TLSv1.3;
104
+    #   proxy_ssl_session_reuse on;
105
+      
106
+    #   # 对于生产环境,应该尽量使用有效的证书而不是依赖``proxy_ssl_verify off;`` ,因为这会带来安全风险
107
+    #   proxy_ssl_verify off;
108
+
109
+    #   # TODO:替换为实际后端服务地址
110
+    #   # 注意在URL末尾添加了斜杠,这样Nginx会去掉 /fg-api 前缀
111
+    #   # 前端请求 http://your-domain.com/fg-api/users 转发到 https://ukw0y1.laf.run/users
112
+    #   proxy_pass https://ukw0y1.laf.run/;
113
+
114
+    #   # 上面一行的效果与下面2行一样的效果,都是为了去掉 /fg-api 前缀
115
+    #   # 显式移除/fg-api前缀
116
+    #   # rewrite ^/fg-api(.*)$ $1 break; 
117
+    #   # 域名末尾不需要斜杠了
118
+    #   # proxy_pass https://ukw0y1.laf.run;
119
+
120
+    #   proxy_connect_timeout 60s;
121
+    #   proxy_send_timeout 60s;
122
+    #   proxy_read_timeout 60s;
123
+
124
+    #   proxy_buffers 8 32k;
125
+    #   proxy_buffer_size 64k;
126
+    #   proxy_busy_buffers_size 128k;
127
+
128
+    #   proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
129
+    # }
130
+
131
+    # 错误页面配置
132
+    error_page 404 /index.html;
133
+    error_page 500 502 503 504 /50x.html;
134
+    location = /50x.html {
135
+      root /usr/share/nginx/html;
136
+    }
137
+
138
+    # 禁止访问隐藏文件
139
+    location ~ /\. {
140
+      deny all;
141
+      access_log off;
142
+      log_not_found off;
143
+    }
144
+  }
145
+}

+ 30 - 0
env/.env

@@ -0,0 +1,30 @@
1
+VITE_APP_TITLE = 'unibest'
2
+VITE_APP_PORT = 9000
3
+
4
+VITE_UNI_APPID = '__UNI__D1E5001'
5
+VITE_WX_APPID = 'wxa2abb91f64032a2b'
6
+
7
+# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base
8
+# https://uniapp.dcloud.net.cn/collocation/manifest.html#h5-router
9
+# 比如你要部署到 https://unibest.tech/doc/ ,则配置为 /doc/
10
+VITE_APP_PUBLIC_BASE=/
11
+
12
+# 后台请求地址
13
+VITE_SERVER_BASEURL = 'https://ukw0y1.laf.run'
14
+# 备注:如果后台带统一前缀,则也要加到后面,eg: https://ukw0y1.laf.run/api
15
+
16
+# 注意,如果是微信小程序,还有一套请求地址的配置,根据 develop、trial、release 分别设置上传地址,见 `src/utils/index.ts`。
17
+
18
+# h5是否需要配置代理
19
+VITE_APP_PROXY_ENABLE = false
20
+# 下面的不用修改,只要不跟你后台的统一前缀冲突就行。如果修改了,记得修改 `nginx` 里面的配置
21
+VITE_APP_PROXY_PREFIX = '/fg-api'
22
+
23
+# 第二个请求地址 (目前alova中可以使用)
24
+VITE_SERVER_BASEURL_SECONDARY = 'https://ukw0y1.laf.run'
25
+
26
+# 认证模式,'single' | 'double' ==> 单token | 双token
27
+VITE_AUTH_MODE = 'single'
28
+
29
+# 原生插件资源复制开关,控制是否启用 copy-native-resources 插件
30
+VITE_COPY_NATIVE_RES_ENABLE = false

+ 9 - 0
env/.env.development

@@ -0,0 +1,9 @@
1
+# 变量必须以 VITE_ 为前缀才能暴露给外部读取
2
+NODE_ENV = 'development'
3
+# 是否去除console 和 debugger
4
+VITE_DELETE_CONSOLE = false
5
+# 是否开启sourcemap
6
+VITE_SHOW_SOURCEMAP = false
7
+
8
+# 后台请求地址
9
+VITE_SERVER_BASEURL = 'http://192.168.1.15:8080'

+ 9 - 0
env/.env.production

@@ -0,0 +1,9 @@
1
+# 变量必须以 VITE_ 为前缀才能暴露给外部读取
2
+NODE_ENV = 'production'
3
+# 是否去除console 和 debugger
4
+VITE_DELETE_CONSOLE = true
5
+# 是否开启sourcemap
6
+VITE_SHOW_SOURCEMAP = false
7
+
8
+# 后台请求地址
9
+# VITE_SERVER_BASEURL = 'https://prod.xxx.com'

+ 9 - 0
env/.env.test

@@ -0,0 +1,9 @@
1
+# 变量必须以 VITE_ 为前缀才能暴露给外部读取
2
+NODE_ENV = 'development'
3
+# 是否去除console 和 debugger
4
+VITE_DELETE_CONSOLE = false
5
+# 是否开启sourcemap
6
+VITE_SHOW_SOURCEMAP = false
7
+
8
+# 后台请求地址
9
+# VITE_SERVER_BASEURL = 'https://test.xxx.com'

+ 58 - 0
eslint.config.mjs

@@ -0,0 +1,58 @@
1
+import uniHelper from '@uni-helper/eslint-config'
2
+
3
+export default uniHelper({
4
+  unocss: true,
5
+  vue: true,
6
+  markdown: false,
7
+  ignores: [
8
+    // 忽略uni_modules目录
9
+    '**/uni_modules/',
10
+    // 忽略原生插件目录
11
+    '**/nativeplugins/',
12
+    'dist',
13
+    // unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用
14
+    'auto-import.d.ts',
15
+    // vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore
16
+    'uni-pages.d.ts',
17
+    // 插件生成的文件
18
+    'src/pages.json',
19
+    'src/manifest.json',
20
+    // 忽略自动生成文件
21
+    'src/service/**',
22
+  ],
23
+  // https://eslint-config.antfu.me/rules
24
+  rules: {
25
+    'no-useless-return': 'off',
26
+    'no-console': 'off',
27
+    'no-unused-vars': 'off',
28
+    'vue/no-unused-refs': 'off',
29
+    'unused-imports/no-unused-vars': 'off',
30
+    'eslint-comments/no-unlimited-disable': 'off',
31
+    'jsdoc/check-param-names': 'off',
32
+    'jsdoc/require-returns-description': 'off',
33
+    'ts/no-empty-object-type': 'off',
34
+    'no-extend-native': 'off',
35
+    'vue/singleline-html-element-content-newline': [
36
+      'error',
37
+      {
38
+        externalIgnores: ['text'],
39
+      },
40
+    ],
41
+    // vue SFC 调换顺序改这里
42
+    'vue/block-order': ['error', {
43
+      order: [['script', 'template'], 'style'],
44
+    }],
45
+  },
46
+  formatters: {
47
+    /**
48
+     * Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
49
+     * By default uses Prettier
50
+     */
51
+    css: true,
52
+    /**
53
+     * Format HTML files
54
+     * By default uses Prettier
55
+     */
56
+    html: true,
57
+  },
58
+})

BIN
favicon.ico


+ 26 - 0
index.html

@@ -0,0 +1,26 @@
1
+<!doctype html>
2
+<html build-time="%BUILD_TIME%">
3
+  <head>
4
+    <meta charset="UTF-8" />
5
+    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
6
+    <script>
7
+      var coverSupport =
8
+        'CSS' in window &&
9
+        typeof CSS.supports === 'function' &&
10
+        (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
11
+      document.write(
12
+        '<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
13
+          (coverSupport ? ', viewport-fit=cover' : '') +
14
+          '" />',
15
+      )
16
+    </script>
17
+    <title>%VITE_APP_TITLE%</title>
18
+    <!--preload-links-->
19
+    <!--app-context-->
20
+  </head>
21
+
22
+  <body>
23
+    <div id="app"><!--app-html--></div>
24
+    <script type="module" src="/src/main.ts"></script>
25
+  </body>
26
+</html>

+ 164 - 0
manifest.config.ts

@@ -0,0 +1,164 @@
1
+import path from 'node:path'
2
+import process from 'node:process'
3
+// manifest.config.ts
4
+import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
5
+import { loadEnv } from 'vite'
6
+
7
+// 手动解析命令行参数获取 mode
8
+function getMode() {
9
+  const args = process.argv.slice(2)
10
+  const modeFlagIndex = args.findIndex(arg => arg === '--mode')
11
+  return modeFlagIndex !== -1 ? args[modeFlagIndex + 1] : args[0] === 'build' ? 'production' : 'development' // 默认 development
12
+}
13
+// 获取环境变量的范例
14
+const env = loadEnv(getMode(), path.resolve(process.cwd(), 'env'))
15
+const {
16
+  VITE_APP_TITLE,
17
+  VITE_UNI_APPID,
18
+  VITE_WX_APPID,
19
+  VITE_APP_PUBLIC_BASE,
20
+  VITE_FALLBACK_LOCALE,
21
+} = env
22
+// console.log('manifest.config.ts env:', env)
23
+
24
+export default defineManifestConfig({
25
+  'name': VITE_APP_TITLE,
26
+  'appid': VITE_UNI_APPID,
27
+  'description': '',
28
+  'versionName': '1.0.0',
29
+  'versionCode': '100',
30
+  'transformPx': false,
31
+  'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
32
+  'h5': {
33
+    router: {
34
+      base: VITE_APP_PUBLIC_BASE,
35
+    },
36
+  },
37
+  /* 5+App特有相关 */
38
+  'app-plus': {
39
+    usingComponents: true,
40
+    nvueStyleCompiler: 'uni-app',
41
+    compilerVersion: 3,
42
+    compatible: {
43
+      ignoreVersion: true,
44
+    },
45
+    splashscreen: {
46
+      alwaysShowBeforeRender: true,
47
+      waiting: true,
48
+      autoclose: true,
49
+      delay: 0,
50
+    },
51
+    /* 模块配置 */
52
+    modules: {},
53
+    /* 应用发布信息 */
54
+    distribute: {
55
+      /* android打包配置 */
56
+      android: {
57
+        minSdkVersion: 21,
58
+        targetSdkVersion: 30,
59
+        abiFilters: ['armeabi-v7a', 'arm64-v8a'],
60
+        permissions: [
61
+          '<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>',
62
+          '<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>',
63
+          '<uses-permission android:name="android.permission.VIBRATE"/>',
64
+          '<uses-permission android:name="android.permission.READ_LOGS"/>',
65
+          '<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>',
66
+          '<uses-feature android:name="android.hardware.camera.autofocus"/>',
67
+          '<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
68
+          '<uses-permission android:name="android.permission.CAMERA"/>',
69
+          '<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
70
+          '<uses-permission android:name="android.permission.READ_PHONE_STATE"/>',
71
+          '<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>',
72
+          '<uses-permission android:name="android.permission.WAKE_LOCK"/>',
73
+          '<uses-permission android:name="android.permission.FLASHLIGHT"/>',
74
+          '<uses-feature android:name="android.hardware.camera"/>',
75
+          '<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
76
+        ],
77
+      },
78
+      /* ios打包配置 */
79
+      ios: {},
80
+      /* SDK配置 */
81
+      sdkConfigs: {},
82
+      /* 图标配置 */
83
+      icons: {
84
+        android: {
85
+          hdpi: 'static/app/icons/72x72.png',
86
+          xhdpi: 'static/app/icons/96x96.png',
87
+          xxhdpi: 'static/app/icons/144x144.png',
88
+          xxxhdpi: 'static/app/icons/192x192.png',
89
+        },
90
+        ios: {
91
+          appstore: 'static/app/icons/1024x1024.png',
92
+          ipad: {
93
+            'app': 'static/app/icons/76x76.png',
94
+            'app@2x': 'static/app/icons/152x152.png',
95
+            'notification': 'static/app/icons/20x20.png',
96
+            'notification@2x': 'static/app/icons/40x40.png',
97
+            'proapp@2x': 'static/app/icons/167x167.png',
98
+            'settings': 'static/app/icons/29x29.png',
99
+            'settings@2x': 'static/app/icons/58x58.png',
100
+            'spotlight': 'static/app/icons/40x40.png',
101
+            'spotlight@2x': 'static/app/icons/80x80.png',
102
+          },
103
+          iphone: {
104
+            'app@2x': 'static/app/icons/120x120.png',
105
+            'app@3x': 'static/app/icons/180x180.png',
106
+            'notification@2x': 'static/app/icons/40x40.png',
107
+            'notification@3x': 'static/app/icons/60x60.png',
108
+            'settings@2x': 'static/app/icons/58x58.png',
109
+            'settings@3x': 'static/app/icons/87x87.png',
110
+            'spotlight@2x': 'static/app/icons/80x80.png',
111
+            'spotlight@3x': 'static/app/icons/120x120.png',
112
+          },
113
+        },
114
+      },
115
+    },
116
+  },
117
+  /* 快应用特有相关 */
118
+  'quickapp': {},
119
+  /* 小程序特有相关 */
120
+  'mp-weixin': {
121
+    appid: VITE_WX_APPID,
122
+    setting: {
123
+      urlCheck: false,
124
+      // 是否启用 ES6 转 ES5
125
+      es6: true,
126
+      minified: true,
127
+    },
128
+    optimization: {
129
+      subPackages: true,
130
+    },
131
+    // 是否合并组件虚拟节点外层属性,uni-app 3.5.1+ 开始支持。目前仅支持 style、class 属性。
132
+    // 默认不开启(undefined),这里设置为开启。
133
+    mergeVirtualHostAttributes: true,
134
+    // styleIsolation: 'shared',
135
+    usingComponents: true,
136
+    // __usePrivacyCheck__: true,
137
+  },
138
+  'mp-alipay': {
139
+    usingComponents: true,
140
+    styleIsolation: 'shared',
141
+    optimization: {
142
+      subPackages: true,
143
+    },
144
+    // 解决支付宝小程序开发工具报错 【globalThis is not defined】
145
+    compileOptions: {
146
+      globalObjectMode: 'enable',
147
+      transpile: {
148
+        script: {
149
+          ignore: ['node_modules/**'],
150
+        },
151
+      },
152
+    },
153
+  },
154
+  'mp-baidu': {
155
+    usingComponents: true,
156
+  },
157
+  'mp-toutiao': {
158
+    usingComponents: true,
159
+  },
160
+  'uniStatistics': {
161
+    enable: false,
162
+  },
163
+  'vueVersion': '3',
164
+})

+ 14 - 0
openapi-ts-request.config.ts

@@ -0,0 +1,14 @@
1
+import { defineConfig } from 'openapi-ts-request'
2
+
3
+export default defineConfig([
4
+  {
5
+    describe: 'unibest-openapi-test',
6
+    schemaPath: 'https://ukw0y1.laf.run/unibest-opapi-test.json',
7
+    serversPath: './src/service',
8
+    requestLibPath: `import request from '@/http/vue-query';\n import { CustomRequestOptions_ } from '@/http/types';`,
9
+    requestOptionsType: 'CustomRequestOptions_',
10
+    isGenReactQuery: false,
11
+    reactQueryMode: 'vue',
12
+    isGenJavaScript: false,
13
+  },
14
+])

+ 200 - 0
package.json

@@ -0,0 +1,200 @@
1
+{
2
+  "name": "dingding",
3
+  "type": "module",
4
+  "version": "1.0.0",
5
+  "unibest-version": "4.0.0",
6
+  "unibest-update-time": "2025-11-07",
7
+  "packageManager": "pnpm@10.10.0",
8
+  "description": "unibest - 最好的 uniapp 开发模板",
9
+  "generate-time": "用户创建项目时生成",
10
+  "author": {
11
+    "name": "feige996",
12
+    "zhName": "菲鸽",
13
+    "email": "1020103647@qq.com",
14
+    "github": "https://github.com/feige996",
15
+    "gitee": "https://gitee.com/feige996"
16
+  },
17
+  "license": "MIT",
18
+  "homepage": "https://unibest.tech",
19
+  "repository": "https://github.com/feige996/unibest",
20
+  "bugs": {
21
+    "url": "https://github.com/feige996/unibest/issues",
22
+    "url-old": "https://github.com/codercup/unibest/issues"
23
+  },
24
+  "engines": {
25
+    "node": ">=20",
26
+    "pnpm": ">=9"
27
+  },
28
+  "scripts": {
29
+    "preinstall": "npx only-allow pnpm",
30
+    "uvm": "npx @dcloudio/uvm@latest",
31
+    "uvm-rm": "node ./scripts/postupgrade.js",
32
+    "postuvm": "echo upgrade uni-app success!",
33
+    "dev:app": "uni -p app",
34
+    "dev:app:test": "uni -p app --mode test",
35
+    "dev:app:prod": "uni -p app --mode production",
36
+    "dev:app-android": "uni -p app-android",
37
+    "dev:app-ios": "uni -p app-ios",
38
+    "dev:custom": "uni -p",
39
+    "predev": "pnpm init-baseFiles",
40
+    "dev": "uni",
41
+    "dev:test": "uni --mode test",
42
+    "dev:prod": "uni --mode production",
43
+    "dev:h5": "uni",
44
+    "dev:h5:test": "uni --mode test",
45
+    "dev:h5:prod": "uni --mode production",
46
+    "dev:h5:ssr": "uni --ssr",
47
+    "dev:mp": "uni -p mp-weixin",
48
+    "dev:mp:test": "uni -p mp-weixin --mode test",
49
+    "dev:mp:prod": "uni -p mp-weixin --mode production",
50
+    "dev:mp-alipay": "uni -p mp-alipay",
51
+    "dev:mp-baidu": "uni -p mp-baidu",
52
+    "dev:mp-jd": "uni -p mp-jd",
53
+    "dev:mp-kuaishou": "uni -p mp-kuaishou",
54
+    "dev:mp-lark": "uni -p mp-lark",
55
+    "dev:mp-qq": "uni -p mp-qq",
56
+    "dev:mp-toutiao": "uni -p mp-toutiao",
57
+    "dev:mp-weixin": "uni -p mp-weixin",
58
+    "dev:mp-xhs": "uni -p mp-xhs",
59
+    "dev:quickapp-webview": "uni -p quickapp-webview",
60
+    "dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
61
+    "dev:quickapp-webview-union": "uni -p quickapp-webview-union",
62
+    "build:app": "uni build -p app",
63
+    "build:app:test": "uni build -p app --mode test",
64
+    "build:app:prod": "uni build -p app --mode production",
65
+    "build:app-android": "uni build -p app-android",
66
+    "build:app-ios": "uni build -p app-ios",
67
+    "build:custom": "uni build -p",
68
+    "build:h5": "uni build",
69
+    "build:h5:test": "uni build --mode test",
70
+    "build:h5:prod": "uni build --mode production",
71
+    "build": "uni build",
72
+    "build:test": "uni build --mode test",
73
+    "build:prod": "uni build --mode production",
74
+    "build:h5:ssr": "uni build --ssr",
75
+    "build:mp-alipay": "uni build -p mp-alipay",
76
+    "build:mp": "uni build -p mp-weixin",
77
+    "build:mp:test": "uni build -p mp-weixin --mode test",
78
+    "build:mp:prod": "uni build -p mp-weixin --mode production",
79
+    "build:mp-baidu": "uni build -p mp-baidu",
80
+    "build:mp-jd": "uni build -p mp-jd",
81
+    "build:mp-kuaishou": "uni build -p mp-kuaishou",
82
+    "build:mp-lark": "uni build -p mp-lark",
83
+    "build:mp-qq": "uni build -p mp-qq",
84
+    "build:mp-toutiao": "uni build -p mp-toutiao",
85
+    "build:mp-weixin": "uni build -p mp-weixin",
86
+    "build:mp-xhs": "uni build -p mp-xhs",
87
+    "build:quickapp-webview": "uni build -p quickapp-webview",
88
+    "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
89
+    "build:quickapp-webview-union": "uni build -p quickapp-webview-union",
90
+    "type-check": "vue-tsc --noEmit",
91
+    "openapi": "openapi-ts",
92
+    "init-husky": "git init && husky",
93
+    "init-baseFiles": "node ./scripts/create-base-files.js",
94
+    "init-json": "pnpm init-baseFiles",
95
+    "prepare": "pnpm init-husky & pnpm init-baseFiles",
96
+    "lint": "eslint",
97
+    "lint:fix": "eslint --fix"
98
+  },
99
+  "dependencies": {
100
+    "@alova/adapter-uniapp": "^2.0.14",
101
+    "@alova/shared": "^1.3.1",
102
+    "@dcloudio/uni-app": "3.0.0-4080520251106001",
103
+    "@dcloudio/uni-app-harmony": "3.0.0-4080520251106001",
104
+    "@dcloudio/uni-app-plus": "3.0.0-4080520251106001",
105
+    "@dcloudio/uni-components": "3.0.0-4080520251106001",
106
+    "@dcloudio/uni-h5": "3.0.0-4080520251106001",
107
+    "@dcloudio/uni-mp-alipay": "3.0.0-4080520251106001",
108
+    "@dcloudio/uni-mp-baidu": "3.0.0-4080520251106001",
109
+    "@dcloudio/uni-mp-harmony": "3.0.0-4080520251106001",
110
+    "@dcloudio/uni-mp-jd": "3.0.0-4080520251106001",
111
+    "@dcloudio/uni-mp-kuaishou": "3.0.0-4080520251106001",
112
+    "@dcloudio/uni-mp-lark": "3.0.0-4080520251106001",
113
+    "@dcloudio/uni-mp-qq": "3.0.0-4080520251106001",
114
+    "@dcloudio/uni-mp-toutiao": "3.0.0-4080520251106001",
115
+    "@dcloudio/uni-mp-weixin": "3.0.0-4080520251106001",
116
+    "@dcloudio/uni-mp-xhs": "3.0.0-4080520251106001",
117
+    "@dcloudio/uni-quickapp-webview": "3.0.0-4080520251106001",
118
+    "abortcontroller-polyfill": "^1.7.8",
119
+    "alova": "^3.3.3",
120
+    "dayjs": "1.11.10",
121
+    "js-cookie": "^3.0.5",
122
+    "pinia": "2.0.36",
123
+    "pinia-plugin-persistedstate": "3.2.1",
124
+    "vue": "^3.4.21",
125
+    "vue-i18n": "^9.1.9",
126
+    "vue-router": "4.5.1",
127
+    "wot-design-uni": "latest",
128
+    "z-paging": "2.8.7"
129
+  },
130
+  "devDependencies": {
131
+    "@commitlint/cli": "^19.8.1",
132
+    "@commitlint/config-conventional": "^19.8.1",
133
+    "@dcloudio/types": "^3.4.8",
134
+    "@dcloudio/uni-automator": "3.0.0-4080520251106001",
135
+    "@dcloudio/uni-cli-shared": "3.0.0-4080520251106001",
136
+    "@dcloudio/uni-stacktracey": "3.0.0-4080520251106001",
137
+    "@dcloudio/vite-plugin-uni": "3.0.0-4080520251106001",
138
+    "@esbuild/darwin-arm64": "0.20.2",
139
+    "@esbuild/darwin-x64": "0.20.2",
140
+    "@iconify-json/carbon": "^1.2.4",
141
+    "@iconify/utils": "^3.0.2",
142
+    "@rollup/rollup-darwin-x64": "^4.28.0",
143
+    "@types/node": "^20.17.9",
144
+    "@uni-helper/eslint-config": "0.5.0",
145
+    "@uni-helper/plugin-uni": "0.1.0",
146
+    "@uni-helper/uni-app-types": "1.0.0-alpha.6",
147
+    "@uni-helper/uni-env": "0.1.8",
148
+    "@uni-helper/uni-types": "1.0.0-alpha.6",
149
+    "@uni-helper/unocss-preset-uni": "0.2.11",
150
+    "@uni-helper/vite-plugin-uni-components": "0.2.3",
151
+    "@uni-helper/vite-plugin-uni-layouts": "0.1.11",
152
+    "@uni-helper/vite-plugin-uni-manifest": "0.2.8",
153
+    "@uni-helper/vite-plugin-uni-pages": "0.3.19",
154
+    "@uni-helper/vite-plugin-uni-platform": "0.0.5",
155
+    "@uni-ku/bundle-optimizer": "v1.3.15-beta.2",
156
+    "@uni-ku/root": "1.4.1",
157
+    "@unocss/eslint-plugin": "^66.2.3",
158
+    "@unocss/preset-legacy-compat": "66.0.0",
159
+    "@unocss/transformer-directives": "^66.5.5",
160
+    "@vue/runtime-core": "^3.4.21",
161
+    "@vue/tsconfig": "^0.1.3",
162
+    "autoprefixer": "^10.4.20",
163
+    "cross-env": "^10.0.0",
164
+    "eslint": "^9.31.0",
165
+    "eslint-config-prettier": "^10.1.8",
166
+    "eslint-plugin-format": "^1.0.1",
167
+    "husky": "^9.1.7",
168
+    "lint-staged": "^15.2.10",
169
+    "miniprogram-api-typings": "^4.1.0",
170
+    "openapi-ts-request": "^1.10.0",
171
+    "postcss": "^8.4.49",
172
+    "postcss-html": "^1.8.0",
173
+    "postcss-scss": "^4.0.9",
174
+    "prettier": "^3.6.2",
175
+    "rollup-plugin-visualizer": "^6.0.3",
176
+    "sass": "1.77.8",
177
+    "std-env": "^3.9.0",
178
+    "typescript": "~5.8.0",
179
+    "unocss": "66.0.0",
180
+    "unplugin-auto-import": "^20.0.0",
181
+    "vite": "5.2.8",
182
+    "vite-plugin-restart": "^1.0.0",
183
+    "vue-tsc": "^3.0.6"
184
+  },
185
+  "pnpm": {
186
+    "overrides": {
187
+      "unconfig": "7.3.2"
188
+    }
189
+  },
190
+  "overrides": {
191
+    "unconfig": "7.3.2"
192
+  },
193
+  "resolutions": {
194
+    "bin-wrapper": "npm:bin-wrapper-china",
195
+    "unconfig": "7.3.2"
196
+  },
197
+  "lint-staged": {
198
+    "*": "eslint --fix"
199
+  }
200
+}

+ 23 - 0
pages.config.ts

@@ -0,0 +1,23 @@
1
+import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
2
+import { tabBar } from './src/tabbar/config'
3
+
4
+export default defineUniPages({
5
+  globalStyle: {
6
+    navigationStyle: 'default',
7
+    navigationBarTitleText: 'unibest',
8
+    navigationBarBackgroundColor: '#f8f8f8',
9
+    navigationBarTextStyle: 'black',
10
+    backgroundColor: '#FFFFFF',
11
+  },
12
+  easycom: {
13
+    autoscan: true,
14
+    custom: {
15
+      '^fg-(.*)': '@/components/fg-$1/fg-$1.vue',
16
+      '^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
17
+        'z-paging/components/z-paging$1/z-paging$1.vue',
18
+      '^wd-(.*)': 'wot-design-uni/components/wd-$1/wd-$1.vue',
19
+    },
20
+  },
21
+  // tabbar 的配置统一在 “./src/tabbar/config.ts” 文件中
22
+  tabBar: tabBar as any,
23
+})

File diff suppressed because it is too large
+ 14286 - 0
pnpm-lock.yaml


+ 53 - 0
scripts/create-base-files.js

@@ -0,0 +1,53 @@
1
+// 基础配置文件生成脚本
2
+// 此脚本用于生成 src/manifest.json 和 src/pages.json 基础文件
3
+// 由于这两个配置文件会被添加到 .gitignore 中,因此需要通过此脚本确保项目能正常运行
4
+import fs from 'node:fs'
5
+import path from 'node:path'
6
+import { fileURLToPath } from 'node:url'
7
+
8
+// 获取当前文件的目录路径(替代 CommonJS 中的 __dirname)
9
+const __filename = fileURLToPath(import.meta.url)
10
+const __dirname = path.dirname(__filename)
11
+
12
+// 最简可运行配置
13
+const manifest = { }
14
+const pages = {
15
+  pages: [
16
+    {
17
+      path: 'pages/index/index',
18
+      type: 'home',
19
+      style: {
20
+        navigationStyle: 'custom',
21
+        navigationBarTitleText: '首页',
22
+      },
23
+    },
24
+    {
25
+      path: 'pages/me/me',
26
+      type: 'page',
27
+      style: {
28
+        navigationBarTitleText: '我的',
29
+      },
30
+    },
31
+  ],
32
+  subPackages: [],
33
+}
34
+
35
+// 使用修复后的 __dirname 来解析文件路径
36
+const manifestPath = path.resolve(__dirname, '../src/manifest.json')
37
+const pagesPath = path.resolve(__dirname, '../src/pages.json')
38
+
39
+// 确保 src 目录存在
40
+const srcDir = path.resolve(__dirname, '../src')
41
+if (!fs.existsSync(srcDir)) {
42
+  fs.mkdirSync(srcDir, { recursive: true })
43
+}
44
+
45
+// 如果 src/manifest.json 不存在,就创建它;存在就不处理,以免覆盖
46
+if (!fs.existsSync(manifestPath) || fs.statSync(manifestPath).size === 0) {
47
+  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
48
+}
49
+
50
+// 如果 src/pages.json 不存在,就创建它;存在就不处理,以免覆盖
51
+if (!fs.existsSync(pagesPath) || fs.statSync(pagesPath).size === 0) {
52
+  fs.writeFileSync(pagesPath, JSON.stringify(pages, null, 2))
53
+}

+ 83 - 0
scripts/open-dev-tools.js

@@ -0,0 +1,83 @@
1
+import { exec } from 'node:child_process'
2
+import fs from 'node:fs'
3
+import path from 'node:path'
4
+import process from 'node:process'
5
+
6
+/**
7
+ * 打开开发者工具
8
+ */
9
+function _openDevTools() {
10
+  const platform = process.platform // darwin, win32, linux
11
+  const { UNI_PLATFORM } = process.env //  mp-weixin, mp-alipay
12
+
13
+  const uniPlatformText = UNI_PLATFORM === 'mp-weixin' ? '微信小程序' : UNI_PLATFORM === 'mp-alipay' ? '支付宝小程序' : '小程序'
14
+
15
+  // 项目路径(构建输出目录)
16
+  const projectPath = path.resolve(process.cwd(), `dist/dev/${UNI_PLATFORM}`)
17
+
18
+  // 检查构建输出目录是否存在
19
+  if (!fs.existsSync(projectPath)) {
20
+    console.log(`❌ ${uniPlatformText}构建目录不存在:`, projectPath)
21
+    return
22
+  }
23
+
24
+  console.log(`🚀 正在打开${uniPlatformText}开发者工具...`)
25
+
26
+  // 根据不同操作系统执行不同命令
27
+  let command = ''
28
+
29
+  if (platform === 'darwin') {
30
+    // macOS
31
+    if (UNI_PLATFORM === 'mp-weixin') {
32
+      command = `/Applications/wechatwebdevtools.app/Contents/MacOS/cli -o "${projectPath}"`
33
+    }
34
+    else if (UNI_PLATFORM === 'mp-alipay') {
35
+      command = `/Applications/小程序开发者工具.app/Contents/MacOS/小程序开发者工具 --p "${projectPath}"`
36
+    }
37
+  }
38
+  else if (platform === 'win32' || platform === 'win64') {
39
+    // Windows
40
+    if (UNI_PLATFORM === 'mp-weixin') {
41
+      command = `"C:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat" -o "${projectPath}"`
42
+    }
43
+  }
44
+  else {
45
+    // Linux 或其他系统
46
+    console.log('❌ 当前系统不支持自动打开微信开发者工具')
47
+    return
48
+  }
49
+
50
+  exec(command, (error, stdout, stderr) => {
51
+    if (error) {
52
+      console.log(`❌ 打开${uniPlatformText}开发者工具失败:`, error.message)
53
+      console.log(`💡 请确保${uniPlatformText}开发者工具服务端口已启用`)
54
+      console.log(`💡 可以手动打开${uniPlatformText}开发者工具并导入项目:`, projectPath)
55
+      return
56
+    }
57
+
58
+    if (stderr) {
59
+      console.log('⚠️ 警告:', stderr)
60
+    }
61
+
62
+    console.log(`✅ ${uniPlatformText}开发者工具已打开`)
63
+
64
+    if (stdout) {
65
+      console.log(stdout)
66
+    }
67
+  })
68
+}
69
+
70
+export default function openDevTools() {
71
+  // 首次构建标记
72
+  let isFirstBuild = true
73
+
74
+  return {
75
+    name: 'uni-devtools',
76
+    writeBundle() {
77
+      if (isFirstBuild && process.env.UNI_PLATFORM?.includes('mp')) {
78
+        isFirstBuild = false
79
+        _openDevTools()
80
+      }
81
+    },
82
+  }
83
+}

+ 95 - 0
scripts/postupgrade.js

@@ -0,0 +1,95 @@
1
+// # 执行 `pnpm upgrade` 后会升级 `uniapp` 相关依赖
2
+// # 在升级完后,会自动添加很多无用依赖,这需要删除以减小依赖包体积
3
+// # 只需要执行下面的命令即可
4
+
5
+import { exec } from 'node:child_process'
6
+import { promisify } from 'node:util'
7
+
8
+// 日志控制开关,设置为 true 可以启用所有日志输出
9
+const FG_LOG_ENABLE = true
10
+
11
+// 将 exec 转换为返回 Promise 的函数
12
+const execPromise = promisify(exec)
13
+
14
+// 定义要执行的命令
15
+const dependencies = [
16
+  // TODO: 如果不需要某个平台的小程序,请手动删除或注释掉
17
+  '@dcloudio/uni-mp-baidu',
18
+  '@dcloudio/uni-mp-jd',
19
+  '@dcloudio/uni-mp-kuaishou',
20
+  '@dcloudio/uni-mp-qq',
21
+  '@dcloudio/uni-mp-xhs',
22
+  '@dcloudio/uni-quickapp-webview',
23
+]
24
+
25
+/**
26
+ * 带开关的日志输出函数
27
+ * @param {string} message 日志消息
28
+ * @param {string} type 日志类型 (log, error)
29
+ */
30
+function log(message, type = 'log') {
31
+  if (FG_LOG_ENABLE) {
32
+    if (type === 'error') {
33
+      console.error(message)
34
+    }
35
+    else {
36
+      console.log(message)
37
+    }
38
+  }
39
+}
40
+
41
+/**
42
+ * 卸载单个依赖包
43
+ * @param {string} dep 依赖包名
44
+ * @returns {Promise<boolean>} 是否成功卸载
45
+ */
46
+async function uninstallDependency(dep) {
47
+  try {
48
+    log(`开始卸载依赖: ${dep}`)
49
+    const { stdout, stderr } = await execPromise(`pnpm un ${dep}`)
50
+    if (stdout) {
51
+      log(`stdout [${dep}]: ${stdout}`)
52
+    }
53
+    if (stderr) {
54
+      log(`stderr [${dep}]: ${stderr}`, 'error')
55
+    }
56
+    log(`成功卸载依赖: ${dep}`)
57
+    return true
58
+  }
59
+  catch (error) {
60
+    // 单个依赖卸载失败不影响其他依赖
61
+    log(`卸载依赖 ${dep} 失败: ${error.message}`, 'error')
62
+    return false
63
+  }
64
+}
65
+
66
+/**
67
+ * 串行卸载所有依赖包
68
+ */
69
+async function uninstallAllDependencies() {
70
+  log(`开始串行卸载 ${dependencies.length} 个依赖包...`)
71
+
72
+  let successCount = 0
73
+  let failedCount = 0
74
+
75
+  // 串行执行所有卸载命令
76
+  for (const dep of dependencies) {
77
+    const success = await uninstallDependency(dep)
78
+    if (success) {
79
+      successCount++
80
+    }
81
+    else {
82
+      failedCount++
83
+    }
84
+
85
+    // 为了避免命令执行过快导致的问题,添加短暂延迟
86
+    await new Promise(resolve => setTimeout(resolve, 100))
87
+  }
88
+
89
+  log(`卸载操作完成: 成功 ${successCount} 个, 失败 ${failedCount} 个`)
90
+}
91
+
92
+// 执行串行卸载
93
+uninstallAllDependencies().catch((err) => {
94
+  log(`串行卸载过程中出现未捕获的错误: ${err}`, 'error')
95
+})

+ 41 - 0
src/App.ku.vue

@@ -0,0 +1,41 @@
1
+<script setup lang="ts">
2
+import { ref } from 'vue'
3
+import FgTabbar from '@/tabbar/index.vue'
4
+import { isPageTabbar } from './tabbar/store'
5
+import { currRoute } from './utils'
6
+
7
+const isCurrentPageTabbar = ref(true)
8
+onShow(() => {
9
+  console.log('App.ku.vue onShow', currRoute())
10
+  const { path } = currRoute()
11
+  // “蜡笔小开心”提到本地是 '/pages/index/index',线上是 '/' 导致线上 tabbar 不见了
12
+  // 所以这里需要判断一下,如果是 '/' 就当做首页,也要显示 tabbar
13
+  if (path === '/') {
14
+    isCurrentPageTabbar.value = true
15
+  }
16
+  else {
17
+    isCurrentPageTabbar.value = isPageTabbar(path)
18
+  }
19
+})
20
+
21
+const helloKuRoot = ref('Hello AppKuVue')
22
+
23
+const exposeRef = ref('this is form app.Ku.vue')
24
+
25
+defineExpose({
26
+  exposeRef,
27
+})
28
+</script>
29
+
30
+<template>
31
+  <view>
32
+    <!-- 这个先隐藏了,知道这样用就行 -->
33
+    <view class="hidden text-center">
34
+      {{ helloKuRoot }},这里可以配置全局的东西
35
+    </view>
36
+
37
+    <KuRootView />
38
+
39
+    <FgTabbar v-if="isCurrentPageTabbar" />
40
+  </view>
41
+</template>

+ 56 - 0
src/App.vue

@@ -0,0 +1,56 @@
1
+<script setup lang="ts">
2
+import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
3
+import { navigateToInterceptor } from '@/router/interceptor'
4
+import { useTokenStore } from '@/store/token'
5
+import { useAuthStore } from '@/store/auth'
6
+
7
+onLaunch((options) => {
8
+  console.log('App.vue onLaunch', options)
9
+  // 初始化 store
10
+  const tokenStore = useTokenStore()
11
+  const authStore = useAuthStore()
12
+  
13
+  // 钉钉免登录逻辑
14
+  async function handleDingTalkAutoLogin() {
15
+    try {
16
+      // 检查是否已经有有效的token
17
+      if (!tokenStore.isTokenExpired.value) {
18
+        console.log('已有有效登录状态,无需重新登录');
19
+        return;
20
+      }
21
+      
22
+      console.log('尝试钉钉免登录...');
23
+      // 尝试钉钉免登录
24
+      await authStore.authDingTalkLogin();
25
+      console.log('钉钉免登录成功');
26
+    }
27
+    catch (error) {
28
+      console.error('钉钉免登录失败,将跳转到登录页面:', error);
29
+      // 免登录失败,跳转到登录页面
30
+      // 注意:这里不立即跳转,避免影响应用启动性能
31
+      // 路由拦截器会在页面跳转时处理未登录情况
32
+    }
33
+  }
34
+  
35
+  // 执行钉钉免登录
36
+  handleDingTalkAutoLogin();
37
+})
38
+onShow((options) => {
39
+  console.log('App.vue onShow', options)
40
+  // 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
41
+  // https://github.com/unibest-tech/unibest/issues/192
42
+  if (options?.path) {
43
+    navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
44
+  }
45
+  else {
46
+    navigateToInterceptor.invoke({ url: '/' })
47
+  }
48
+})
49
+onHide(() => {
50
+  console.log('App Hide')
51
+})
52
+</script>
53
+
54
+<style lang="scss">
55
+
56
+</style>

+ 28 - 0
src/api/auth.ts

@@ -0,0 +1,28 @@
1
+import type { IAuthLoginRes, ICaptcha } from '@/api/types/login'
2
+import { http } from '@/http/http'
3
+
4
+/**
5
+ * 登录表单
6
+ */
7
+export interface ILoginForm {
8
+  username: string
9
+  password: string
10
+  code?: string
11
+  uuid?: string
12
+}
13
+
14
+/**
15
+ * 获取验证码
16
+ * @returns ICaptcha 验证码
17
+ */
18
+export function getCaptcha() {
19
+  return http.get<ICaptcha>('/captchaImage')
20
+}
21
+
22
+/**
23
+ * 用户登录
24
+ * @param loginForm 登录表单
25
+ */
26
+export function login(loginForm: ILoginForm) {
27
+  return http.post<IAuthLoginRes>('/login', loginForm)
28
+}

+ 17 - 0
src/api/foo-alova.ts

@@ -0,0 +1,17 @@
1
+import { API_DOMAINS, http } from '@/http/alova'
2
+
3
+export interface IFoo {
4
+  id: number
5
+  name: string
6
+}
7
+
8
+export function foo() {
9
+  return http.Get<IFoo>('/foo', {
10
+    params: {
11
+      name: '菲鸽',
12
+      page: 1,
13
+      pageSize: 10,
14
+    },
15
+    meta: { domain: API_DOMAINS.SECONDARY }, // 用于切换请求地址
16
+  })
17
+}

+ 43 - 0
src/api/foo.ts

@@ -0,0 +1,43 @@
1
+import { http } from '@/http/http'
2
+
3
+export interface IFoo {
4
+  id: number
5
+  name: string
6
+}
7
+
8
+export function foo() {
9
+  return http.Get<IFoo>('/foo', {
10
+    params: {
11
+      name: '菲鸽',
12
+      page: 1,
13
+      pageSize: 10,
14
+    },
15
+  })
16
+}
17
+
18
+export interface IFooItem {
19
+  id: string
20
+  name: string
21
+}
22
+
23
+/** GET 请求 */
24
+export async function getFooAPI(name: string) {
25
+  return await http.get<IFooItem>('/foo', { name })
26
+}
27
+/** GET 请求;支持 传递 header 的范例 */
28
+export function getFooAPI2(name: string) {
29
+  return http.get<IFooItem>('/foo', { name }, { 'Content-Type-100': '100' })
30
+}
31
+
32
+/** POST 请求 */
33
+export function postFooAPI(name: string) {
34
+  return http.post<IFooItem>('/foo', { name })
35
+}
36
+/** POST 请求;需要传递 query 参数的范例;微信小程序经常有同时需要query参数和body参数的场景 */
37
+export function postFooAPI2(name: string) {
38
+  return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 })
39
+}
40
+/** POST 请求;支持 传递 header 的范例 */
41
+export function postFooAPI3(name: string) {
42
+  return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 }, { 'Content-Type-100': '100' })
43
+}

+ 117 - 0
src/api/login.ts

@@ -0,0 +1,117 @@
1
+import type { IAuthLoginRes, ICaptcha, IDoubleTokenRes, IUpdateInfo, IUpdatePassword, IUserInfoRes } from './types/login'
2
+import { http } from '@/http/http'
3
+
4
+/**
5
+ * 登录表单
6
+ */
7
+export interface ILoginForm {
8
+  username: string
9
+  password: string
10
+  code: string
11
+  uuid: string
12
+}
13
+
14
+/**
15
+ * 获取验证码
16
+ * @returns ICaptcha 验证码
17
+ */
18
+export function getCaptcha() {
19
+  return http.get<ICaptcha>('/captchaImage')
20
+}
21
+
22
+/**
23
+ * 用户登录
24
+ * @param loginForm 登录表单
25
+ */
26
+export function login(loginForm: ILoginForm) {
27
+  return http.post<IAuthLoginRes>('/login', loginForm)
28
+}
29
+
30
+/**
31
+ * 刷新token
32
+ * @param refreshToken 刷新token
33
+ */
34
+export function refreshToken(refreshToken: string) {
35
+  return http.post<IDoubleTokenRes>('/auth/refreshToken', { refreshToken })
36
+}
37
+
38
+/**
39
+ * 获取用户信息
40
+ */
41
+export function getUserInfo() {
42
+  return http.get<IUserInfoRes>('/getInfo')
43
+}
44
+
45
+/**
46
+ * 退出登录
47
+ */
48
+export function logout() {
49
+  return http.get<void>('/logout')
50
+}
51
+
52
+/**
53
+ * 修改用户信息
54
+ */
55
+export function updateInfo(data: IUpdateInfo) {
56
+  return http.post('/user/updateInfo', data)
57
+}
58
+
59
+/**
60
+ * 修改用户密码
61
+ */
62
+export function updateUserPassword(data: IUpdatePassword) {
63
+  return http.post('/user/updatePassword', data)
64
+}
65
+
66
+/**
67
+ * 获取微信登录凭证
68
+ * @returns Promise 包含微信登录凭证(code)
69
+ */
70
+export function getWxCode() {
71
+  return new Promise<UniApp.LoginRes>((resolve, reject) => {
72
+    uni.login({
73
+      provider: 'weixin',
74
+      success: res => resolve(res),
75
+      fail: err => reject(new Error(err)),
76
+    })
77
+  })
78
+}
79
+
80
+/**
81
+ * 微信登录
82
+ * @param params 微信登录参数,包含code
83
+ * @returns Promise 包含登录结果
84
+ */
85
+export function wxLogin(data: { code: string }) {
86
+  return http.post<IAuthLoginRes>('/auth/wxLogin', data)
87
+}
88
+
89
+/**
90
+ * 获取钉钉登录凭证
91
+ * @returns Promise 包含钉钉登录凭证(code)
92
+ */
93
+export function getDingTalkCode() {
94
+  return new Promise<{ code: string }>((resolve, reject) => {
95
+    // #ifdef H5
96
+    // H5环境下,模拟获取code,实际项目中可能需要通过URL参数或其他方式获取
97
+    console.warn('H5环境下,需要手动传入钉钉code');
98
+    resolve({ code: 'mock-dingtalk-code' });
99
+    // #endif
100
+    // #ifdef APP-PLUS
101
+    // 钉钉小程序环境下,调用钉钉SDK获取code
102
+    plus.dingtalk.getAuthCode({
103
+      success: (res: { code: string }) => resolve(res),
104
+      fail: (err: any) => reject(new Error(JSON.stringify(err)))
105
+    });
106
+    // #endif
107
+  })
108
+}
109
+
110
+/**
111
+ * 钉钉登录
112
+ * @param authCode 钉钉授权码
113
+ * @returns Promise 包含登录结果
114
+ */
115
+export function dingTalkLogin(authCode: string) {
116
+  return http.get<IAuthLoginRes>(`/dingTalkLogin/${authCode}`)
117
+}

+ 105 - 0
src/api/types/login.ts

@@ -0,0 +1,105 @@
1
+// 认证模式类型
2
+export type AuthMode = 'single' | 'double'
3
+
4
+// 单Token响应类型
5
+export interface ISingleTokenRes {
6
+  token: string
7
+  expiresIn: number // 有效期(秒)
8
+}
9
+
10
+// 双Token响应类型
11
+export interface IDoubleTokenRes {
12
+  accessToken: string
13
+  refreshToken: string
14
+  accessExpiresIn: number // 访问令牌有效期(秒)
15
+  refreshExpiresIn: number // 刷新令牌有效期(秒)
16
+}
17
+
18
+/**
19
+ * 登录返回的信息,其实就是 token 信息
20
+ */
21
+export type IAuthLoginRes = ISingleTokenRes | IDoubleTokenRes
22
+
23
+/**
24
+ * 用户信息
25
+ */
26
+export interface IUserInfoRes {
27
+  userId: number
28
+  username: string
29
+  nickname: string
30
+  avatar?: string
31
+  [key: string]: any // 允许其他扩展字段
32
+}
33
+
34
+// 认证存储数据结构
35
+export interface AuthStorage {
36
+  mode: AuthMode
37
+  tokens: ISingleTokenRes | IDoubleTokenRes
38
+  userInfo?: IUserInfoRes
39
+  loginTime: number // 登录时间戳
40
+}
41
+
42
+/**
43
+ * 获取验证码
44
+ */
45
+export interface ICaptcha {
46
+  captchaEnabled: boolean
47
+  uuid: string
48
+  img: string
49
+}
50
+/**
51
+ * 上传成功的信息
52
+ */
53
+export interface IUploadSuccessInfo {
54
+  fileId: number
55
+  originalName: string
56
+  fileName: string
57
+  storagePath: string
58
+  fileHash: string
59
+  fileType: string
60
+  fileBusinessType: string
61
+  fileSize: number
62
+}
63
+/**
64
+ * 更新用户信息
65
+ */
66
+export interface IUpdateInfo {
67
+  id: number
68
+  name: string
69
+  sex: string
70
+}
71
+/**
72
+ * 更新用户信息
73
+ */
74
+export interface IUpdatePassword {
75
+  id: number
76
+  oldPassword: string
77
+  newPassword: string
78
+  confirmPassword: string
79
+}
80
+
81
+/**
82
+ * 判断是否为单Token响应
83
+ * @param tokenRes 登录响应数据
84
+ * @returns 是否为单Token响应
85
+ */
86
+export function isSingleTokenRes(tokenRes: IAuthLoginRes | string): tokenRes is ISingleTokenRes {
87
+  // 如果是字符串,不是单token对象
88
+  if (typeof tokenRes === 'string') {
89
+    return false
90
+  }
91
+  return 'token' in tokenRes && !('refreshToken' in tokenRes)
92
+}
93
+
94
+/**
95
+ * 判断是否为双Token响应
96
+ * @param tokenRes 登录响应数据
97
+ * @returns 是否为双Token响应
98
+ */
99
+export function isDoubleTokenRes(tokenRes: IAuthLoginRes | string): tokenRes is IDoubleTokenRes {
100
+  // 如果是字符串,不是双token对象
101
+  if (typeof tokenRes === 'string') {
102
+    return false
103
+  }
104
+  return 'accessToken' in tokenRes && 'refreshToken' in tokenRes
105
+}

+ 0 - 0
src/components/.gitkeep


+ 35 - 0
src/env.d.ts

@@ -0,0 +1,35 @@
1
+/// <reference types="vite/client" />
2
+/// <reference types="vite-svg-loader" />
3
+
4
+declare module '*.vue' {
5
+  import type { DefineComponent } from 'vue'
6
+
7
+  const component: DefineComponent<{}, {}, any>
8
+  export default component
9
+}
10
+
11
+interface ImportMetaEnv {
12
+  /** 网站标题,应用名称 */
13
+  readonly VITE_APP_TITLE: string
14
+  /** 服务端口号 */
15
+  readonly VITE_SERVER_PORT: string
16
+  /** 后台接口地址 */
17
+  readonly VITE_SERVER_BASEURL: string
18
+  /** H5是否需要代理 */
19
+  readonly VITE_APP_PROXY_ENABLE: 'true' | 'false'
20
+  /** H5是否需要代理,需要的话有个前缀 */
21
+  readonly VITE_APP_PROXY_PREFIX: string
22
+  /** 后端是否有统一前缀 /api */
23
+  readonly VITE_SERVER_HAS_API_PREFIX: 'true' | 'false'
24
+  /** 认证模式,'single' | 'double' ==> 单token | 双token */
25
+  readonly VITE_AUTH_MODE: 'single' | 'double'
26
+  /** 是否清除console */
27
+  readonly VITE_DELETE_CONSOLE: string
28
+  // 更多环境变量...
29
+}
30
+
31
+interface ImportMeta {
32
+  readonly env: ImportMetaEnv
33
+}
34
+
35
+declare const __VITE_APP_PROXY__: 'true' | 'false'

+ 54 - 0
src/hooks/useRequest.ts

@@ -0,0 +1,54 @@
1
+import type { Ref } from 'vue'
2
+import { ref } from 'vue'
3
+
4
+interface IUseRequestOptions<T> {
5
+  /** 是否立即执行 */
6
+  immediate?: boolean
7
+  /** 初始化数据 */
8
+  initialData?: T
9
+}
10
+
11
+interface IUseRequestReturn<T, P = undefined> {
12
+  loading: Ref<boolean>
13
+  error: Ref<boolean | Error>
14
+  data: Ref<T | undefined>
15
+  run: (args?: P) => Promise<T | undefined>
16
+}
17
+
18
+/**
19
+ * useRequest是一个定制化的请求钩子,用于处理异步请求和响应。
20
+ * @param func 一个执行异步请求的函数,返回一个包含响应数据的Promise。
21
+ * @param options 包含请求选项的对象 {immediate, initialData}。
22
+ * @param options.immediate 是否立即执行请求,默认为false。
23
+ * @param options.initialData 初始化数据,默认为undefined。
24
+ * @returns 返回一个对象{loading, error, data, run},包含请求的加载状态、错误信息、响应数据和手动触发请求的函数。
25
+ */
26
+export default function useRequest<T, P = undefined>(
27
+  func: (args?: P) => Promise<T>,
28
+  options: IUseRequestOptions<T> = { immediate: false },
29
+): IUseRequestReturn<T, P> {
30
+  const loading = ref(false)
31
+  const error = ref(false)
32
+  const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
33
+  const run = async (args?: P) => {
34
+    loading.value = true
35
+    return func(args)
36
+      .then((res) => {
37
+        data.value = res
38
+        error.value = false
39
+        return data.value
40
+      })
41
+      .catch((err) => {
42
+        error.value = err
43
+        throw err
44
+      })
45
+      .finally(() => {
46
+        loading.value = false
47
+      })
48
+  }
49
+
50
+  if (options.immediate) {
51
+    (run as (args: P) => Promise<T | undefined>)({} as P)
52
+  }
53
+  return { loading, error, data, run }
54
+}

+ 116 - 0
src/hooks/useScroll.md

@@ -0,0 +1,116 @@
1
+# 上拉刷新和下拉加载更多
2
+
3
+在 unibest 框架中,我们通过组合 `useScroll` Hook 可结合 `scroll-view` 组件来轻松实现上拉刷新和下拉加载更多的功能。
4
+场景一 页面滚动
5
+
6
+```
7
+definePage({
8
+  style: {
9
+    navigationBarTitleText: '上拉刷新和下拉加载更多',
10
+    enablePullDownRefresh: true,
11
+    onReachBottomDistance: 100,
12
+  },
13
+})
14
+```
15
+
16
+场景二 局部滚动 结合 `scroll-view`
17
+
18
+## 关键文件
19
+
20
+- `src/hooks/useScroll.ts`: 提供了核心的滚动逻辑处理 Hook。
21
+- `src/pages-sub/demo/scroll.vue`: 一个具体的实现示例页面。
22
+
23
+## `useScroll` Hook
24
+
25
+`useScroll` 是一个 Vue Composition API Hook,它封装了处理下拉刷新和上拉加载的通用逻辑。
26
+
27
+### 主要功能
28
+
29
+- **管理加载状态**: 自动处理 `loading`(加载中)、`finished`(已加载全部)和 `error`(加载失败)等状态。
30
+- **分页逻辑**: 内部维护分页参数(页码 `page` 和每页数量 `pageSize`)。
31
+- **事件处理**: 提供 `onScrollToLower`(滚动到底部)、`onRefresherRefresh`(下拉刷新)等方法,用于在视图层触发。
32
+- **数据合并**: 自动将新加载的数据追加到现有列表 `list` 中。
33
+
34
+### 使用方法
35
+
36
+```typescript
37
+import { useScroll } from '@/hooks/useScroll'
38
+import { getList } from '@/service/list' // 你的数据请求API
39
+
40
+const {
41
+  list, // 响应式的数据列表
42
+  loading, // 是否加载中
43
+  finished, // 是否已全部加载
44
+  error, // 是否加载失败
45
+  onScrollToLower, // 滚动到底部时触发的事件
46
+  onRefresherRefresh, // 下拉刷新时触发的事件
47
+} = useScroll(getList) // 将获取数据的API函数传入
48
+```
49
+
50
+## `scroll-view` 组件
51
+
52
+`scroll-view` 是 uni-app 提供的可滚动视图区域组件,它提供了一系列属性来支持下拉刷新和上拉加载。
53
+
54
+### 关键属性
55
+
56
+- `scroll-y`: 允许纵向滚动。
57
+- `refresher-enabled`: 启用下拉刷新。
58
+- `refresher-triggered`: 控制下拉刷新动画的显示与隐藏,通过 `loading` 状态绑定。
59
+- `@scrolltolower`: 滚动到底部时触发的事件,绑定 `onScrollToLower` 方法。
60
+- `@refresherrefresh`: 触发下拉刷新时触发的事件,绑定 `onRefresherRefresh` 方法。
61
+
62
+## 示例代码
63
+
64
+以下是 `src/pages-sub/demo/scroll.vue` 中的核心代码,展示了如何将 `useScroll` 和 `scroll-view` 结合使用。
65
+
66
+```vue
67
+<template>
68
+  <view class="scroll-page">
69
+    <scroll-view
70
+      class="scroll-view"
71
+      scroll-y
72
+      :refresher-enabled="true"
73
+      :refresher-triggered="loading"
74
+      @scrolltolower="onScrollToLower"
75
+      @refresherrefresh="onRefresherRefresh"
76
+    >
77
+      <view v-for="item in list" :key="item.id" class="scroll-item">
78
+        {{ item.name }}
79
+      </view>
80
+
81
+      <!-- 加载状态提示 -->
82
+      <view v-if="loading" class="loading-tip">加载中...</view>
83
+      <view v-if="finished" class="finished-tip">没有更多了</view>
84
+      <view v-if="error" class="error-tip">加载失败,请重试</view>
85
+    </scroll-view>
86
+  </view>
87
+</template>
88
+
89
+<script setup lang="ts">
90
+import { useScroll } from '@/hooks/useScroll'
91
+import { getList } from '@/service/list'
92
+
93
+const { list, loading, finished, error, onScrollToLower, onRefresherRefresh } = useScroll(getList)
94
+</script>
95
+
96
+<style scoped>
97
+/* 样式省略 */
98
+.scroll-page, .scroll-view {
99
+  height: 100%;
100
+}
101
+</style>
102
+```
103
+
104
+## 实现步骤总结
105
+
106
+1.  **创建API**: 确保你有一个返回分页数据的API请求函数(例如 `getList`),它应该接受页码和页面大小作为参数。
107
+2.  **调用 `useScroll`**: 在你的页面脚本中,导入并调用 `useScroll` Hook,将你的API函数作为参数传入。
108
+3.  **模板绑定**:
109
+    -   使用 `scroll-view` 组件作为滚动容器。
110
+    -   将其 `refresher-triggered` 属性绑定到 `useScroll` 返回的 `loading` 状态。
111
+    -   将其 `@scrolltolower` 事件绑定到 `onScrollToLower` 方法。
112
+    -   将其 `@refresherrefresh` 事件绑定到 `onRefresherRefresh` 方法。
113
+4.  **渲染列表**: 使用 `v-for` 指令渲染 `useScroll` 返回的 `list` 数组。
114
+5.  **添加加载提示**: 根据 `loading`, `finished`, `error` 状态,在列表底部显示不同的提示信息,提升用户体验。
115
+
116
+通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。

+ 74 - 0
src/hooks/useScroll.ts

@@ -0,0 +1,74 @@
1
+import type { Ref } from 'vue'
2
+import { onMounted, ref } from 'vue'
3
+
4
+interface UseScrollOptions<T> {
5
+  fetchData: (page: number, pageSize: number) => Promise<T[]>
6
+  pageSize?: number
7
+}
8
+
9
+interface UseScrollReturn<T> {
10
+  list: Ref<T[]>
11
+  loading: Ref<boolean>
12
+  finished: Ref<boolean>
13
+  error: Ref<any>
14
+  refresh: () => Promise<void>
15
+  loadMore: () => Promise<void>
16
+}
17
+
18
+export function useScroll<T>({
19
+  fetchData,
20
+  pageSize = 10,
21
+}: UseScrollOptions<T>): UseScrollReturn<T> {
22
+  const list = ref<T[]>([]) as Ref<T[]>
23
+  const loading = ref(false)
24
+  const finished = ref(false)
25
+  const error = ref<any>(null)
26
+  const page = ref(1)
27
+
28
+  const loadData = async () => {
29
+    if (loading.value || finished.value)
30
+      return
31
+
32
+    loading.value = true
33
+    error.value = null
34
+
35
+    try {
36
+      const data = await fetchData(page.value, pageSize)
37
+      if (data.length < pageSize) {
38
+        finished.value = true
39
+      }
40
+      list.value.push(...data)
41
+      page.value++
42
+    }
43
+    catch (err) {
44
+      error.value = err
45
+    }
46
+    finally {
47
+      loading.value = false
48
+    }
49
+  }
50
+
51
+  const refresh = async () => {
52
+    page.value = 1
53
+    finished.value = false
54
+    list.value = []
55
+    await loadData()
56
+  }
57
+
58
+  const loadMore = async () => {
59
+    await loadData()
60
+  }
61
+
62
+  onMounted(() => {
63
+    refresh()
64
+  })
65
+
66
+  return {
67
+    list,
68
+    loading,
69
+    finished,
70
+    error,
71
+    refresh,
72
+    loadMore,
73
+  }
74
+}

+ 171 - 0
src/hooks/useUpload.ts

@@ -0,0 +1,171 @@
1
+import { ref } from 'vue'
2
+import { getEnvBaseUrl } from '@/utils/index'
3
+
4
+const VITE_UPLOAD_BASEURL = `${getEnvBaseUrl()}/upload`
5
+
6
+type TfileType = 'image' | 'file'
7
+type TImage = 'png' | 'jpg' | 'jpeg' | 'webp' | '*'
8
+type TFile = 'doc' | 'docx' | 'ppt' | 'zip' | 'xls' | 'xlsx' | 'txt' | TImage
9
+
10
+interface TOptions<T extends TfileType> {
11
+  formData?: Record<string, any>
12
+  maxSize?: number
13
+  accept?: T extends 'image' ? TImage[] : TFile[]
14
+  fileType?: T
15
+  success?: (params: any) => void
16
+  error?: (err: any) => void
17
+}
18
+
19
+export default function useUpload<T extends TfileType>(options: TOptions<T> = {} as TOptions<T>) {
20
+  const {
21
+    formData = {},
22
+    maxSize = 5 * 1024 * 1024,
23
+    accept = ['*'],
24
+    fileType = 'image',
25
+    success,
26
+    error: onError,
27
+  } = options
28
+
29
+  const loading = ref(false)
30
+  const error = ref<Error | null>(null)
31
+  const data = ref<any>(null)
32
+
33
+  const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string, size: number }) => {
34
+    if (size > maxSize) {
35
+      uni.showToast({
36
+        title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`,
37
+        icon: 'none',
38
+      })
39
+      return
40
+    }
41
+
42
+    // const fileExtension = file?.tempFiles?.name?.split('.').pop()?.toLowerCase()
43
+    // const isTypeValid = accept.some((type) => type === '*' || type.toLowerCase() === fileExtension)
44
+
45
+    // if (!isTypeValid) {
46
+    //   uni.showToast({
47
+    //     title: `仅支持 ${accept.join(', ')} 格式的文件`,
48
+    //     icon: 'none',
49
+    //   })
50
+    //   return
51
+    // }
52
+
53
+    loading.value = true
54
+    uploadFile({
55
+      tempFilePath,
56
+      formData,
57
+      onSuccess: (res) => {
58
+        // 修改这里的解析逻辑,适应不同平台的返回格式
59
+        let parsedData = res
60
+        try {
61
+          // 尝试解析为JSON
62
+          const jsonData = JSON.parse(res)
63
+          // 检查是否包含data字段
64
+          parsedData = jsonData.data || jsonData
65
+        }
66
+        catch (e) {
67
+          // 如果解析失败,使用原始数据
68
+          console.log('Response is not JSON, using raw data:', res)
69
+        }
70
+        data.value = parsedData
71
+        // console.log('上传成功', res)
72
+        success?.(parsedData)
73
+      },
74
+      onError: (err) => {
75
+        error.value = err
76
+        onError?.(err)
77
+      },
78
+      onComplete: () => {
79
+        loading.value = false
80
+      },
81
+    })
82
+  }
83
+
84
+  const run = () => {
85
+    // 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
86
+    // 微信小程序在2023年10月17日之后,使用本API需要配置隐私协议
87
+    const chooseFileOptions = {
88
+      count: 1,
89
+      success: (res: any) => {
90
+        console.log('File selected successfully:', res)
91
+        // 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]}
92
+        // h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]}
93
+        // h5的File有以下字段:{name: "girl.jpeg", size: 48976, type: "image/jpeg"}
94
+        // App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]}
95
+        // App的File有以下字段:{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976}
96
+        let tempFilePath = ''
97
+        let size = 0
98
+        // #ifdef MP-WEIXIN
99
+        tempFilePath = res.tempFiles[0].tempFilePath
100
+        size = res.tempFiles[0].size
101
+        // #endif
102
+        // #ifndef MP-WEIXIN
103
+        tempFilePath = res.tempFilePaths[0]
104
+        size = res.tempFiles[0].size
105
+        // #endif
106
+        handleFileChoose({ tempFilePath, size })
107
+      },
108
+      fail: (err: any) => {
109
+        console.error('File selection failed:', err)
110
+        error.value = err
111
+        onError?.(err)
112
+      },
113
+    }
114
+
115
+    if (fileType === 'image') {
116
+      // #ifdef MP-WEIXIN
117
+      uni.chooseMedia({
118
+        ...chooseFileOptions,
119
+        mediaType: ['image'],
120
+      })
121
+      // #endif
122
+
123
+      // #ifndef MP-WEIXIN
124
+      uni.chooseImage(chooseFileOptions)
125
+      // #endif
126
+    }
127
+    else {
128
+      uni.chooseFile({
129
+        ...chooseFileOptions,
130
+        type: 'all',
131
+      })
132
+    }
133
+  }
134
+
135
+  return { loading, error, data, run }
136
+}
137
+
138
+async function uploadFile({
139
+  tempFilePath,
140
+  formData,
141
+  onSuccess,
142
+  onError,
143
+  onComplete,
144
+}: {
145
+  tempFilePath: string
146
+  formData: Record<string, any>
147
+  onSuccess: (data: any) => void
148
+  onError: (err: any) => void
149
+  onComplete: () => void
150
+}) {
151
+  uni.uploadFile({
152
+    url: VITE_UPLOAD_BASEURL,
153
+    filePath: tempFilePath,
154
+    name: 'file',
155
+    formData,
156
+    success: (uploadFileRes) => {
157
+      try {
158
+        const data = uploadFileRes.data
159
+        onSuccess(data)
160
+      }
161
+      catch (err) {
162
+        onError(err)
163
+      }
164
+    },
165
+    fail: (err) => {
166
+      console.error('Upload failed:', err)
167
+      onError(err)
168
+    },
169
+    complete: onComplete,
170
+  })
171
+}

+ 13 - 0
src/http/README.md

@@ -0,0 +1,13 @@
1
+# 请求库
2
+
3
+目前unibest支持3种请求库:
4
+- 菲鸽简单封装的 `简单版本http`,路径(src/http/http.ts),对应的示例在 src/api/foo.ts
5
+- `alova 的 http`,路径(src/http/alova.ts),对应的示例在 src/api/foo-alova.ts
6
+- `vue-query`, 路径(src/http/vue-query.ts), 目前主要用在自动生成接口,详情看(https://unibest.tech/base/17-generate),示例在 src/service/app 文件夹
7
+
8
+## 如何选择
9
+如果您以前用过 alova 或者 vue-query,可以优先使用您熟悉的。
10
+如果您的项目简单,简单版本的http 就够了,也不会增加包体积。(发版的时候可以去掉alova和vue-query,如果没有超过包体积,留着也无所谓 ^_^)
11
+
12
+## roadmap
13
+菲鸽最近在优化脚手架,后续可以选择是否使用第三方的请求库,以及选择什么请求库。还在开发中,大概月底出来(8月31号)。

+ 119 - 0
src/http/alova.ts

@@ -0,0 +1,119 @@
1
+import type { uniappRequestAdapter } from '@alova/adapter-uniapp'
2
+import type { IResponse } from './types'
3
+import AdapterUniapp from '@alova/adapter-uniapp'
4
+import { createAlova } from 'alova'
5
+import { createServerTokenAuthentication } from 'alova/client'
6
+import VueHook from 'alova/vue'
7
+import { toLoginPage } from '@/utils/toLoginPage'
8
+import { ContentTypeEnum, ResultEnum, ShowMessage } from './tools/enum'
9
+
10
+// 配置动态Tag
11
+export const API_DOMAINS = {
12
+  DEFAULT: import.meta.env.VITE_SERVER_BASEURL,
13
+  SECONDARY: import.meta.env.VITE_SERVER_BASEURL_SECONDARY,
14
+}
15
+
16
+/**
17
+ * 创建请求实例
18
+ */
19
+const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<
20
+  typeof VueHook,
21
+  typeof uniappRequestAdapter
22
+>({
23
+  // 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛
24
+  refreshTokenOnError: {
25
+    isExpired: (error) => {
26
+      return error.response?.status === ResultEnum.Unauthorized
27
+    },
28
+    handler: async () => {
29
+      try {
30
+        // await authLogin();
31
+      }
32
+      catch (error) {
33
+        // 切换到登录页
34
+        toLoginPage({ mode: 'reLaunch' })
35
+        throw error
36
+      }
37
+    },
38
+  },
39
+})
40
+
41
+/**
42
+ * alova 请求实例
43
+ */
44
+const alovaInstance = createAlova({
45
+  baseURL: API_DOMAINS.DEFAULT,
46
+  ...AdapterUniapp(),
47
+  timeout: 5000,
48
+  statesHook: VueHook,
49
+
50
+  beforeRequest: onAuthRequired((method) => {
51
+    // 设置默认 Content-Type
52
+    method.config.headers = {
53
+      ContentType: ContentTypeEnum.JSON,
54
+      Accept: 'application/json, text/plain, */*',
55
+      ...method.config.headers,
56
+    }
57
+
58
+    const { config } = method
59
+    const ignoreAuth = !config.meta?.ignoreAuth
60
+    console.log('ignoreAuth===>', ignoreAuth)
61
+    // 处理认证信息   自行处理认证问题
62
+    if (ignoreAuth) {
63
+      const token = 'getToken()'
64
+      if (!token) {
65
+        throw new Error('[请求错误]:未登录')
66
+      }
67
+      // method.config.headers.token = token;
68
+    }
69
+
70
+    // 处理动态域名
71
+    if (config.meta?.domain) {
72
+      method.baseURL = config.meta.domain
73
+      console.log('当前域名', method.baseURL)
74
+    }
75
+  }),
76
+
77
+  responded: onResponseRefreshToken((response, method) => {
78
+    const { config } = method
79
+    const { requestType } = config
80
+    const {
81
+      statusCode,
82
+      data: rawData,
83
+      errMsg,
84
+    } = response as UniNamespace.RequestSuccessCallbackResult
85
+
86
+    // 处理特殊请求类型(上传/下载)
87
+    if (requestType === 'upload' || requestType === 'download') {
88
+      return response
89
+    }
90
+
91
+    // 处理 HTTP 状态码错误
92
+    if (statusCode !== 200) {
93
+      const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]`
94
+      console.error('errorMessage===>', errorMessage)
95
+      uni.showToast({
96
+        title: errorMessage,
97
+        icon: 'error',
98
+      })
99
+      throw new Error(`${errorMessage}:${errMsg}`)
100
+    }
101
+
102
+    // 处理业务逻辑错误
103
+    const { code, message, data } = rawData as IResponse
104
+    // 0和200当做成功都很普遍,这里直接兼容两者,见 ResultEnum
105
+    if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) {
106
+      if (config.meta?.toast !== false) {
107
+        uni.showToast({
108
+          title: message,
109
+          icon: 'none',
110
+        })
111
+      }
112
+      throw new Error(`请求错误[${code}]:${message}`)
113
+    }
114
+    // 处理成功响应,返回业务数据
115
+    return data
116
+  }),
117
+})
118
+
119
+export const http = alovaInstance

+ 199 - 0
src/http/http.ts

@@ -0,0 +1,199 @@
1
+import type { IDoubleTokenRes } from '@/api/types/login'
2
+import type { CustomRequestOptions, IResponse } from '@/http/types'
3
+import { nextTick } from 'vue'
4
+import { useTokenStore } from '@/store/token'
5
+import { isDoubleTokenMode } from '@/utils'
6
+import { toLoginPage } from '@/utils/toLoginPage'
7
+import { ResultEnum } from './tools/enum'
8
+
9
+// 刷新 token 状态管理
10
+let refreshing = false // 防止重复刷新 token 标识
11
+let taskQueue: (() => void)[] = [] // 刷新 token 请求队列
12
+
13
+export function http<T>(options: CustomRequestOptions) {
14
+  // 1. 返回 Promise 对象
15
+  return new Promise<T>((resolve, reject) => {
16
+    uni.request({
17
+      ...options,
18
+      dataType: 'json',
19
+      // #ifndef MP-WEIXIN
20
+      responseType: 'json',
21
+      // #endif
22
+      // 响应成功
23
+      success: async (res) => {
24
+        const responseData = res.data as IResponse<T>
25
+        const { code } = responseData
26
+
27
+        // 检查是否是401错误(包括HTTP状态码401或业务码401)
28
+        const isTokenExpired = res.statusCode === 401 || code === 401
29
+
30
+        if (isTokenExpired) {
31
+          const tokenStore = useTokenStore()
32
+          if (!isDoubleTokenMode) {
33
+            // 未启用双token策略,清理用户信息,跳转到登录页
34
+            // tokenStore.logout()
35
+            toLoginPage()
36
+            return reject(res)
37
+          }
38
+
39
+          /* -------- 无感刷新 token ----------- */
40
+          const { refreshToken } = tokenStore.tokenInfo as IDoubleTokenRes || {}
41
+          // token 失效的,且有刷新 token 的,才放到请求队列里
42
+          if (refreshToken) {
43
+            taskQueue.push(() => {
44
+              resolve(http<T>(options))
45
+            })
46
+          }
47
+
48
+          // 如果有 refreshToken 且未在刷新中,发起刷新 token 请求
49
+          if (refreshToken && !refreshing) {
50
+            refreshing = true
51
+            try {
52
+              // 发起刷新 token 请求(使用 store 的 refreshToken 方法)
53
+              await tokenStore.refreshToken()
54
+              // 刷新 token 成功
55
+              refreshing = false
56
+              nextTick(() => {
57
+                // 关闭其他弹窗
58
+                uni.hideToast()
59
+                uni.showToast({
60
+                  title: 'token 刷新成功',
61
+                  icon: 'none',
62
+                })
63
+              })
64
+              // 将任务队列的所有任务重新请求
65
+              taskQueue.forEach(task => task())
66
+            }
67
+            catch (refreshErr) {
68
+              console.error('刷新 token 失败:', refreshErr)
69
+              refreshing = false
70
+              // 刷新 token 失败,跳转到登录页
71
+              nextTick(() => {
72
+                // 关闭其他弹窗
73
+                uni.hideToast()
74
+                uni.showToast({
75
+                  title: '登录已过期,请重新登录',
76
+                  icon: 'none',
77
+                })
78
+              })
79
+              // 清除用户信息
80
+              await tokenStore.logout()
81
+              // 跳转到登录页
82
+              setTimeout(() => {
83
+                toLoginPage()
84
+              }, 2000)
85
+            }
86
+            finally {
87
+              // 不管刷新 token 成功与否,都清空任务队列
88
+              taskQueue = []
89
+            }
90
+          }
91
+
92
+          return reject(res)
93
+        }
94
+
95
+        // 处理其他成功状态(HTTP状态码200-299)
96
+        if (res.statusCode >= 200 && res.statusCode < 300) {
97
+          // 处理业务逻辑错误
98
+          if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) {
99
+            uni.showToast({
100
+              icon: 'none',
101
+              title: responseData.msg || responseData.message || '请求错误',
102
+            })
103
+          }
104
+          return resolve(responseData)
105
+        }
106
+
107
+        // 处理其他错误
108
+        !options.hideErrorToast
109
+        && uni.showToast({
110
+          icon: 'none',
111
+          title: (res.data as any).msg || '请求错误',
112
+        })
113
+        reject(res)
114
+      },
115
+      // 响应失败
116
+      fail(err) {
117
+        uni.showToast({
118
+          icon: 'none',
119
+          title: '网络错误,换个网络试试',
120
+        })
121
+        reject(err)
122
+      },
123
+    })
124
+  })
125
+}
126
+
127
+/**
128
+ * GET 请求
129
+ * @param url 后台地址
130
+ * @param query 请求query参数
131
+ * @param header 请求头,默认为json格式
132
+ * @returns
133
+ */
134
+export function httpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
135
+  return http<T>({
136
+    url,
137
+    query,
138
+    method: 'GET',
139
+    header,
140
+    ...options,
141
+  })
142
+}
143
+
144
+/**
145
+ * POST 请求
146
+ * @param url 后台地址
147
+ * @param data 请求body参数
148
+ * @param query 请求query参数,post请求也支持query,很多微信接口都需要
149
+ * @param header 请求头,默认为json格式
150
+ * @returns
151
+ */
152
+export function httpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
153
+  return http<T>({
154
+    url,
155
+    query,
156
+    data,
157
+    method: 'POST',
158
+    header,
159
+    ...options,
160
+  })
161
+}
162
+/**
163
+ * PUT 请求
164
+ */
165
+export function httpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
166
+  return http<T>({
167
+    url,
168
+    data,
169
+    query,
170
+    method: 'PUT',
171
+    header,
172
+    ...options,
173
+  })
174
+}
175
+
176
+/**
177
+ * DELETE 请求(无请求体,仅 query)
178
+ */
179
+export function httpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
180
+  return http<T>({
181
+    url,
182
+    query,
183
+    method: 'DELETE',
184
+    header,
185
+    ...options,
186
+  })
187
+}
188
+
189
+// 支持与 axios 类似的API调用
190
+http.get = httpGet
191
+http.post = httpPost
192
+http.put = httpPut
193
+http.delete = httpDelete
194
+
195
+// 支持与 alovaJS 类似的API调用
196
+http.Get = httpGet
197
+http.Post = httpPost
198
+http.Put = httpPut
199
+http.Delete = httpDelete

+ 69 - 0
src/http/interceptor.ts

@@ -0,0 +1,69 @@
1
+import type { CustomRequestOptions } from '@/http/types'
2
+import { useTokenStore } from '@/store'
3
+import { getEnvBaseUrl } from '@/utils'
4
+import { stringifyQuery } from './tools/queryString'
5
+
6
+// 请求基准地址
7
+const baseUrl = getEnvBaseUrl()
8
+
9
+// 拦截器配置
10
+const httpInterceptor = {
11
+  // 拦截前触发
12
+  invoke(options: CustomRequestOptions) {
13
+    // 如果您使用了alova,则请把下面的代码放开注释
14
+    // alova 执行流程:alova beforeRequest --> 本拦截器 --> alova responded
15
+    // return options
16
+
17
+    // 非 alova 请求,正常执行
18
+    // 接口请求支持通过 query 参数配置 queryString
19
+    if (options.query) {
20
+      const queryStr = stringifyQuery(options.query)
21
+      if (options.url.includes('?')) {
22
+        options.url += `&${queryStr}`
23
+      }
24
+      else {
25
+        options.url += `?${queryStr}`
26
+      }
27
+    }
28
+    // 非 http 开头需拼接地址
29
+    if (!options.url.startsWith('http')) {
30
+      // #ifdef H5
31
+      if (JSON.parse(import.meta.env.VITE_APP_PROXY_ENABLE)) {
32
+        // 自动拼接代理前缀
33
+        options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url
34
+      }
35
+      else {
36
+        options.url = baseUrl + options.url
37
+      }
38
+      // #endif
39
+      // 非H5正常拼接
40
+      // #ifndef H5
41
+      options.url = baseUrl + options.url
42
+      // #endif
43
+      // TIPS: 如果需要对接多个后端服务,也可以在这里处理,拼接成所需要的地址
44
+    }
45
+    // 1. 请求超时
46
+    options.timeout = 60000 // 60s
47
+    // 2. (可选)添加小程序端请求头标识
48
+    options.header = {
49
+      ...options.header,
50
+    }
51
+    // 3. 添加 token 请求头标识
52
+    const tokenStore = useTokenStore()
53
+    const token = tokenStore.validToken
54
+
55
+    if (token) {
56
+      options.header.Authorization = `Bearer ${token}`
57
+    }
58
+    return options
59
+  },
60
+}
61
+
62
+export const requestInterceptor = {
63
+  install() {
64
+    // 拦截 request 请求
65
+    uni.addInterceptor('request', httpInterceptor)
66
+    // 拦截 uploadFile 文件上传
67
+    uni.addInterceptor('uploadFile', httpInterceptor)
68
+  },
69
+}

+ 68 - 0
src/http/tools/enum.ts

@@ -0,0 +1,68 @@
1
+export enum ResultEnum {
2
+  // 0和200当做成功都很普遍,这里直接兼容两者(PS:0和200通常都不会当做错误码,但是有的接口会返回0,有的接口会返回200)
3
+  Success0 = 0, // 成功
4
+  Success200 = 200, // 成功
5
+  Error = 400, // 错误
6
+  Unauthorized = 401, // 未授权
7
+  Forbidden = 403, // 禁止访问(原为forbidden)
8
+  NotFound = 404, // 未找到(原为notFound)
9
+  MethodNotAllowed = 405, // 方法不允许(原为methodNotAllowed)
10
+  RequestTimeout = 408, // 请求超时(原为requestTimeout)
11
+  InternalServerError = 500, // 服务器错误(原为internalServerError)
12
+  NotImplemented = 501, // 未实现(原为notImplemented)
13
+  BadGateway = 502, // 网关错误(原为badGateway)
14
+  ServiceUnavailable = 503, // 服务不可用(原为serviceUnavailable)
15
+  GatewayTimeout = 504, // 网关超时(原为gatewayTimeout)
16
+  HttpVersionNotSupported = 505, // HTTP版本不支持(原为httpVersionNotSupported)
17
+}
18
+export enum ContentTypeEnum {
19
+  JSON = 'application/json;charset=UTF-8',
20
+  FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
21
+  FORM_DATA = 'multipart/form-data;charset=UTF-8',
22
+}
23
+/**
24
+ * 根据状态码,生成对应的错误信息
25
+ * @param {number|string} status 状态码
26
+ * @returns {string} 错误信息
27
+ */
28
+export function ShowMessage(status: number | string): string {
29
+  let message: string
30
+  switch (status) {
31
+    case 400:
32
+      message = '请求错误(400)'
33
+      break
34
+    case 401:
35
+      message = '未授权,请重新登录(401)'
36
+      break
37
+    case 403:
38
+      message = '拒绝访问(403)'
39
+      break
40
+    case 404:
41
+      message = '请求出错(404)'
42
+      break
43
+    case 408:
44
+      message = '请求超时(408)'
45
+      break
46
+    case 500:
47
+      message = '服务器错误(500)'
48
+      break
49
+    case 501:
50
+      message = '服务未实现(501)'
51
+      break
52
+    case 502:
53
+      message = '网络错误(502)'
54
+      break
55
+    case 503:
56
+      message = '服务不可用(503)'
57
+      break
58
+    case 504:
59
+      message = '网络超时(504)'
60
+      break
61
+    case 505:
62
+      message = 'HTTP版本不受支持(505)'
63
+      break
64
+    default:
65
+      message = `连接出错(${status})!`
66
+  }
67
+  return `${message},请检查网络或联系管理员!`
68
+}

+ 29 - 0
src/http/tools/queryString.ts

@@ -0,0 +1,29 @@
1
+/**
2
+ * 将对象序列化为URL查询字符串,用于替代第三方的 qs 库,节省宝贵的体积
3
+ * 支持基本类型值和数组,不支持嵌套对象
4
+ * @param obj 要序列化的对象
5
+ * @returns 序列化后的查询字符串
6
+ */
7
+export function stringifyQuery(obj: Record<string, any>): string {
8
+  if (!obj || typeof obj !== 'object' || Array.isArray(obj))
9
+    return ''
10
+
11
+  return Object.entries(obj)
12
+    .filter(([_, value]) => value !== undefined && value !== null)
13
+    .map(([key, value]) => {
14
+      // 对键进行编码
15
+      const encodedKey = encodeURIComponent(key)
16
+
17
+      // 处理数组类型
18
+      if (Array.isArray(value)) {
19
+        return value
20
+          .filter(item => item !== undefined && item !== null)
21
+          .map(item => `${encodedKey}=${encodeURIComponent(item)}`)
22
+          .join('&')
23
+      }
24
+
25
+      // 处理基本类型
26
+      return `${encodedKey}=${encodeURIComponent(value)}`
27
+    })
28
+    .join('&')
29
+}

+ 44 - 0
src/http/types.ts

@@ -0,0 +1,44 @@
1
+/**
2
+ * 在 uniapp 的 RequestOptions 和 IUniUploadFileOptions 基础上,添加自定义参数
3
+ */
4
+export type CustomRequestOptions = UniApp.RequestOptions & {
5
+  query?: Record<string, any>
6
+  /** 出错时是否隐藏错误提示 */
7
+  hideErrorToast?: boolean
8
+} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
9
+
10
+/** 主要提供给 openapi-ts-request 生成的代码使用 */
11
+export type CustomRequestOptions_ = Omit<CustomRequestOptions, 'url'>
12
+
13
+export interface HttpRequestResult<T> {
14
+  promise: Promise<T>
15
+  requestTask: UniApp.RequestTask
16
+}
17
+
18
+// 通用响应格式(兼容 msg + message 字段)
19
+export type IResponse<T = any> = {
20
+  code: number
21
+  data: T
22
+  message: string
23
+  [key: string]: any // 允许额外属性
24
+} | {
25
+  code: number
26
+  data: T
27
+  msg: string
28
+  [key: string]: any // 允许额外属性
29
+}
30
+
31
+// 分页请求参数
32
+export interface PageParams {
33
+  page: number
34
+  pageSize: number
35
+  [key: string]: any
36
+}
37
+
38
+// 分页响应数据
39
+export interface PageResult<T> {
40
+  list: T[]
41
+  total: number
42
+  page: number
43
+  pageSize: number
44
+}

+ 30 - 0
src/http/vue-query.ts

@@ -0,0 +1,30 @@
1
+import type { CustomRequestOptions } from '@/http/types'
2
+import { http } from './http'
3
+
4
+/*
5
+ * openapi-ts-request 工具的 request 跨客户端适配方法
6
+ */
7
+export default function request<T = unknown>(
8
+  url: string,
9
+  options: Omit<CustomRequestOptions, 'url'> & {
10
+    params?: Record<string, unknown>
11
+    headers?: Record<string, unknown>
12
+  },
13
+) {
14
+  const requestOptions = {
15
+    url,
16
+    ...options,
17
+  }
18
+
19
+  if (options.params) {
20
+    requestOptions.query = requestOptions.params
21
+    delete requestOptions.params
22
+  }
23
+
24
+  if (options.headers) {
25
+    requestOptions.header = options.headers
26
+    delete requestOptions.headers
27
+  }
28
+
29
+  return http<T>(requestOptions)
30
+}

+ 15 - 0
src/layouts/default.vue

@@ -0,0 +1,15 @@
1
+<script lang="ts" setup>
2
+import { getI18nText } from '@/tabbar/i18n'
3
+import { getCurrentPageI18nKey } from '@/utils'
4
+
5
+onShow(() => {
6
+  console.log('layout default - onShow')
7
+  uni.setNavigationBarTitle({
8
+    title: getI18nText(getCurrentPageI18nKey()),
9
+  })
10
+})
11
+</script>
12
+
13
+<template>
14
+  <slot />
15
+</template>

+ 12 - 0
src/locale/README.md

@@ -0,0 +1,12 @@
1
+# 注意事项
2
+
3
+> 文件夹名字必须为 `locale`, 这是 `uniapp` 官方约定的,如果改为别的,标题将不能正常切换多语言(其他内容还是正常)。
4
+>
5
+> `xxx.json` 的 `xxx` 多语言标识必须与 `uniapp` 官方约定的一致,否则也会出现 BUG。
6
+>
7
+> 查看截图 `screenshots/i18n.png`。
8
+
9
+## 参考文档
10
+
11
+[uniapp 国际化开发指南](https://uniapp.dcloud.net.cn/tutorial/i18n.html)
12
+[uniapp 国际化-注意事项](https://uniapp.dcloud.net.cn/api/ui/locale.html#onlocalechange) 最下面的注意事项

+ 10 - 0
src/locale/en.json

@@ -0,0 +1,10 @@
1
+{
2
+  "tabbar.home": "Home",
3
+  "tabbar.about": "About",
4
+  "tabbar.me": "Me",
5
+  "i18n.title": "En Title",
6
+  "alova.title": "Alova Request",
7
+  "weight": "{heavy}KG",
8
+  "detail": "{0}cm, {1}KG",
9
+  "introduction": "I am {name},height:{detail.height},weight:{detail.weight}"
10
+}

+ 87 - 0
src/locale/index.ts

@@ -0,0 +1,87 @@
1
+import { createI18n } from 'vue-i18n'
2
+
3
+import en from './en.json'
4
+import zhHans from './zh-Hans.json' // 简体中文
5
+
6
+const messages = {
7
+  en,
8
+  'zh-Hans': zhHans, // key 不能乱写,查看截图 screenshots/i18n.png
9
+}
10
+
11
+const i18n = createI18n({
12
+  locale: uni.getLocale(), // 获取已设置的语言,fallback 语言需要再 manifest.config.ts 中设置
13
+  messages,
14
+  allowComposition: true,
15
+})
16
+
17
+console.log(uni.getLocale())
18
+console.log(i18n.global.locale)
19
+
20
+/**
21
+ * 可以拿到原始的语言模板,非 vue 文件使用这个方法,
22
+ * @param { string } key 多语言的key,eg: "app.name"
23
+ * @returns {string} 返回原始的多语言模板,eg: "{heavy}KG"
24
+ */
25
+export function getTemplateByKey(key: string) {
26
+  if (!key) {
27
+    console.error(`[i18n] Function getTemplateByKey(), key param is required`)
28
+    return ''
29
+  }
30
+  const locale = uni.getLocale()
31
+  console.log('locale:', locale)
32
+
33
+  const message = messages[locale] // 拿到某个多语言的所有模板(是一个对象)
34
+  if (Object.keys(message).includes(key)) {
35
+    return message[key]
36
+  }
37
+
38
+  try {
39
+    const keyList = key.split('.')
40
+    let result = keyList.reduce((pre, cur) => {
41
+      return pre && typeof pre === 'object' ? pre[cur] : undefined
42
+    }, message)
43
+    // 确保返回的是字符串类型
44
+    return typeof result === 'string' ? result : ''
45
+  }
46
+  catch (error) {
47
+    console.error(`[i18n] Function getTemplateByKey(), key param ${key} is not existed.`)
48
+    return ''
49
+  }
50
+}
51
+
52
+/**
53
+ * formatI18n('我是{name},身高{detail.height},体重{detail.weight}',{name:'张三',detail:{height:178,weight:'75kg'}})
54
+ * 暂不支持数组
55
+ * @param template 多语言模板字符串,eg: `我是{name}`
56
+ * @param {object | undefined} data 需要传递的数据对象,里面的key与多语言字符串对应,eg: `{name:'菲鸽'}`
57
+ * @returns
58
+ */
59
+function formatI18n(template: string, data?: any) {
60
+  // 添加template有效性检查,避免在undefined上调用replace方法
61
+  if (!template || typeof template !== 'string') {
62
+    return template || ''
63
+  }
64
+  return template.replace(/\{([^}]+)\}/g, (match, key: string) => {
65
+    // console.log( match, key) // => { detail.height }  detail.height
66
+    const arr = key.trim().split('.')
67
+    let result = data
68
+    while (arr.length) {
69
+      const first = arr.shift()
70
+      result = result[first]
71
+    }
72
+    return result
73
+  })
74
+}
75
+
76
+/**
77
+ * t('introduction',{name:'张三',detail:{height:178,weight:'75kg'}})
78
+ * => formatI18n('我是{name},身高{detail.height},体重{detail.weight}',{name:'张三',detail:{height:178,weight:'75kg'}})
79
+ * 没有key的,可以不传 data;暂不支持数组
80
+ * @param template 多语言模板字符串,eg: `我是{name}`
81
+ * @param {object | undefined} data 需要传递的数据对象,里面的key与多语言字符串对应,eg: `{name:'菲鸽'}`
82
+ * @returns
83
+ */
84
+export function t(key, data?) {
85
+  return formatI18n(getTemplateByKey(key), data)
86
+}
87
+export default i18n

+ 10 - 0
src/locale/zh-Hans.json

@@ -0,0 +1,10 @@
1
+{
2
+  "tabbar.home": "首页",
3
+  "tabbar.about": "关于",
4
+  "tabbar.me": "我的",
5
+  "i18n.title": "中文标题",
6
+  "alova.title": "Alova 请求",
7
+  "weight": "{heavy}公斤",
8
+  "detail": "{0}cm, {1}公斤",
9
+  "introduction": "我是 {name},身高:{detail.height},体重:{detail.weight}"
10
+}

+ 21 - 0
src/main.ts

@@ -0,0 +1,21 @@
1
+import { createSSRApp } from 'vue'
2
+import App from './App.vue'
3
+import { requestInterceptor } from './http/interceptor'
4
+import i18n from './locale/index'
5
+import { routeInterceptor } from './router/interceptor'
6
+
7
+import store from './store'
8
+import '@/style/index.scss'
9
+import 'virtual:uno.css'
10
+
11
+export function createApp() {
12
+  const app = createSSRApp(App)
13
+  app.use(store)
14
+  app.use(i18n)
15
+  app.use(routeInterceptor)
16
+  app.use(requestInterceptor)
17
+
18
+  return {
19
+    app,
20
+  }
21
+}

+ 3 - 0
src/pages-fg/404/README.md

@@ -0,0 +1,3 @@
1
+# 404 页面
2
+
3
+`404页面` 只有在路由不存在时才会显示,如果您不需要可以删除该页面。但是建议保留。

+ 30 - 0
src/pages-fg/404/index.vue

@@ -0,0 +1,30 @@
1
+<script lang="ts" setup>
2
+import { HOME_PAGE } from '@/utils'
3
+
4
+definePage({
5
+  style: {
6
+    // 'custom' 表示开启自定义导航栏,默认 'default'
7
+    navigationStyle: 'custom',
8
+  },
9
+})
10
+
11
+function goBack() {
12
+  // 当pages.config.ts中配置了tabbar页面时,使用switchTab切换到首页
13
+  // 否则使用navigateTo返回首页
14
+  uni.switchTab({ url: HOME_PAGE })
15
+}
16
+</script>
17
+
18
+<template>
19
+  <view class="h-screen flex flex-col items-center justify-center">
20
+    <view> 404 </view>
21
+    <view> 页面不存在 </view>
22
+    <button class="mt-6 w-40 text-center" @click="goBack">
23
+      返回首页
24
+    </button>
25
+  </view>
26
+</template>
27
+
28
+<style lang="scss" scoped>
29
+//
30
+</style>

+ 3 - 0
src/pages-fg/REAME.md

@@ -0,0 +1,3 @@
1
+# pages-fg 说明
2
+
3
+为了尽量减少主包的大小,一些无关紧要但经常需要的页面(如登录页、注册页、404页等)放在了 `pages-fg` 目录下。

+ 20 - 0
src/pages-fg/login/README.md

@@ -0,0 +1,20 @@
1
+# 登录页
2
+需要输入账号、密码/验证码的登录页。
3
+
4
+## 适用性
5
+
6
+本页面主要用于 `h5` 和 `APP`。
7
+
8
+小程序通常有平台的登录方式 `uni.login` 通常用不到登录页,所以不适用于 `小程序`。(即默认情况下,小程序环境是不会走登录拦截逻辑的。)
9
+
10
+但是如果您的小程序也需要现实的 `登录页` 那也是可以使用的。
11
+
12
+在 `src/router/config.ts` 中有一个变量 `LOGIN_PAGE_ENABLE_IN_MP` 来控制是否在小程序中使用 `H5的登录页`。
13
+
14
+更多信息请看 `src/router` 文件夹的内容。
15
+
16
+## 登录跳转
17
+
18
+目前登录的跳转逻辑主要在 `src/router/interceptor.ts` 和 `src/pages/login/login.vue` 里面,默认会在登录后自动重定向到来源/配置的页面。
19
+
20
+如果与您的业务不符,您可以自行修改。

+ 371 - 0
src/pages-fg/login/login.vue

@@ -0,0 +1,371 @@
1
+<script lang="ts" setup>
2
+import type { ILoginForm } from '@/api/login'
3
+import type { ICaptcha } from '@/api/types/login'
4
+import { computed, onMounted, ref } from 'vue'
5
+import { getCaptcha } from '@/api/login'
6
+import { useAuthStore } from '@/store/auth'
7
+
8
+definePage({
9
+  style: {
10
+    navigationBarTitleText: '登录',
11
+  },
12
+})
13
+
14
+const authStore = useAuthStore()
15
+
16
+// 验证码响应实体类
17
+const captchaInfo = ref<ICaptcha>({
18
+  captchaEnabled: false,
19
+  img: '',
20
+  uuid: '',
21
+})
22
+
23
+// 登录表单数据
24
+const loginForm = ref<ILoginForm>({
25
+  username: '',
26
+  password: '',
27
+  code: '',
28
+  uuid: '',
29
+})
30
+
31
+// 验证码相关数据
32
+const captchaData = ref({
33
+  code: '',
34
+  uuid: '',
35
+})
36
+
37
+// 重定向地址
38
+const redirectUrl = ref('')
39
+
40
+// 表单验证规则
41
+const formRules = computed(() => ({
42
+  username: {
43
+    required: true,
44
+    message: '请输入用户名',
45
+    trigger: 'blur',
46
+  },
47
+  password: {
48
+    required: true,
49
+    message: '请输入密码',
50
+    trigger: 'blur',
51
+  },
52
+  code: {
53
+    required: captchaInfo.value.captchaEnabled,
54
+    message: '请输入验证码',
55
+    trigger: 'blur',
56
+  },
57
+}))
58
+
59
+// 获取验证码接口
60
+async function loadCaptcha() {
61
+  try {
62
+    const resp = await getCaptcha()
63
+    console.log('获取验证码响应:', resp)
64
+    if (resp.captchaEnabled && resp.img) {
65
+      resp.img = `data:image/png;base64,${resp.img}`
66
+    }
67
+    captchaInfo.value = resp
68
+    loginForm.value.uuid = resp.uuid || ''
69
+  }
70
+  catch (error) {
71
+    console.error('获取验证码失败:', error)
72
+    uni.showToast({
73
+      title: '获取验证码失败',
74
+      icon: 'error',
75
+    })
76
+  }
77
+}
78
+
79
+// uni-app页面生命周期钩子
80
+onLoad((options) => {
81
+  // 获取重定向地址
82
+  if (options?.redirect) {
83
+    redirectUrl.value = decodeURIComponent(options.redirect)
84
+  }
85
+})
86
+
87
+onMounted(async () => {
88
+  await loadCaptcha()
89
+})
90
+
91
+// 处理登录
92
+async function handleLogin() {
93
+  try {
94
+    // 构建登录参数
95
+    const requestParam: ILoginForm = {
96
+      username: loginForm.value.username,
97
+      password: loginForm.value.password,
98
+      code: '',
99
+      uuid: '',
100
+    }
101
+
102
+    // 如果需要验证码,添加验证码参数
103
+    if (captchaInfo.value.captchaEnabled) {
104
+      requestParam.code = loginForm.value.code
105
+      requestParam.uuid = loginForm.value.uuid
106
+    }
107
+
108
+    // 调用登录接口
109
+    await authStore.authLogin(requestParam)
110
+
111
+    uni.showToast({
112
+      title: '登录成功',
113
+      icon: 'success',
114
+    })
115
+
116
+    // 登录成功后跳转
117
+    setTimeout(() => {
118
+      if (redirectUrl.value) {
119
+        // 如果有重定向地址,跳转到指定页面
120
+        if (redirectUrl.value.startsWith('/pages/')) {
121
+          // 检查是否为tabbar页面
122
+          if (redirectUrl.value === '/pages/index/index' || redirectUrl.value === '/pages/me/me') {
123
+            uni.switchTab({
124
+              url: redirectUrl.value,
125
+            })
126
+          }
127
+          else {
128
+            uni.redirectTo({
129
+              url: redirectUrl.value,
130
+            })
131
+          }
132
+        }
133
+        else {
134
+          uni.redirectTo({
135
+            url: redirectUrl.value,
136
+          })
137
+        }
138
+      }
139
+      else {
140
+        // 没有重定向地址,跳转到首页
141
+        uni.switchTab({
142
+          url: '/pages/index/index',
143
+        })
144
+      }
145
+    }, 1500)
146
+  }
147
+  catch (error) {
148
+    console.error('登录失败:', error)
149
+    uni.showToast({
150
+      title: '登录失败,请检查用户名密码',
151
+      icon: 'error',
152
+    })
153
+    // 处理验证码错误,刷新验证码
154
+    if (captchaInfo.value.captchaEnabled) {
155
+      await loadCaptcha()
156
+      loginForm.value.code = ''
157
+    }
158
+  }
159
+}
160
+
161
+// 表单提交
162
+function onSubmit() {
163
+  handleLogin()
164
+}
165
+
166
+// 刷新验证码
167
+function refreshCaptcha() {
168
+  loadCaptcha()
169
+}
170
+
171
+// 表单验证
172
+const formRef = ref()
173
+
174
+function validateForm() {
175
+  formRef.value?.validate().then(() => {
176
+    onSubmit()
177
+  }).catch(() => {
178
+    uni.showToast({
179
+      title: '请检查表单输入',
180
+      icon: 'error',
181
+    })
182
+  })
183
+}
184
+</script>
185
+
186
+<template>
187
+  <view class="login-container">
188
+    <!-- 登录头部 -->
189
+    <view class="login-header">
190
+      <view class="logo-container">
191
+        <image src="/static/logo.svg" class="logo" mode="aspectFit" />
192
+      </view>
193
+      <text class="login-title">用户登录</text>
194
+    </view>
195
+
196
+    <!-- 登录表单 -->
197
+    <view class="login-form">
198
+      <wd-form ref="formRef" :model="loginForm" :rules="formRules">
199
+        <!-- 用户名输入 -->
200
+        <wd-form-item prop="username" label="用户名" required>
201
+          <wd-input v-model="loginForm.username" placeholder="请输入用户名" clearable :show-clear="true" />
202
+        </wd-form-item>
203
+
204
+        <!-- 密码输入 -->
205
+        <wd-form-item prop="password" label="密码" required>
206
+          <wd-input v-model="loginForm.password" type="password" placeholder="请输入密码" clearable :show-clear="true" :show-password="true" />
207
+        </wd-form-item>
208
+
209
+        <!-- 验证码输入 -->
210
+        <wd-form-item v-if="captchaInfo && captchaInfo.captchaEnabled" prop="code" label="验证码" required>
211
+          <view class="captcha-container">
212
+            <wd-input v-model="loginForm.code" placeholder="请输入验证码" clearable :show-clear="true" class="captcha-input" />
213
+            <view class="captcha-image-wrapper" @click="refreshCaptcha">
214
+              <image v-if="captchaInfo && captchaInfo.img" :src="captchaInfo.img" class="captcha-image" mode="aspectFit" />
215
+              <text v-else class="captcha-placeholder">点击获取</text>
216
+            </view>
217
+          </view>
218
+        </wd-form-item>
219
+      </wd-form>
220
+
221
+      <!-- 登录按钮 -->
222
+      <view class="login-actions">
223
+        <wd-button type="primary" block round :loading="authStore.loginLoading" @click="validateForm">
224
+          {{ authStore.loginLoading ? '登录中...' : '登录' }}
225
+        </wd-button>
226
+      </view>
227
+    </view>
228
+  </view>
229
+</template>
230
+
231
+<style lang="scss" scoped>
232
+.login-container {
233
+  min-height: 100vh;
234
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
235
+  padding: 0 32rpx;
236
+  display: flex;
237
+  flex-direction: column;
238
+  justify-content: center;
239
+}
240
+
241
+.login-header {
242
+  text-align: center;
243
+  margin-bottom: 80rpx;
244
+
245
+  .logo-container {
246
+    margin-bottom: 40rpx;
247
+
248
+    .logo {
249
+      width: 120rpx;
250
+      height: 120rpx;
251
+    }
252
+  }
253
+
254
+  .login-title {
255
+    display: block;
256
+    font-size: 48rpx;
257
+    font-weight: bold;
258
+    color: white;
259
+    margin-bottom: 16rpx;
260
+  }
261
+
262
+  .login-subtitle {
263
+    display: block;
264
+    font-size: 28rpx;
265
+    color: rgba(255, 255, 255, 0.8);
266
+  }
267
+}
268
+
269
+.login-form {
270
+  background: white;
271
+  border-radius: 24rpx;
272
+  padding: 48rpx 32rpx;
273
+  box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
274
+
275
+  .test-section {
276
+    margin-bottom: 32rpx;
277
+    padding: 16rpx;
278
+    background: #f0f8ff;
279
+    border-radius: 8rpx;
280
+
281
+    text {
282
+      display: block;
283
+      margin-bottom: 16rpx;
284
+      font-size: 28rpx;
285
+      color: #333;
286
+    }
287
+  }
288
+
289
+  :deep(.wd-form-item) {
290
+    margin-bottom: 32rpx;
291
+  }
292
+
293
+  :deep(.wd-form-item__label) {
294
+    font-weight: 500;
295
+    color: #333;
296
+  }
297
+
298
+  :deep(.wd-input) {
299
+    height: 88rpx;
300
+    border-radius: 12rpx;
301
+    background: #f8f9fa;
302
+  }
303
+
304
+  .captcha-container {
305
+    display: flex;
306
+    align-items: center;
307
+    gap: 16rpx;
308
+
309
+    .captcha-input {
310
+      flex: 1;
311
+    }
312
+
313
+    .captcha-image-wrapper {
314
+      width: 160rpx;
315
+      height: 88rpx;
316
+      border-radius: 12rpx;
317
+      background: #f8f9fa;
318
+      display: flex;
319
+      align-items: center;
320
+      justify-content: center;
321
+      border: 2rpx solid #e9ecef;
322
+      cursor: pointer;
323
+      transition: all 0.3s ease;
324
+
325
+      &:active {
326
+        transform: scale(0.95);
327
+        background: #e9ecef;
328
+      }
329
+
330
+      .captcha-image {
331
+        width: 100%;
332
+        height: 100%;
333
+        border-radius: 8rpx;
334
+      }
335
+
336
+      .captcha-placeholder {
337
+        font-size: 24rpx;
338
+        color: #999;
339
+      }
340
+    }
341
+  }
342
+
343
+  .login-actions {
344
+    margin: 48rpx 0 32rpx;
345
+
346
+    :deep(.wd-button) {
347
+      height: 96rpx;
348
+      font-size: 32rpx;
349
+      font-weight: 600;
350
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
351
+      border: none;
352
+    }
353
+  }
354
+
355
+  .login-footer {
356
+    text-align: center;
357
+
358
+    .footer-text {
359
+      font-size: 28rpx;
360
+      color: #666;
361
+    }
362
+
363
+    .register-link {
364
+      font-size: 28rpx;
365
+      color: #667eea;
366
+      font-weight: 500;
367
+      margin-left: 8rpx;
368
+    }
369
+  }
370
+}
371
+</style>

+ 34 - 0
src/pages-fg/login/register.vue

@@ -0,0 +1,34 @@
1
+<script lang="ts" setup>
2
+import { LOGIN_PAGE } from '@/router/config'
3
+
4
+definePage({
5
+  style: {
6
+    navigationBarTitleText: '注册',
7
+  },
8
+})
9
+
10
+function doRegister() {
11
+  uni.showToast({
12
+    title: '注册成功',
13
+  })
14
+  // 注册成功后跳转到登录页
15
+  uni.navigateTo({
16
+    url: LOGIN_PAGE,
17
+  })
18
+}
19
+</script>
20
+
21
+<template>
22
+  <view class="login">
23
+    <view class="text-center">
24
+      注册页
25
+    </view>
26
+    <button class="mt-4 w-40 text-center" @click="doRegister">
27
+      点击模拟注册
28
+    </button>
29
+  </view>
30
+</template>
31
+
32
+<style lang="scss" scoped>
33
+//
34
+</style>

+ 166 - 0
src/pages/index/index.vue

@@ -0,0 +1,166 @@
1
+<script lang="ts" setup>
2
+import { onMounted, ref } from 'vue'
3
+import { http } from '@/http/http'
4
+import { toLoginPage } from '@/utils/toLoginPage'
5
+
6
+defineOptions({
7
+  name: 'Home',
8
+})
9
+definePage({
10
+  // 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page
11
+  type: 'home',
12
+  style: {
13
+    // 'custom' 表示开启自定义导航栏,默认 'default'
14
+    navigationStyle: 'custom',
15
+    navigationBarTitleText: '%tabbar.home%',
16
+  },
17
+})
18
+
19
+// 菜单项接口定义
20
+interface MenuItem {
21
+  /** 菜单ID */
22
+  id: string
23
+  /** 菜单名称 */
24
+  name: string
25
+  /** 图标 */
26
+  icon?: string
27
+  /** 链接地址 */
28
+  url?: string
29
+  /** 子菜单 */
30
+  children?: MenuItem[]
31
+  /** 图标颜色 */
32
+  iconColor?: string
33
+}
34
+
35
+// 菜单数据
36
+const menuData = ref<MenuItem[]>([])
37
+const loading = ref(false)
38
+const error = ref('')
39
+
40
+// Mock数据 - 由于接口可能调不通,使用模拟数据
41
+const mockMenuData: MenuItem[] = []
42
+
43
+// 获取菜单数据
44
+async function fetchMenuData() {
45
+  loading.value = true
46
+  error.value = ''
47
+  try {
48
+    // 尝试调用真实接口
49
+    const response = await http<MenuItem[]>({
50
+      url: '/getRouters',
51
+      method: 'GET',
52
+    })
53
+    menuData.value = response.data
54
+    console.log('菜单数据:', response)
55
+  }
56
+  catch (err) {
57
+    console.error('获取菜单失败:', err)
58
+    // 接口调用失败时使用mock数据
59
+    // menuData.value = mockMenuData
60
+    // 如果是token失效错误,会被http拦截器自动处理跳转到登录页
61
+    if (err.data.msg.includes('认证失败')) {
62
+      toLoginPage()
63
+    }
64
+  }
65
+  finally {
66
+    loading.value = false
67
+  }
68
+}
69
+
70
+// 处理菜单项点击
71
+function handleMenuItemClick(menu: MenuItem) {
72
+  if (menu.children && menu.children.length > 0) {
73
+    // 有子菜单的父菜单不跳转
74
+    return
75
+  }
76
+  // 根据菜单ID执行相应操作
77
+  if (menu.url) {
78
+    uni.navigateTo({
79
+      url: menu.url,
80
+    })
81
+  }
82
+  else {
83
+    uni.showToast({
84
+      title: `点击了${menu.meta?.title || menu.name}`,
85
+      icon: 'none',
86
+    })
87
+  }
88
+}
89
+onMounted(async () => {
90
+  // fetchMenuData()
91
+})
92
+onLoad(async () => {
93
+  fetchMenuData()
94
+})
95
+
96
+</script>
97
+
98
+<template>
99
+  <view class="min-h-screen bg-gray-100">
100
+    <!-- 菜单内容 -->
101
+    <view class="p-2">
102
+      <block v-if="loading">
103
+        <view class="text-center p-16">
104
+          <wd-loading size="large" type="ring"></wd-loading>
105
+          <text class="text-gray-500 text-sm mt-4 inline-block">加载中...</text>
106
+        </view>
107
+      </block>
108
+      <block v-else-if="error">
109
+        <view class="text-center p-16">
110
+          <wd-icon name="info-circle" size="60" color="#909399"></wd-icon>
111
+          <text class="text-gray-500 text-sm mt-4 inline-block">{{ error }}</text>
112
+        </view>
113
+      </block>
114
+      <block v-else>
115
+        <wd-card 
116
+          v-for="group in menuData" 
117
+          :key="group.id"
118
+          class="mb-3"
119
+          :title="group.meta?.title || group.name"
120
+          :border="true"
121
+          :shadow="false"
122
+        >
123
+          <wd-grid :column="4" :gutter="10">
124
+            <wd-grid-item 
125
+              v-for="item in group.children" 
126
+              :key="item.id"
127
+              @click="handleMenuItemClick(item)"
128
+              class="menu-item"
129
+            >
130
+              <wd-card 
131
+                size="small" 
132
+                :border="false"
133
+                class="menu-card"
134
+                :style="{ backgroundColor: (item.iconColor || '#4CAF50') + '20' }"
135
+              >
136
+                <wd-icon :name="item.meta?.icon || item.icon" color="#0083ff" size="30" />
137
+              </wd-card>
138
+              <text class="text-xs text-center mt-1">{{ item.meta?.title || item.name }}</text>
139
+            </wd-grid-item>
140
+          </wd-grid>
141
+        </wd-card>
142
+      </block>
143
+    </view>
144
+    <tabbar />
145
+  </view>
146
+</template>
147
+
148
+<style scoped>
149
+.menu-item {
150
+  padding: 10rpx 0;
151
+}
152
+
153
+.menu-card {
154
+  display: flex;
155
+  align-items: center;
156
+  justify-content: center;
157
+  height: 100rpx;
158
+  border-radius: 16rpx !important;
159
+  padding: 0;
160
+  margin: 0 8px;
161
+}
162
+/* 修复wot ui组件样式 */
163
+:deep(.wd-grid-item__content) {
164
+  padding: 0;
165
+}
166
+</style>

+ 275 - 0
src/pages/me/me.vue

@@ -0,0 +1,275 @@
1
+<script lang="ts" setup>
2
+import { storeToRefs } from 'pinia'
3
+import { ref } from 'vue'
4
+import { LOGIN_PAGE } from '@/router/config'
5
+import { useUserStore } from '@/store'
6
+import { useTokenStore } from '@/store/token'
7
+
8
+
9
+definePage({
10
+  style: {
11
+    navigationBarTitleText: '我的',
12
+    tabBarHidden: true, // 隐藏底部的tabBar
13
+  },
14
+})
15
+
16
+const userStore = useUserStore()
17
+const tokenStore = useTokenStore()
18
+// 使用storeToRefs解构userInfo
19
+const { userInfo } = storeToRefs(userStore)
20
+console.log(userInfo.value)
21
+// 功能列表数据
22
+const featureList = [
23
+  // { name: '365会员', icon: 'vip', badge: '双11特价1元' },
24
+  // { name: '钉瓜瓜', icon: 'message', dot: true },
25
+  // { name: '钱包(支付宝)', icon: 'wallet' },
26
+  // { name: '权益', icon: 'gift', badge: '26积分待领取' },
27
+  // { name: '动态', icon: 'data-board' },
28
+  // { name: '收藏', icon: 'star' },
29
+  // { name: '客服与帮助', icon: 'customer-service' },
30
+  { name: '隐私', icon: 'setting' },
31
+]
32
+
33
+// 将图标名称转换为文本
34
+function getIconText(iconName: string): string {
35
+  const iconMap: Record<string, string> = {
36
+    'vip': '会员',
37
+    'message': '瓜',
38
+    'wallet': '钱',
39
+    'gift': '权',
40
+    'data-board': '动',
41
+    'star': '收',
42
+    'customer-service': '客',
43
+    'setting': '设'
44
+  }
45
+  return iconMap[iconName] || iconName.charAt(0)
46
+}
47
+// 处理功能项点击
48
+function handleFeature(featureName: string) {
49
+  uni.showToast({
50
+    title: `点击了${featureName}`,
51
+    icon: 'none'
52
+  })
53
+}
54
+
55
+// 微信小程序下登录
56
+async function handleLogin() {
57
+  /* #ifdef MP-WEIXIN */
58
+  // 微信登录
59
+  await tokenStore.wxLogin()
60
+
61
+  /* #endif */
62
+  /* #ifndef MP-WEIXIN */
63
+  uni.navigateTo({
64
+    url: `${LOGIN_PAGE}`,
65
+  })
66
+  /* #endif */
67
+}
68
+
69
+async function handleLogout() {
70
+  uni.showModal({
71
+    title: '提示',
72
+    content: '确定要退出登录吗?',
73
+    success: async (res) => {
74
+      if (res.confirm) {
75
+        // 清空用户信息,等待logout执行完毕
76
+        await useTokenStore().logout()
77
+        // 执行退出登录逻辑
78
+        uni.showToast({
79
+          title: '退出登录成功',
80
+          icon: 'success',
81
+        })
82
+        // 使用reLaunch清除所有页面栈并跳转到登录页
83
+        uni.reLaunch({
84
+          url: LOGIN_PAGE
85
+        })
86
+      }
87
+    },
88
+  })
89
+}
90
+</script>
91
+
92
+<template>
93
+  <view class="profile-container">
94
+    <!-- 头部信息区域 -->
95
+    <view class="header-section bg-white p-4">
96
+      <view class="user-info-flex">
97
+        <!-- 左侧名字四方框 -->
98
+        <view class="avatar-container">
99
+          <text class="avatar-text">{{ userInfo.nickname ? userInfo.nickname.charAt(0) : '游' }}</text>
100
+        </view>
101
+        <!-- 右侧人员信息 -->
102
+        <view class="user-details">
103
+          <view class="username">{{ userInfo.nickname || '游客' }}</view>
104
+          <view class="user-meta">
105
+            <text class="user-role">{{ userInfo.username || '普通用户' }}</text>
106
+          </view>
107
+        </view>
108
+      </view>
109
+    </view>
110
+
111
+    <!-- 功能列表 -->
112
+    <view class="feature-list mt-4 bg-white">
113
+      <view v-for="(item, index) in featureList" :key="index" class="feature-item" @click="handleFeature(item.name)">
114
+        <view class="feature-left">
115
+          <text class="feature-icon">{{ getIconText(item.icon) }}</text>
116
+          <text class="feature-name">{{ item.name }}</text>
117
+        </view>
118
+        <view class="feature-right">
119
+          <text v-if="item.badge" class="feature-badge">{{ item.badge }}</text>
120
+          <text v-if="item.dot" class="notification-dot"></text>
121
+          <text class="arrow">></text>
122
+        </view>
123
+      </view>
124
+    </view>
125
+    <!-- 退出登录按钮 -->
126
+    <view class="logout-section px-4 mb-8">
127
+      <button v-if="tokenStore.hasLogin" type="primary" class="w-full" @click="handleLogout">
128
+        退出登录
129
+      </button>
130
+      <button v-else type="primary" class="w-full" @click="handleLogin">
131
+        登录
132
+      </button>
133
+    </view>
134
+  </view>
135
+</template>
136
+
137
+<style scoped>
138
+.profile-container {
139
+  background-color: #f5f5f5;
140
+  min-height: 100vh;
141
+}
142
+
143
+.header-section {
144
+  background-color: white;
145
+  padding: 16px;
146
+}
147
+
148
+.user-info-flex {
149
+  display: flex;
150
+  align-items: center;
151
+  gap: 12px;
152
+}
153
+
154
+.avatar-container {
155
+  width: 60px;
156
+  height: 60px;
157
+  background-color: #1677ff;
158
+  border-radius: 4px;
159
+  display: flex;
160
+  align-items: center;
161
+  justify-content: center;
162
+  color: white;
163
+}
164
+
165
+.avatar-text {
166
+  font-size: 24px;
167
+  font-weight: 500;
168
+}
169
+
170
+.user-details {
171
+  display: flex;
172
+  flex-direction: column;
173
+  gap: 4px;
174
+}
175
+
176
+.username {
177
+  font-size: 18px;
178
+  font-weight: 500;
179
+  color: #333;
180
+}
181
+
182
+.user-meta {
183
+  display: flex;
184
+  gap: 8px;
185
+}
186
+
187
+.user-role {
188
+  font-size: 14px;
189
+  color: #666;
190
+}
191
+
192
+.feature-list {
193
+  margin-top: 16px;
194
+  background-color: white;
195
+}
196
+
197
+.feature-item {
198
+  display: flex;
199
+  justify-content: space-between;
200
+  align-items: center;
201
+  padding: 16px;
202
+  border-bottom: 1px solid #f0f0f0;
203
+}
204
+
205
+.feature-item:last-child {
206
+  border-bottom: none;
207
+}
208
+
209
+.feature-left {
210
+  display: flex;
211
+  align-items: center;
212
+  gap: 12px;
213
+}
214
+
215
+.feature-icon {
216
+  width: 24px;
217
+  height: 24px;
218
+  display: flex;
219
+  align-items: center;
220
+  justify-content: center;
221
+  background-color: #f0f9ff;
222
+  color: #1677ff;
223
+  border-radius: 4px;
224
+  font-size: 14px;
225
+  font-weight: 500;
226
+}
227
+
228
+.feature-name {
229
+  font-size: 16px;
230
+  color: #333;
231
+}
232
+
233
+.feature-right {
234
+  display: flex;
235
+  align-items: center;
236
+  gap: 8px;
237
+}
238
+
239
+.feature-badge {
240
+  font-size: 12px;
241
+  color: #ff4d4f;
242
+  background-color: #fff1f0;
243
+  padding: 2px 6px;
244
+  border-radius: 10px;
245
+}
246
+
247
+.notification-dot {
248
+  width: 8px;
249
+  height: 8px;
250
+  background-color: #ff4d4f;
251
+  border-radius: 50%;
252
+}
253
+
254
+.arrow {
255
+  font-size: 14px;
256
+  color: #c0c4cc;
257
+}
258
+
259
+.version-info {
260
+  margin-top: 32px;
261
+  margin-bottom: 24px;
262
+  text-align: center;
263
+  font-size: 14px;
264
+  color: #909399;
265
+}
266
+
267
+.logout-section {
268
+  padding: 16px;
269
+  margin-bottom: 32px;
270
+}
271
+
272
+button {
273
+  width: 100%;
274
+}
275
+</style>

+ 55 - 0
src/router/README.md

@@ -0,0 +1,55 @@
1
+# 登录 说明
2
+
3
+## 登录 2种策略
4
+- 默认无需登录策略: DEFAULT_NO_NEED_LOGIN
5
+- 默认需要登录策略: DEFAULT_NEED_LOGIN
6
+
7
+### 默认无需登录策略: DEFAULT_NO_NEED_LOGIN
8
+进入任何页面都不需要登录,只有进入到黑名单中的页面/或者页面中某些动作需要登录,才需要登录。
9
+
10
+比如大部分2C的应用,美团、今日头条、抖音等,都可以直接浏览,只有点赞、评论、分享等操作或者去特殊页面(比如个人中心),才需要登录。
11
+
12
+### 默认需要登录策略: DEFAULT_NEED_LOGIN
13
+
14
+进入任何页面都需要登录,只有进入到白名单中的页面,才不需要登录。默认进入应用需要先去登录页。
15
+
16
+比如大部分2B和后台管理类的应用,比如企业微信、钉钉、飞书、内部报表系统、CMS系统等,都需要登录,只有登录后,才能使用。
17
+
18
+### EXCLUDE_LOGIN_PATH_LIST
19
+`EXCLUDE_LOGIN_PATH_LIST` 表示排除的路由列表。
20
+
21
+在 `默认无需登录策略: DEFAULT_NO_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才需要登录,相当于黑名单。
22
+
23
+在 `默认需要登录策略: DEFAULT_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才不需要登录,相当于白名单。
24
+
25
+### excludeLoginPath
26
+definePage 中可以通过 `excludeLoginPath` 来配置路由是否需要登录。(类似过去的 needLogin 的功能)
27
+
28
+```ts
29
+definePage({
30
+  style: {
31
+    navigationBarTitleText: '关于',
32
+  },
33
+  // 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹
34
+  excludeLoginPath: true,
35
+  // 角色授权(可选):如果需要根据角色授权,就配置这个
36
+  roleAuth: {
37
+    field: 'role',
38
+    value: 'admin',
39
+    redirect: '/pages/auth/403',
40
+  },
41
+})
42
+```
43
+
44
+## 登录注册页路由
45
+
46
+登录页 `login.vue` 对应路由是 `/pages/login/login`.
47
+注册页 `register.vue` 对应路由是 `/pages/login/register`.
48
+
49
+## 登录注册页适用性
50
+
51
+登录注册页主要适用于 `h5` 和 `App`,默认不适用于 `小程序`,因为 `小程序` 通常会使用平台提供的快捷登录。
52
+
53
+特殊情况例外,如业务需要跨平台复用登录注册页时,也可以用在 `小程序` 上,所以主要还是看业务需求。
54
+
55
+通过一个参数 `LOGIN_PAGE_ENABLE_IN_MP` 来控制是否在 `小程序` 中使用 `H5登录页` 的登录逻辑。

+ 31 - 0
src/router/config.ts

@@ -0,0 +1,31 @@
1
+import { getAllPages } from '@/utils'
2
+
3
+export const LOGIN_STRATEGY_MAP = {
4
+  DEFAULT_NO_NEED_LOGIN: 0, // 黑名单策略,默认可以进入APP
5
+  DEFAULT_NEED_LOGIN: 1, // 白名单策略,默认不可以进入APP,需要强制登录
6
+}
7
+// TODO: 1/3 登录策略,默认使用`无需登录策略`,即默认不需要登录就可以访问
8
+export const LOGIN_STRATEGY = LOGIN_STRATEGY_MAP.DEFAULT_NO_NEED_LOGIN
9
+export const isNeedLoginMode = LOGIN_STRATEGY === LOGIN_STRATEGY_MAP.DEFAULT_NEED_LOGIN
10
+
11
+export const LOGIN_PAGE = '/pages-fg/login/login'
12
+export const REGISTER_PAGE = '/pages-fg/login/register'
13
+export const NOT_FOUND_PAGE = '/pages-fg/404/index'
14
+
15
+export const LOGIN_PAGE_LIST = [LOGIN_PAGE, REGISTER_PAGE]
16
+
17
+// 在 definePage 里面配置了 excludeLoginPath 的页面,功能与 EXCLUDE_LOGIN_PATH_LIST 相同
18
+export const excludeLoginPathList = getAllPages('excludeLoginPath').map(page => page.path)
19
+
20
+// 排除在外的列表,白名单策略指白名单列表,黑名单策略指黑名单列表
21
+// TODO: 2/3 在 definePage 配置 excludeLoginPath,或者在下面配置 EXCLUDE_LOGIN_PATH_LIST
22
+export const EXCLUDE_LOGIN_PATH_LIST = [
23
+  '/pages/xxx/index', // 示例值
24
+  '/pages-sub/xxx/index', // 示例值
25
+  ...excludeLoginPathList, // 都是以 / 开头的 path
26
+]
27
+
28
+// 在小程序里面是否使用H5的登录页,默认为 false
29
+// 如果为 true 则复用 h5 的登录逻辑
30
+// TODO: 3/3 确定自己的登录页是否需要在小程序里面使用
31
+export const LOGIN_PAGE_ENABLE_IN_MP = false

+ 170 - 0
src/router/interceptor.ts

@@ -0,0 +1,170 @@
1
+import { isMp } from '@uni-helper/uni-env'
2
+/**
3
+ * by 菲鸽 on 2025-08-19
4
+ * 路由拦截,通常也是登录拦截
5
+ * 黑、白名单的配置,请看 config.ts 文件, EXCLUDE_LOGIN_PATH_LIST
6
+ */
7
+import { useTokenStore } from '@/store/token'
8
+import { isPageTabbar, tabbarStore } from '@/tabbar/store'
9
+import { getAllPages, getLastPage, HOME_PAGE, parseUrlToObj } from '@/utils/index'
10
+import { EXCLUDE_LOGIN_PATH_LIST, isNeedLoginMode, LOGIN_PAGE, LOGIN_PAGE_ENABLE_IN_MP, NOT_FOUND_PAGE } from './config'
11
+
12
+export const FG_LOG_ENABLE = false
13
+
14
+// 系统内部页面白名单(不需要检查路由存在性)
15
+const SYSTEM_INTERNAL_PATHS = [
16
+  '/__uniappchooselocation', // 选择位置页面
17
+  '/__uniapproute', // 路由页面
18
+  '/__uniappfilepicker', // 文件选择器
19
+  '/__uniappimagepicker', // 图片选择器
20
+]
21
+
22
+export function judgeIsExcludePath(path: string) {
23
+  const isDev = import.meta.env.DEV
24
+  if (!isDev) {
25
+    return EXCLUDE_LOGIN_PATH_LIST.includes(path)
26
+  }
27
+  const allExcludeLoginPages = getAllPages('excludeLoginPath') // dev 环境下,需要每次都重新获取,否则新配置就不会生效
28
+  return EXCLUDE_LOGIN_PATH_LIST.includes(path) || (isDev && allExcludeLoginPages.some(page => page.path === path))
29
+}
30
+
31
+// 检查是否为系统内部页面
32
+export function isSystemInternalPath(path: string): boolean {
33
+  return SYSTEM_INTERNAL_PATHS.includes(path)
34
+}
35
+
36
+// 检查路由是否存在
37
+export function isRouteExists(path: string): boolean {
38
+  // 系统内部页面始终认为存在
39
+  if (isSystemInternalPath(path)) {
40
+    return true
41
+  }
42
+
43
+  const allPages = getAllPages()
44
+  return allPages.some(page => page.path === path) || path === '/'
45
+}
46
+
47
+export const navigateToInterceptor = {
48
+  // 注意,这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同
49
+  // 增加对相对路径的处理,BY 网友 @ideal
50
+  invoke({ url, query }: { url: string, query?: Record<string, string> }) {
51
+    if (url === undefined) {
52
+      return
53
+    }
54
+    let { path, query: _query } = parseUrlToObj(url)
55
+
56
+    FG_LOG_ENABLE && console.log('\n\n路由拦截器:-------------------------------------')
57
+    FG_LOG_ENABLE && console.log('路由拦截器 1: url->', url, ', query ->', query)
58
+    const myQuery = { ..._query, ...query }
59
+    // /pages/route-interceptor/index?name=feige&age=30
60
+    FG_LOG_ENABLE && console.log('路由拦截器 2: path->', path, ', _query ->', _query)
61
+    FG_LOG_ENABLE && console.log('路由拦截器 3: myQuery ->', myQuery)
62
+
63
+    // 处理相对路径
64
+    if (!path.startsWith('/')) {
65
+      const currentPath = getLastPage()?.route || ''
66
+      const normalizedCurrentPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}`
67
+      const baseDir = normalizedCurrentPath.substring(0, normalizedCurrentPath.lastIndexOf('/'))
68
+      path = `${baseDir}/${path}`
69
+    }
70
+
71
+    // 系统内部页面直接放行(不检查路由存在性,不进行登录拦截)
72
+    if (isSystemInternalPath(path)) {
73
+      FG_LOG_ENABLE && console.log('系统内部页面,直接放行:', path)
74
+      return true
75
+    }
76
+
77
+    // 处理路由不存在的情况
78
+    if (!isRouteExists(path)) {
79
+      console.warn('路由不存在:', path)
80
+      uni.navigateTo({ url: NOT_FOUND_PAGE })
81
+      return false // 明确表示阻止原路由继续执行
82
+    }
83
+
84
+    // 处理直接进入路由非首页时,tabbarIndex 不正确的问题
85
+    tabbarStore.setAutoCurIdx(path)
86
+
87
+    // 小程序里面使用平台自带的登录,则不走下面的逻辑
88
+    if (isMp && !LOGIN_PAGE_ENABLE_IN_MP) {
89
+      return true // 明确表示允许路由继续执行
90
+    }
91
+
92
+    const tokenStore = useTokenStore()
93
+    FG_LOG_ENABLE && console.log('tokenStore.hasLogin:', tokenStore.hasLogin)
94
+
95
+    // 不管黑白名单,登录了就直接去吧(但是当前不能是登录页)
96
+    if (tokenStore.hasLogin) {
97
+      if (path !== LOGIN_PAGE) {
98
+        return true // 明确表示允许路由继续执行
99
+      }
100
+      else {
101
+        console.log('已经登录,但是还在登录页', myQuery.redirect)
102
+        const url = myQuery.redirect || HOME_PAGE
103
+        if (isPageTabbar(url)) {
104
+          uni.switchTab({ url })
105
+        }
106
+        else {
107
+          uni.navigateTo({ url })
108
+        }
109
+        return false // 明确表示阻止原路由继续执行
110
+      }
111
+    }
112
+    let fullPath = path
113
+
114
+    if (Object.keys(myQuery).length) {
115
+      fullPath += `?${Object.keys(myQuery).map(key => `${key}=${myQuery[key]}`).join('&')}`
116
+    }
117
+    const redirectUrl = `${LOGIN_PAGE}?redirect=${encodeURIComponent(fullPath)}`
118
+
119
+    // #region 1/2 默认需要登录的情况(白名单策略) ---------------------------
120
+    if (isNeedLoginMode) {
121
+      // 需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示白名单,可以直接通过
122
+      if (judgeIsExcludePath(path)) {
123
+        return true // 明确表示允许路由继续执行
124
+      }
125
+      // 否则需要重定向到登录页
126
+      else {
127
+        if (path === LOGIN_PAGE) {
128
+          return true // 明确表示允许路由继续执行
129
+        }
130
+        FG_LOG_ENABLE && console.log('1 isNeedLogin(白名单策略) redirectUrl:', redirectUrl)
131
+        uni.navigateTo({ url: redirectUrl })
132
+        return false // 明确表示阻止原路由继续执行
133
+      }
134
+    }
135
+    // #endregion 1/2 默认需要登录的情况(白名单策略) ---------------------------
136
+
137
+    // #region 2/2 默认不需要登录的情况(黑名单策略) ---------------------------
138
+    else {
139
+      // 不需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示黑名单,需要重定向到登录页
140
+      if (judgeIsExcludePath(path)) {
141
+        FG_LOG_ENABLE && console.log('2 isNeedLogin(黑名单策略) redirectUrl:', redirectUrl)
142
+        uni.navigateTo({ url: redirectUrl })
143
+        return false // 修改为false,阻止原路由继续执行
144
+      }
145
+      return true // 明确表示允许路由继续执行
146
+    }
147
+    // #endregion 2/2 默认不需要登录的情况(黑名单策略) ---------------------------
148
+  },
149
+}
150
+
151
+// 针对 chooseLocation 的特殊处理
152
+export const chooseLocationInterceptor = {
153
+  invoke(options: any) {
154
+    // 直接放行 chooseLocation 调用
155
+    FG_LOG_ENABLE && console.log('chooseLocation 调用,直接放行:', options)
156
+    return true
157
+  },
158
+}
159
+
160
+export const routeInterceptor = {
161
+  install() {
162
+    uni.addInterceptor('navigateTo', navigateToInterceptor)
163
+    uni.addInterceptor('reLaunch', navigateToInterceptor)
164
+    uni.addInterceptor('redirectTo', navigateToInterceptor)
165
+    uni.addInterceptor('switchTab', navigateToInterceptor)
166
+
167
+    // 添加 chooseLocation 的拦截器,确保直接放行
168
+    uni.addInterceptor('chooseLocation', chooseLocationInterceptor)
169
+  },
170
+}

+ 6 - 0
src/service/index.ts

@@ -0,0 +1,6 @@
1
+/* eslint-disable */
2
+// @ts-ignore
3
+export * from './types';
4
+
5
+export * from './listAll';
6
+export * from './info';

+ 14 - 0
src/service/info.ts

@@ -0,0 +1,14 @@
1
+/* eslint-disable */
2
+// @ts-ignore
3
+import request from '@/http/vue-query';
4
+import { CustomRequestOptions_ } from '@/http/types';
5
+
6
+import * as API from './types';
7
+
8
+/** 用户信息 GET /user/info */
9
+export function infoUsingGet({ options }: { options?: CustomRequestOptions_ }) {
10
+  return request<API.InfoUsingGetResponse>('/getInfo', {
11
+    method: 'GET',
12
+    ...(options || {}),
13
+  });
14
+}

+ 18 - 0
src/service/listAll.ts

@@ -0,0 +1,18 @@
1
+/* eslint-disable */
2
+// @ts-ignore
3
+import request from '@/http/vue-query';
4
+import { CustomRequestOptions_ } from '@/http/types';
5
+
6
+import * as API from './types';
7
+
8
+/** 用户列表 GET /user/listAll */
9
+export function listAllUsingGet({
10
+  options,
11
+}: {
12
+  options?: CustomRequestOptions_;
13
+}) {
14
+  return request<API.ListAllUsingGetResponse>('/user/listAll', {
15
+    method: 'GET',
16
+    ...(options || {}),
17
+  });
18
+}

+ 29 - 0
src/service/types.ts

@@ -0,0 +1,29 @@
1
+/* eslint-disable */
2
+// @ts-ignore
3
+
4
+export type InfoUsingGetResponse = {
5
+  code: number;
6
+  msg: string;
7
+  data: UserItem;
8
+};
9
+
10
+export type InfoUsingGetResponses = {
11
+  200: InfoUsingGetResponse;
12
+};
13
+
14
+export type ListAllUsingGetResponse = {
15
+  code: number;
16
+  msg: string;
17
+  data: UserItem[];
18
+};
19
+
20
+export type ListAllUsingGetResponses = {
21
+  200: ListAllUsingGetResponse;
22
+};
23
+
24
+export type UserItem = {
25
+  userId: number;
26
+  username: string;
27
+  nickname: string;
28
+  avatar: string;
29
+};

BIN
src/static/app/icons/1024x1024.png


BIN
src/static/app/icons/120x120.png


BIN
src/static/app/icons/144x144.png


BIN
src/static/app/icons/152x152.png


BIN
src/static/app/icons/167x167.png


BIN
src/static/app/icons/180x180.png


BIN
src/static/app/icons/192x192.png


BIN
src/static/app/icons/20x20.png


BIN
src/static/app/icons/29x29.png


BIN
src/static/app/icons/40x40.png


BIN
src/static/app/icons/58x58.png


BIN
src/static/app/icons/60x60.png


BIN
src/static/app/icons/72x72.png


BIN
src/static/app/icons/76x76.png


BIN
src/static/app/icons/80x80.png


BIN
src/static/app/icons/87x87.png


BIN
src/static/app/icons/96x96.png


BIN
src/static/images/avatar.jpg


+ 0 - 0
src/static/images/default-avatar.png


Some files were not shown because too many files changed in this diff