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

+ + + +

+ +

+ unibest - 最好的 uniapp 开发框架 +

+ +
+旧仓库 codercup 进不去了,star 也拿不回来,这里也展示一下那个地址的 star. + +[![GitHub Repo stars](https://img.shields.io/github/stars/codercup/unibest?style=flat&logo=github)](https://github.com/codercup/unibest) +[![GitHub forks](https://img.shields.io/github/forks/codercup/unibest?style=flat&logo=github)](https://github.com/codercup/unibest) + +
+ +
+ +[![GitHub Repo stars](https://img.shields.io/github/stars/feige996/unibest?style=flat&logo=github)](https://github.com/feige996/unibest) +[![GitHub forks](https://img.shields.io/github/forks/feige996/unibest?style=flat&logo=github)](https://github.com/feige996/unibest) +[![star](https://gitee.com/feige996/unibest/badge/star.svg?theme=dark)](https://gitee.com/feige996/unibest/stargazers) +[![fork](https://gitee.com/feige996/unibest/badge/fork.svg?theme=dark)](https://gitee.com/feige996/unibest/members) +![node version](https://img.shields.io/badge/node-%3E%3D18-green) +![pnpm version](https://img.shields.io/badge/pnpm-%3E%3D7.30-green) +![GitHub package.json version (subfolder of monorepo)](https://img.shields.io/github/package-json/v/feige996/unibest) +![GitHub License](https://img.shields.io/github/license/feige996/unibest) + +
+ +`unibest` —— 最好的 `uniapp` 开发模板,由 `uniapp` + `Vue3` + `Ts` + `Vite5` + `UnoCss` + `wot-ui` + `z-paging` 构成,使用了最新的前端技术栈,无需依靠 `HBuilderX`,通过命令行方式运行 `web`、`小程序` 和 `App`(编辑器推荐 `VSCode`,可选 `webstorm`)。 + +`unibest` 内置了 `约定式路由`、`layout布局`、`请求封装`、`请求拦截`、`登录拦截`、`UnoCSS`、`i18n多语言` 等基础功能,提供了 `代码提示`、`自动格式化`、`统一配置`、`代码片段` 等辅助功能,让你编写 `uniapp` 拥有 `best` 体验 ( `unibest 的由来`)。 + +![](https://raw.githubusercontent.com/andreasbm/readme/master/screenshots/lines/rainbow.png) + +

+ 📖 文档地址(new) + | + 📱 DEMO 地址 +

+ +--- + +注意旧的地址 [codercup](https://github.com/codercup/unibest) 我进不去了,使用新的 [feige996](https://github.com/feige996/unibest)。PR和 issue 也请使用新地址,否则无法合并。 + +## 平台兼容性 + +| H5 | IOS | 安卓 | 微信小程序 | 字节小程序 | 快手小程序 | 支付宝小程序 | 钉钉小程序 | 百度小程序 | +| --- | --- | ---- | ---------- | ---------- | ---------- | ------------ | ---------- | ---------- | +| √ | √ | √ | √ | √ | √ | √ | √ | √ | + +注意每种 `UI框架` 支持的平台有所不同,详情请看各 `UI框架` 的官网,也可以看 `unibest` 文档。 + +## ⚙️ 环境 + +- node>=18 +- pnpm>=7.30 +- Vue Official>=2.1.10 +- TypeScript>=5.0 + +## 新版分支 +- main == base +- base --> base-i18n +- base-login --> base-login-i18n + +## 📂 快速开始 + +执行 `pnpm create unibest` 创建项目 +执行 `pnpm i` 安装依赖 +执行 `pnpm dev` 运行 `H5` +执行 `pnpm dev:mp` 运行 `微信小程序` + +## 📦 运行(支持热更新) + +- web平台: `pnpm dev:h5`, 然后打开 [http://localhost:9000/](http://localhost:9000/)。 +- weixin平台:`pnpm dev:mp` 然后打开微信开发者工具,导入本地文件夹,选择本项目的`dist/dev/mp-weixin` 文件。 +- APP平台:`pnpm dev:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/dev/app` 文件夹,选择运行到模拟器(开发时优先使用),或者运行的安卓/ios基座。(如果是 `安卓` 和 `鸿蒙` 平台,则不用这个方式,可以把整个unibest项目导入到hbx,通过hbx的菜单来运行到对应的平台。) + +## 🔗 发布 + +- web平台: `pnpm build:h5`,打包后的文件在 `dist/build/h5`,可以放到web服务器,如nginx运行。如果最终不是放在根目录,可以在 `manifest.config.ts` 文件的 `h5.router.base` 属性进行修改。 +- weixin平台:`pnpm build:mp`, 打包后的文件在 `dist/build/mp-weixin`,然后通过微信开发者工具导入,并点击右上角的“上传”按钮进行上传。 +- APP平台:`pnpm build:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/build/app` 文件夹,选择发行 - APP云打包。(如果是 `安卓` 和 `鸿蒙` 平台,则不用这个方式,可以把整个unibest项目导入到hbx,通过hbx的菜单来发行到对应的平台。) + +## 📄 License + +[MIT](https://opensource.org/license/mit/) + +Copyright (c) 2025 菲鸽 + +## 捐赠 + +

+special sponsor appwrite +special sponsor appwrite +

diff --git a/env/.env b/env/.env new file mode 100644 index 0000000..bf7e046 --- /dev/null +++ b/env/.env @@ -0,0 +1,30 @@ +VITE_APP_TITLE = 'unibest' +VITE_APP_PORT = 9000 + +VITE_UNI_APPID = '__UNI__D1E5001' +VITE_WX_APPID = 'wxa2abb91f64032a2b' + +# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base +# https://uniapp.dcloud.net.cn/collocation/manifest.html#h5-router +# 比如你要部署到 https://unibest.tech/doc/ ,则配置为 /doc/ +VITE_APP_PUBLIC_BASE=/ + +# 后台请求地址 +VITE_SERVER_BASEURL = 'https://ukw0y1.laf.run' +# 备注:如果后台带统一前缀,则也要加到后面,eg: https://ukw0y1.laf.run/api + +# 注意,如果是微信小程序,还有一套请求地址的配置,根据 develop、trial、release 分别设置上传地址,见 `src/utils/index.ts`。 + +# h5是否需要配置代理 +VITE_APP_PROXY_ENABLE = false +# 下面的不用修改,只要不跟你后台的统一前缀冲突就行。如果修改了,记得修改 `nginx` 里面的配置 +VITE_APP_PROXY_PREFIX = '/fg-api' + +# 第二个请求地址 (目前alova中可以使用) +VITE_SERVER_BASEURL_SECONDARY = 'https://ukw0y1.laf.run' + +# 认证模式,'single' | 'double' ==> 单token | 双token +VITE_AUTH_MODE = 'single' + +# 原生插件资源复制开关,控制是否启用 copy-native-resources 插件 +VITE_COPY_NATIVE_RES_ENABLE = false diff --git a/env/.env.development b/env/.env.development new file mode 100644 index 0000000..ac1b65b --- /dev/null +++ b/env/.env.development @@ -0,0 +1,9 @@ +# 变量必须以 VITE_ 为前缀才能暴露给外部读取 +NODE_ENV = 'development' +# 是否去除console 和 debugger +VITE_DELETE_CONSOLE = false +# 是否开启sourcemap +VITE_SHOW_SOURCEMAP = false + +# 后台请求地址 +# VITE_SERVER_BASEURL = 'https://dev.xxx.com' diff --git a/env/.env.production b/env/.env.production new file mode 100644 index 0000000..eef01df --- /dev/null +++ b/env/.env.production @@ -0,0 +1,9 @@ +# 变量必须以 VITE_ 为前缀才能暴露给外部读取 +NODE_ENV = 'production' +# 是否去除console 和 debugger +VITE_DELETE_CONSOLE = true +# 是否开启sourcemap +VITE_SHOW_SOURCEMAP = false + +# 后台请求地址 +# VITE_SERVER_BASEURL = 'https://prod.xxx.com' diff --git a/env/.env.test b/env/.env.test new file mode 100644 index 0000000..5a975f8 --- /dev/null +++ b/env/.env.test @@ -0,0 +1,9 @@ +# 变量必须以 VITE_ 为前缀才能暴露给外部读取 +NODE_ENV = 'development' +# 是否去除console 和 debugger +VITE_DELETE_CONSOLE = false +# 是否开启sourcemap +VITE_SHOW_SOURCEMAP = false + +# 后台请求地址 +# VITE_SERVER_BASEURL = 'https://test.xxx.com' diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..c62f529 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,58 @@ +import uniHelper from '@uni-helper/eslint-config' + +export default uniHelper({ + unocss: true, + vue: true, + markdown: false, + ignores: [ + // 忽略uni_modules目录 + '**/uni_modules/', + // 忽略原生插件目录 + '**/nativeplugins/', + 'dist', + // unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用 + 'auto-import.d.ts', + // vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore + 'uni-pages.d.ts', + // 插件生成的文件 + 'src/pages.json', + 'src/manifest.json', + // 忽略自动生成文件 + 'src/service/**', + ], + // https://eslint-config.antfu.me/rules + rules: { + 'no-useless-return': 'off', + 'no-console': 'off', + 'no-unused-vars': 'off', + 'vue/no-unused-refs': 'off', + 'unused-imports/no-unused-vars': 'off', + 'eslint-comments/no-unlimited-disable': 'off', + 'jsdoc/check-param-names': 'off', + 'jsdoc/require-returns-description': 'off', + 'ts/no-empty-object-type': 'off', + 'no-extend-native': 'off', + 'vue/singleline-html-element-content-newline': [ + 'error', + { + externalIgnores: ['text'], + }, + ], + // vue SFC 调换顺序改这里 + 'vue/block-order': ['error', { + order: [['script', 'template'], 'style'], + }], + }, + formatters: { + /** + * Format CSS, LESS, SCSS files, also the ` diff --git a/src/api/foo-alova.ts b/src/api/foo-alova.ts new file mode 100644 index 0000000..de35095 --- /dev/null +++ b/src/api/foo-alova.ts @@ -0,0 +1,17 @@ +import { API_DOMAINS, http } from '@/http/alova' + +export interface IFoo { + id: number + name: string +} + +export function foo() { + return http.Get('/foo', { + params: { + name: '菲鸽', + page: 1, + pageSize: 10, + }, + meta: { domain: API_DOMAINS.SECONDARY }, // 用于切换请求地址 + }) +} diff --git a/src/api/foo.ts b/src/api/foo.ts new file mode 100644 index 0000000..a500002 --- /dev/null +++ b/src/api/foo.ts @@ -0,0 +1,43 @@ +import { http } from '@/http/http' + +export interface IFoo { + id: number + name: string +} + +export function foo() { + return http.Get('/foo', { + params: { + name: '菲鸽', + page: 1, + pageSize: 10, + }, + }) +} + +export interface IFooItem { + id: string + name: string +} + +/** GET 请求 */ +export async function getFooAPI(name: string) { + return await http.get('/foo', { name }) +} +/** GET 请求;支持 传递 header 的范例 */ +export function getFooAPI2(name: string) { + return http.get('/foo', { name }, { 'Content-Type-100': '100' }) +} + +/** POST 请求 */ +export function postFooAPI(name: string) { + return http.post('/foo', { name }) +} +/** POST 请求;需要传递 query 参数的范例;微信小程序经常有同时需要query参数和body参数的场景 */ +export function postFooAPI2(name: string) { + return http.post('/foo', { name }, { a: 1, b: 2 }) +} +/** POST 请求;支持 传递 header 的范例 */ +export function postFooAPI3(name: string) { + return http.post('/foo', { name }, { a: 1, b: 2 }, { 'Content-Type-100': '100' }) +} diff --git a/src/api/login.ts b/src/api/login.ts new file mode 100644 index 0000000..7691c9b --- /dev/null +++ b/src/api/login.ts @@ -0,0 +1,85 @@ +import type { IAuthLoginRes, ICaptcha, IDoubleTokenRes, IUpdateInfo, IUpdatePassword, IUserInfoRes } from './types/login' +import { http } from '@/http/http' + +/** + * 登录表单 + */ +export interface ILoginForm { + username: string + password: string +} + +/** + * 获取验证码 + * @returns ICaptcha 验证码 + */ +export function getCode() { + return http.get('/user/getCode') +} + +/** + * 用户登录 + * @param loginForm 登录表单 + */ +export function login(loginForm: ILoginForm) { + return http.post('/auth/login', loginForm) +} + +/** + * 刷新token + * @param refreshToken 刷新token + */ +export function refreshToken(refreshToken: string) { + return http.post('/auth/refreshToken', { refreshToken }) +} + +/** + * 获取用户信息 + */ +export function getUserInfo() { + return http.get('/user/info') +} + +/** + * 退出登录 + */ +export function logout() { + return http.get('/auth/logout') +} + +/** + * 修改用户信息 + */ +export function updateInfo(data: IUpdateInfo) { + return http.post('/user/updateInfo', data) +} + +/** + * 修改用户密码 + */ +export function updateUserPassword(data: IUpdatePassword) { + return http.post('/user/updatePassword', data) +} + +/** + * 获取微信登录凭证 + * @returns Promise 包含微信登录凭证(code) + */ +export function getWxCode() { + return new Promise((resolve, reject) => { + uni.login({ + provider: 'weixin', + success: res => resolve(res), + fail: err => reject(new Error(err)), + }) + }) +} + +/** + * 微信登录 + * @param params 微信登录参数,包含code + * @returns Promise 包含登录结果 + */ +export function wxLogin(data: { code: string }) { + return http.post('/auth/wxLogin', data) +} diff --git a/src/api/types/login.ts b/src/api/types/login.ts new file mode 100644 index 0000000..d703fd8 --- /dev/null +++ b/src/api/types/login.ts @@ -0,0 +1,97 @@ +// 认证模式类型 +export type AuthMode = 'single' | 'double' + +// 单Token响应类型 +export interface ISingleTokenRes { + token: string + expiresIn: number // 有效期(秒) +} + +// 双Token响应类型 +export interface IDoubleTokenRes { + accessToken: string + refreshToken: string + accessExpiresIn: number // 访问令牌有效期(秒) + refreshExpiresIn: number // 刷新令牌有效期(秒) +} + +/** + * 登录返回的信息,其实就是 token 信息 + */ +export type IAuthLoginRes = ISingleTokenRes | IDoubleTokenRes + +/** + * 用户信息 + */ +export interface IUserInfoRes { + userId: number + username: string + nickname: string + avatar?: string + [key: string]: any // 允许其他扩展字段 +} + +// 认证存储数据结构 +export interface AuthStorage { + mode: AuthMode + tokens: ISingleTokenRes | IDoubleTokenRes + userInfo?: IUserInfoRes + loginTime: number // 登录时间戳 +} + +/** + * 获取验证码 + */ +export interface ICaptcha { + captchaEnabled: boolean + uuid: string + image: string +} +/** + * 上传成功的信息 + */ +export interface IUploadSuccessInfo { + fileId: number + originalName: string + fileName: string + storagePath: string + fileHash: string + fileType: string + fileBusinessType: string + fileSize: number +} +/** + * 更新用户信息 + */ +export interface IUpdateInfo { + id: number + name: string + sex: string +} +/** + * 更新用户信息 + */ +export interface IUpdatePassword { + id: number + oldPassword: string + newPassword: string + confirmPassword: string +} + +/** + * 判断是否为单Token响应 + * @param tokenRes 登录响应数据 + * @returns 是否为单Token响应 + */ +export function isSingleTokenRes(tokenRes: IAuthLoginRes): tokenRes is ISingleTokenRes { + return 'token' in tokenRes && !('refreshToken' in tokenRes) +} + +/** + * 判断是否为双Token响应 + * @param tokenRes 登录响应数据 + * @returns 是否为双Token响应 + */ +export function isDoubleTokenRes(tokenRes: IAuthLoginRes): tokenRes is IDoubleTokenRes { + return 'accessToken' in tokenRes && 'refreshToken' in tokenRes +} diff --git a/src/components/.gitkeep b/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..ea1334c --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,35 @@ +/// +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent<{}, {}, any> + export default component +} + +interface ImportMetaEnv { + /** 网站标题,应用名称 */ + readonly VITE_APP_TITLE: string + /** 服务端口号 */ + readonly VITE_SERVER_PORT: string + /** 后台接口地址 */ + readonly VITE_SERVER_BASEURL: string + /** H5是否需要代理 */ + readonly VITE_APP_PROXY_ENABLE: 'true' | 'false' + /** H5是否需要代理,需要的话有个前缀 */ + readonly VITE_APP_PROXY_PREFIX: string + /** 后端是否有统一前缀 /api */ + readonly VITE_SERVER_HAS_API_PREFIX: 'true' | 'false' + /** 认证模式,'single' | 'double' ==> 单token | 双token */ + readonly VITE_AUTH_MODE: 'single' | 'double' + /** 是否清除console */ + readonly VITE_DELETE_CONSOLE: string + // 更多环境变量... +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +declare const __VITE_APP_PROXY__: 'true' | 'false' diff --git a/src/hooks/useRequest.ts b/src/hooks/useRequest.ts new file mode 100644 index 0000000..8ac4bfe --- /dev/null +++ b/src/hooks/useRequest.ts @@ -0,0 +1,54 @@ +import type { Ref } from 'vue' +import { ref } from 'vue' + +interface IUseRequestOptions { + /** 是否立即执行 */ + immediate?: boolean + /** 初始化数据 */ + initialData?: T +} + +interface IUseRequestReturn { + loading: Ref + error: Ref + data: Ref + run: (args?: P) => Promise +} + +/** + * useRequest是一个定制化的请求钩子,用于处理异步请求和响应。 + * @param func 一个执行异步请求的函数,返回一个包含响应数据的Promise。 + * @param options 包含请求选项的对象 {immediate, initialData}。 + * @param options.immediate 是否立即执行请求,默认为false。 + * @param options.initialData 初始化数据,默认为undefined。 + * @returns 返回一个对象{loading, error, data, run},包含请求的加载状态、错误信息、响应数据和手动触发请求的函数。 + */ +export default function useRequest( + func: (args?: P) => Promise, + options: IUseRequestOptions = { immediate: false }, +): IUseRequestReturn { + const loading = ref(false) + const error = ref(false) + const data = ref(options.initialData) as Ref + const run = async (args?: P) => { + loading.value = true + return func(args) + .then((res) => { + data.value = res + error.value = false + return data.value + }) + .catch((err) => { + error.value = err + throw err + }) + .finally(() => { + loading.value = false + }) + } + + if (options.immediate) { + (run as (args: P) => Promise)({} as P) + } + return { loading, error, data, run } +} diff --git a/src/hooks/useScroll.md b/src/hooks/useScroll.md new file mode 100644 index 0000000..bb2eace --- /dev/null +++ b/src/hooks/useScroll.md @@ -0,0 +1,116 @@ +# 上拉刷新和下拉加载更多 + +在 unibest 框架中,我们通过组合 `useScroll` Hook 可结合 `scroll-view` 组件来轻松实现上拉刷新和下拉加载更多的功能。 +场景一 页面滚动 + +``` +definePage({ + style: { + navigationBarTitleText: '上拉刷新和下拉加载更多', + enablePullDownRefresh: true, + onReachBottomDistance: 100, + }, +}) +``` + +场景二 局部滚动 结合 `scroll-view` + +## 关键文件 + +- `src/hooks/useScroll.ts`: 提供了核心的滚动逻辑处理 Hook。 +- `src/pages-sub/demo/scroll.vue`: 一个具体的实现示例页面。 + +## `useScroll` Hook + +`useScroll` 是一个 Vue Composition API Hook,它封装了处理下拉刷新和上拉加载的通用逻辑。 + +### 主要功能 + +- **管理加载状态**: 自动处理 `loading`(加载中)、`finished`(已加载全部)和 `error`(加载失败)等状态。 +- **分页逻辑**: 内部维护分页参数(页码 `page` 和每页数量 `pageSize`)。 +- **事件处理**: 提供 `onScrollToLower`(滚动到底部)、`onRefresherRefresh`(下拉刷新)等方法,用于在视图层触发。 +- **数据合并**: 自动将新加载的数据追加到现有列表 `list` 中。 + +### 使用方法 + +```typescript +import { useScroll } from '@/hooks/useScroll' +import { getList } from '@/service/list' // 你的数据请求API + +const { + list, // 响应式的数据列表 + loading, // 是否加载中 + finished, // 是否已全部加载 + error, // 是否加载失败 + onScrollToLower, // 滚动到底部时触发的事件 + onRefresherRefresh, // 下拉刷新时触发的事件 +} = useScroll(getList) // 将获取数据的API函数传入 +``` + +## `scroll-view` 组件 + +`scroll-view` 是 uni-app 提供的可滚动视图区域组件,它提供了一系列属性来支持下拉刷新和上拉加载。 + +### 关键属性 + +- `scroll-y`: 允许纵向滚动。 +- `refresher-enabled`: 启用下拉刷新。 +- `refresher-triggered`: 控制下拉刷新动画的显示与隐藏,通过 `loading` 状态绑定。 +- `@scrolltolower`: 滚动到底部时触发的事件,绑定 `onScrollToLower` 方法。 +- `@refresherrefresh`: 触发下拉刷新时触发的事件,绑定 `onRefresherRefresh` 方法。 + +## 示例代码 + +以下是 `src/pages-sub/demo/scroll.vue` 中的核心代码,展示了如何将 `useScroll` 和 `scroll-view` 结合使用。 + +```vue + + + + + +``` + +## 实现步骤总结 + +1. **创建API**: 确保你有一个返回分页数据的API请求函数(例如 `getList`),它应该接受页码和页面大小作为参数。 +2. **调用 `useScroll`**: 在你的页面脚本中,导入并调用 `useScroll` Hook,将你的API函数作为参数传入。 +3. **模板绑定**: + - 使用 `scroll-view` 组件作为滚动容器。 + - 将其 `refresher-triggered` 属性绑定到 `useScroll` 返回的 `loading` 状态。 + - 将其 `@scrolltolower` 事件绑定到 `onScrollToLower` 方法。 + - 将其 `@refresherrefresh` 事件绑定到 `onRefresherRefresh` 方法。 +4. **渲染列表**: 使用 `v-for` 指令渲染 `useScroll` 返回的 `list` 数组。 +5. **添加加载提示**: 根据 `loading`, `finished`, `error` 状态,在列表底部显示不同的提示信息,提升用户体验。 + +通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。 \ No newline at end of file diff --git a/src/hooks/useScroll.ts b/src/hooks/useScroll.ts new file mode 100644 index 0000000..1563223 --- /dev/null +++ b/src/hooks/useScroll.ts @@ -0,0 +1,74 @@ +import type { Ref } from 'vue' +import { onMounted, ref } from 'vue' + +interface UseScrollOptions { + fetchData: (page: number, pageSize: number) => Promise + pageSize?: number +} + +interface UseScrollReturn { + list: Ref + loading: Ref + finished: Ref + error: Ref + refresh: () => Promise + loadMore: () => Promise +} + +export function useScroll({ + fetchData, + pageSize = 10, +}: UseScrollOptions): UseScrollReturn { + const list = ref([]) as Ref + const loading = ref(false) + const finished = ref(false) + const error = ref(null) + const page = ref(1) + + const loadData = async () => { + if (loading.value || finished.value) + return + + loading.value = true + error.value = null + + try { + const data = await fetchData(page.value, pageSize) + if (data.length < pageSize) { + finished.value = true + } + list.value.push(...data) + page.value++ + } + catch (err) { + error.value = err + } + finally { + loading.value = false + } + } + + const refresh = async () => { + page.value = 1 + finished.value = false + list.value = [] + await loadData() + } + + const loadMore = async () => { + await loadData() + } + + onMounted(() => { + refresh() + }) + + return { + list, + loading, + finished, + error, + refresh, + loadMore, + } +} diff --git a/src/hooks/useUpload.ts b/src/hooks/useUpload.ts new file mode 100644 index 0000000..7c9700a --- /dev/null +++ b/src/hooks/useUpload.ts @@ -0,0 +1,171 @@ +import { ref } from 'vue' +import { getEnvBaseUrl } from '@/utils/index' + +const VITE_UPLOAD_BASEURL = `${getEnvBaseUrl()}/upload` + +type TfileType = 'image' | 'file' +type TImage = 'png' | 'jpg' | 'jpeg' | 'webp' | '*' +type TFile = 'doc' | 'docx' | 'ppt' | 'zip' | 'xls' | 'xlsx' | 'txt' | TImage + +interface TOptions { + formData?: Record + maxSize?: number + accept?: T extends 'image' ? TImage[] : TFile[] + fileType?: T + success?: (params: any) => void + error?: (err: any) => void +} + +export default function useUpload(options: TOptions = {} as TOptions) { + const { + formData = {}, + maxSize = 5 * 1024 * 1024, + accept = ['*'], + fileType = 'image', + success, + error: onError, + } = options + + const loading = ref(false) + const error = ref(null) + const data = ref(null) + + const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string, size: number }) => { + if (size > maxSize) { + uni.showToast({ + title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`, + icon: 'none', + }) + return + } + + // const fileExtension = file?.tempFiles?.name?.split('.').pop()?.toLowerCase() + // const isTypeValid = accept.some((type) => type === '*' || type.toLowerCase() === fileExtension) + + // if (!isTypeValid) { + // uni.showToast({ + // title: `仅支持 ${accept.join(', ')} 格式的文件`, + // icon: 'none', + // }) + // return + // } + + loading.value = true + uploadFile({ + tempFilePath, + formData, + onSuccess: (res) => { + // 修改这里的解析逻辑,适应不同平台的返回格式 + let parsedData = res + try { + // 尝试解析为JSON + const jsonData = JSON.parse(res) + // 检查是否包含data字段 + parsedData = jsonData.data || jsonData + } + catch (e) { + // 如果解析失败,使用原始数据 + console.log('Response is not JSON, using raw data:', res) + } + data.value = parsedData + // console.log('上传成功', res) + success?.(parsedData) + }, + onError: (err) => { + error.value = err + onError?.(err) + }, + onComplete: () => { + loading.value = false + }, + }) + } + + const run = () => { + // 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。 + // 微信小程序在2023年10月17日之后,使用本API需要配置隐私协议 + const chooseFileOptions = { + count: 1, + success: (res: any) => { + console.log('File selected successfully:', res) + // 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]} + // h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]} + // h5的File有以下字段:{name: "girl.jpeg", size: 48976, type: "image/jpeg"} + // App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]} + // App的File有以下字段:{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976} + let tempFilePath = '' + let size = 0 + // #ifdef MP-WEIXIN + tempFilePath = res.tempFiles[0].tempFilePath + size = res.tempFiles[0].size + // #endif + // #ifndef MP-WEIXIN + tempFilePath = res.tempFilePaths[0] + size = res.tempFiles[0].size + // #endif + handleFileChoose({ tempFilePath, size }) + }, + fail: (err: any) => { + console.error('File selection failed:', err) + error.value = err + onError?.(err) + }, + } + + if (fileType === 'image') { + // #ifdef MP-WEIXIN + uni.chooseMedia({ + ...chooseFileOptions, + mediaType: ['image'], + }) + // #endif + + // #ifndef MP-WEIXIN + uni.chooseImage(chooseFileOptions) + // #endif + } + else { + uni.chooseFile({ + ...chooseFileOptions, + type: 'all', + }) + } + } + + return { loading, error, data, run } +} + +async function uploadFile({ + tempFilePath, + formData, + onSuccess, + onError, + onComplete, +}: { + tempFilePath: string + formData: Record + onSuccess: (data: any) => void + onError: (err: any) => void + onComplete: () => void +}) { + uni.uploadFile({ + url: VITE_UPLOAD_BASEURL, + filePath: tempFilePath, + name: 'file', + formData, + success: (uploadFileRes) => { + try { + const data = uploadFileRes.data + onSuccess(data) + } + catch (err) { + onError(err) + } + }, + fail: (err) => { + console.error('Upload failed:', err) + onError(err) + }, + complete: onComplete, + }) +} diff --git a/src/http/README.md b/src/http/README.md new file mode 100644 index 0000000..5bb5a4f --- /dev/null +++ b/src/http/README.md @@ -0,0 +1,13 @@ +# 请求库 + +目前unibest支持3种请求库: +- 菲鸽简单封装的 `简单版本http`,路径(src/http/http.ts),对应的示例在 src/api/foo.ts +- `alova 的 http`,路径(src/http/alova.ts),对应的示例在 src/api/foo-alova.ts +- `vue-query`, 路径(src/http/vue-query.ts), 目前主要用在自动生成接口,详情看(https://unibest.tech/base/17-generate),示例在 src/service/app 文件夹 + +## 如何选择 +如果您以前用过 alova 或者 vue-query,可以优先使用您熟悉的。 +如果您的项目简单,简单版本的http 就够了,也不会增加包体积。(发版的时候可以去掉alova和vue-query,如果没有超过包体积,留着也无所谓 ^_^) + +## roadmap +菲鸽最近在优化脚手架,后续可以选择是否使用第三方的请求库,以及选择什么请求库。还在开发中,大概月底出来(8月31号)。 \ No newline at end of file diff --git a/src/http/alova.ts b/src/http/alova.ts new file mode 100644 index 0000000..b7b9ff6 --- /dev/null +++ b/src/http/alova.ts @@ -0,0 +1,119 @@ +import type { uniappRequestAdapter } from '@alova/adapter-uniapp' +import type { IResponse } from './types' +import AdapterUniapp from '@alova/adapter-uniapp' +import { createAlova } from 'alova' +import { createServerTokenAuthentication } from 'alova/client' +import VueHook from 'alova/vue' +import { toLoginPage } from '@/utils/toLoginPage' +import { ContentTypeEnum, ResultEnum, ShowMessage } from './tools/enum' + +// 配置动态Tag +export const API_DOMAINS = { + DEFAULT: import.meta.env.VITE_SERVER_BASEURL, + SECONDARY: import.meta.env.VITE_SERVER_BASEURL_SECONDARY, +} + +/** + * 创建请求实例 + */ +const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication< + typeof VueHook, + typeof uniappRequestAdapter +>({ + // 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛 + refreshTokenOnError: { + isExpired: (error) => { + return error.response?.status === ResultEnum.Unauthorized + }, + handler: async () => { + try { + // await authLogin(); + } + catch (error) { + // 切换到登录页 + toLoginPage({ mode: 'reLaunch' }) + throw error + } + }, + }, +}) + +/** + * alova 请求实例 + */ +const alovaInstance = createAlova({ + baseURL: API_DOMAINS.DEFAULT, + ...AdapterUniapp(), + timeout: 5000, + statesHook: VueHook, + + beforeRequest: onAuthRequired((method) => { + // 设置默认 Content-Type + method.config.headers = { + ContentType: ContentTypeEnum.JSON, + Accept: 'application/json, text/plain, */*', + ...method.config.headers, + } + + const { config } = method + const ignoreAuth = !config.meta?.ignoreAuth + console.log('ignoreAuth===>', ignoreAuth) + // 处理认证信息 自行处理认证问题 + if (ignoreAuth) { + const token = 'getToken()' + if (!token) { + throw new Error('[请求错误]:未登录') + } + // method.config.headers.token = token; + } + + // 处理动态域名 + if (config.meta?.domain) { + method.baseURL = config.meta.domain + console.log('当前域名', method.baseURL) + } + }), + + responded: onResponseRefreshToken((response, method) => { + const { config } = method + const { requestType } = config + const { + statusCode, + data: rawData, + errMsg, + } = response as UniNamespace.RequestSuccessCallbackResult + + // 处理特殊请求类型(上传/下载) + if (requestType === 'upload' || requestType === 'download') { + return response + } + + // 处理 HTTP 状态码错误 + if (statusCode !== 200) { + const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]` + console.error('errorMessage===>', errorMessage) + uni.showToast({ + title: errorMessage, + icon: 'error', + }) + throw new Error(`${errorMessage}:${errMsg}`) + } + + // 处理业务逻辑错误 + const { code, message, data } = rawData as IResponse + // 0和200当做成功都很普遍,这里直接兼容两者,见 ResultEnum + if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) { + if (config.meta?.toast !== false) { + uni.showToast({ + title: message, + icon: 'none', + }) + } + throw new Error(`请求错误[${code}]:${message}`) + } + // 处理成功响应,返回业务数据 + return data + }), +}) + +export const http = alovaInstance diff --git a/src/http/http.ts b/src/http/http.ts new file mode 100644 index 0000000..88f8508 --- /dev/null +++ b/src/http/http.ts @@ -0,0 +1,199 @@ +import type { IDoubleTokenRes } from '@/api/types/login' +import type { CustomRequestOptions, IResponse } from '@/http/types' +import { nextTick } from 'vue' +import { useTokenStore } from '@/store/token' +import { isDoubleTokenMode } from '@/utils' +import { toLoginPage } from '@/utils/toLoginPage' +import { ResultEnum } from './tools/enum' + +// 刷新 token 状态管理 +let refreshing = false // 防止重复刷新 token 标识 +let taskQueue: (() => void)[] = [] // 刷新 token 请求队列 + +export function http(options: CustomRequestOptions) { + // 1. 返回 Promise 对象 + return new Promise((resolve, reject) => { + uni.request({ + ...options, + dataType: 'json', + // #ifndef MP-WEIXIN + responseType: 'json', + // #endif + // 响应成功 + success: async (res) => { + const responseData = res.data as IResponse + const { code } = responseData + + // 检查是否是401错误(包括HTTP状态码401或业务码401) + const isTokenExpired = res.statusCode === 401 || code === 401 + + if (isTokenExpired) { + const tokenStore = useTokenStore() + if (!isDoubleTokenMode) { + // 未启用双token策略,清理用户信息,跳转到登录页 + tokenStore.logout() + toLoginPage() + return reject(res) + } + + /* -------- 无感刷新 token ----------- */ + const { refreshToken } = tokenStore.tokenInfo as IDoubleTokenRes || {} + // token 失效的,且有刷新 token 的,才放到请求队列里 + if (refreshToken) { + taskQueue.push(() => { + resolve(http(options)) + }) + } + + // 如果有 refreshToken 且未在刷新中,发起刷新 token 请求 + if (refreshToken && !refreshing) { + refreshing = true + try { + // 发起刷新 token 请求(使用 store 的 refreshToken 方法) + await tokenStore.refreshToken() + // 刷新 token 成功 + refreshing = false + nextTick(() => { + // 关闭其他弹窗 + uni.hideToast() + uni.showToast({ + title: 'token 刷新成功', + icon: 'none', + }) + }) + // 将任务队列的所有任务重新请求 + taskQueue.forEach(task => task()) + } + catch (refreshErr) { + console.error('刷新 token 失败:', refreshErr) + refreshing = false + // 刷新 token 失败,跳转到登录页 + nextTick(() => { + // 关闭其他弹窗 + uni.hideToast() + uni.showToast({ + title: '登录已过期,请重新登录', + icon: 'none', + }) + }) + // 清除用户信息 + await tokenStore.logout() + // 跳转到登录页 + setTimeout(() => { + toLoginPage() + }, 2000) + } + finally { + // 不管刷新 token 成功与否,都清空任务队列 + taskQueue = [] + } + } + + return reject(res) + } + + // 处理其他成功状态(HTTP状态码200-299) + if (res.statusCode >= 200 && res.statusCode < 300) { + // 处理业务逻辑错误 + if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) { + uni.showToast({ + icon: 'none', + title: responseData.msg || responseData.message || '请求错误', + }) + } + return resolve(responseData.data) + } + + // 处理其他错误 + !options.hideErrorToast + && uni.showToast({ + icon: 'none', + title: (res.data as any).msg || '请求错误', + }) + reject(res) + }, + // 响应失败 + fail(err) { + uni.showToast({ + icon: 'none', + title: '网络错误,换个网络试试', + }) + reject(err) + }, + }) + }) +} + +/** + * GET 请求 + * @param url 后台地址 + * @param query 请求query参数 + * @param header 请求头,默认为json格式 + * @returns + */ +export function httpGet(url: string, query?: Record, header?: Record, options?: Partial) { + return http({ + url, + query, + method: 'GET', + header, + ...options, + }) +} + +/** + * POST 请求 + * @param url 后台地址 + * @param data 请求body参数 + * @param query 请求query参数,post请求也支持query,很多微信接口都需要 + * @param header 请求头,默认为json格式 + * @returns + */ +export function httpPost(url: string, data?: Record, query?: Record, header?: Record, options?: Partial) { + return http({ + url, + query, + data, + method: 'POST', + header, + ...options, + }) +} +/** + * PUT 请求 + */ +export function httpPut(url: string, data?: Record, query?: Record, header?: Record, options?: Partial) { + return http({ + url, + data, + query, + method: 'PUT', + header, + ...options, + }) +} + +/** + * DELETE 请求(无请求体,仅 query) + */ +export function httpDelete(url: string, query?: Record, header?: Record, options?: Partial) { + return http({ + url, + query, + method: 'DELETE', + header, + ...options, + }) +} + +// 支持与 axios 类似的API调用 +http.get = httpGet +http.post = httpPost +http.put = httpPut +http.delete = httpDelete + +// 支持与 alovaJS 类似的API调用 +http.Get = httpGet +http.Post = httpPost +http.Put = httpPut +http.Delete = httpDelete diff --git a/src/http/interceptor.ts b/src/http/interceptor.ts new file mode 100644 index 0000000..9ea1746 --- /dev/null +++ b/src/http/interceptor.ts @@ -0,0 +1,69 @@ +import type { CustomRequestOptions } from '@/http/types' +import { useTokenStore } from '@/store' +import { getEnvBaseUrl } from '@/utils' +import { stringifyQuery } from './tools/queryString' + +// 请求基准地址 +const baseUrl = getEnvBaseUrl() + +// 拦截器配置 +const httpInterceptor = { + // 拦截前触发 + invoke(options: CustomRequestOptions) { + // 如果您使用了alova,则请把下面的代码放开注释 + // alova 执行流程:alova beforeRequest --> 本拦截器 --> alova responded + // return options + + // 非 alova 请求,正常执行 + // 接口请求支持通过 query 参数配置 queryString + if (options.query) { + const queryStr = stringifyQuery(options.query) + if (options.url.includes('?')) { + options.url += `&${queryStr}` + } + else { + options.url += `?${queryStr}` + } + } + // 非 http 开头需拼接地址 + if (!options.url.startsWith('http')) { + // #ifdef H5 + if (JSON.parse(import.meta.env.VITE_APP_PROXY_ENABLE)) { + // 自动拼接代理前缀 + options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url + } + else { + options.url = baseUrl + options.url + } + // #endif + // 非H5正常拼接 + // #ifndef H5 + options.url = baseUrl + options.url + // #endif + // TIPS: 如果需要对接多个后端服务,也可以在这里处理,拼接成所需要的地址 + } + // 1. 请求超时 + options.timeout = 60000 // 60s + // 2. (可选)添加小程序端请求头标识 + options.header = { + ...options.header, + } + // 3. 添加 token 请求头标识 + const tokenStore = useTokenStore() + const token = tokenStore.validToken + + if (token) { + options.header.Authorization = `Bearer ${token}` + } + return options + }, +} + +export const requestInterceptor = { + install() { + // 拦截 request 请求 + uni.addInterceptor('request', httpInterceptor) + // 拦截 uploadFile 文件上传 + uni.addInterceptor('uploadFile', httpInterceptor) + }, +} diff --git a/src/http/tools/enum.ts b/src/http/tools/enum.ts new file mode 100644 index 0000000..806e616 --- /dev/null +++ b/src/http/tools/enum.ts @@ -0,0 +1,68 @@ +export enum ResultEnum { + // 0和200当做成功都很普遍,这里直接兼容两者(PS:0和200通常都不会当做错误码,但是有的接口会返回0,有的接口会返回200) + Success0 = 0, // 成功 + Success200 = 200, // 成功 + Error = 400, // 错误 + Unauthorized = 401, // 未授权 + Forbidden = 403, // 禁止访问(原为forbidden) + NotFound = 404, // 未找到(原为notFound) + MethodNotAllowed = 405, // 方法不允许(原为methodNotAllowed) + RequestTimeout = 408, // 请求超时(原为requestTimeout) + InternalServerError = 500, // 服务器错误(原为internalServerError) + NotImplemented = 501, // 未实现(原为notImplemented) + BadGateway = 502, // 网关错误(原为badGateway) + ServiceUnavailable = 503, // 服务不可用(原为serviceUnavailable) + GatewayTimeout = 504, // 网关超时(原为gatewayTimeout) + HttpVersionNotSupported = 505, // HTTP版本不支持(原为httpVersionNotSupported) +} +export enum ContentTypeEnum { + JSON = 'application/json;charset=UTF-8', + FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8', + FORM_DATA = 'multipart/form-data;charset=UTF-8', +} +/** + * 根据状态码,生成对应的错误信息 + * @param {number|string} status 状态码 + * @returns {string} 错误信息 + */ +export function ShowMessage(status: number | string): string { + let message: string + switch (status) { + case 400: + message = '请求错误(400)' + break + case 401: + message = '未授权,请重新登录(401)' + break + case 403: + message = '拒绝访问(403)' + break + case 404: + message = '请求出错(404)' + break + case 408: + message = '请求超时(408)' + break + case 500: + message = '服务器错误(500)' + break + case 501: + message = '服务未实现(501)' + break + case 502: + message = '网络错误(502)' + break + case 503: + message = '服务不可用(503)' + break + case 504: + message = '网络超时(504)' + break + case 505: + message = 'HTTP版本不受支持(505)' + break + default: + message = `连接出错(${status})!` + } + return `${message},请检查网络或联系管理员!` +} diff --git a/src/http/tools/queryString.ts b/src/http/tools/queryString.ts new file mode 100644 index 0000000..edf973e --- /dev/null +++ b/src/http/tools/queryString.ts @@ -0,0 +1,29 @@ +/** + * 将对象序列化为URL查询字符串,用于替代第三方的 qs 库,节省宝贵的体积 + * 支持基本类型值和数组,不支持嵌套对象 + * @param obj 要序列化的对象 + * @returns 序列化后的查询字符串 + */ +export function stringifyQuery(obj: Record): string { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) + return '' + + return Object.entries(obj) + .filter(([_, value]) => value !== undefined && value !== null) + .map(([key, value]) => { + // 对键进行编码 + const encodedKey = encodeURIComponent(key) + + // 处理数组类型 + if (Array.isArray(value)) { + return value + .filter(item => item !== undefined && item !== null) + .map(item => `${encodedKey}=${encodeURIComponent(item)}`) + .join('&') + } + + // 处理基本类型 + return `${encodedKey}=${encodeURIComponent(value)}` + }) + .join('&') +} diff --git a/src/http/types.ts b/src/http/types.ts new file mode 100644 index 0000000..8187822 --- /dev/null +++ b/src/http/types.ts @@ -0,0 +1,44 @@ +/** + * 在 uniapp 的 RequestOptions 和 IUniUploadFileOptions 基础上,添加自定义参数 + */ +export type CustomRequestOptions = UniApp.RequestOptions & { + query?: Record + /** 出错时是否隐藏错误提示 */ + hideErrorToast?: boolean +} & IUniUploadFileOptions // 添加uni.uploadFile参数类型 + +/** 主要提供给 openapi-ts-request 生成的代码使用 */ +export type CustomRequestOptions_ = Omit + +export interface HttpRequestResult { + promise: Promise + requestTask: UniApp.RequestTask +} + +// 通用响应格式(兼容 msg + message 字段) +export type IResponse = { + code: number + data: T + message: string + [key: string]: any // 允许额外属性 +} | { + code: number + data: T + msg: string + [key: string]: any // 允许额外属性 +} + +// 分页请求参数 +export interface PageParams { + page: number + pageSize: number + [key: string]: any +} + +// 分页响应数据 +export interface PageResult { + list: T[] + total: number + page: number + pageSize: number +} diff --git a/src/http/vue-query.ts b/src/http/vue-query.ts new file mode 100644 index 0000000..69ca80d --- /dev/null +++ b/src/http/vue-query.ts @@ -0,0 +1,30 @@ +import type { CustomRequestOptions } from '@/http/types' +import { http } from './http' + +/* + * openapi-ts-request 工具的 request 跨客户端适配方法 + */ +export default function request( + url: string, + options: Omit & { + params?: Record + headers?: Record + }, +) { + const requestOptions = { + url, + ...options, + } + + if (options.params) { + requestOptions.query = requestOptions.params + delete requestOptions.params + } + + if (options.headers) { + requestOptions.header = options.headers + delete requestOptions.headers + } + + return http(requestOptions) +} diff --git a/src/layouts/default.vue b/src/layouts/default.vue new file mode 100644 index 0000000..ba4672f --- /dev/null +++ b/src/layouts/default.vue @@ -0,0 +1,3 @@ + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..8ec1058 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,19 @@ +import { createSSRApp } from 'vue' +import App from './App.vue' +import { requestInterceptor } from './http/interceptor' +import { routeInterceptor } from './router/interceptor' + +import store from './store' +import '@/style/index.scss' +import 'virtual:uno.css' + +export function createApp() { + const app = createSSRApp(App) + app.use(store) + app.use(routeInterceptor) + app.use(requestInterceptor) + + return { + app, + } +} diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue new file mode 100644 index 0000000..27b81b8 --- /dev/null +++ b/src/pages/index/index.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/pages/me/me.vue b/src/pages/me/me.vue new file mode 100644 index 0000000..5df9e2e --- /dev/null +++ b/src/pages/me/me.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/router/README.md b/src/router/README.md new file mode 100644 index 0000000..60e3084 --- /dev/null +++ b/src/router/README.md @@ -0,0 +1,55 @@ +# 登录 说明 + +## 登录 2种策略 +- 默认无需登录策略: DEFAULT_NO_NEED_LOGIN +- 默认需要登录策略: DEFAULT_NEED_LOGIN + +### 默认无需登录策略: DEFAULT_NO_NEED_LOGIN +进入任何页面都不需要登录,只有进入到黑名单中的页面/或者页面中某些动作需要登录,才需要登录。 + +比如大部分2C的应用,美团、今日头条、抖音等,都可以直接浏览,只有点赞、评论、分享等操作或者去特殊页面(比如个人中心),才需要登录。 + +### 默认需要登录策略: DEFAULT_NEED_LOGIN + +进入任何页面都需要登录,只有进入到白名单中的页面,才不需要登录。默认进入应用需要先去登录页。 + +比如大部分2B和后台管理类的应用,比如企业微信、钉钉、飞书、内部报表系统、CMS系统等,都需要登录,只有登录后,才能使用。 + +### EXCLUDE_LOGIN_PATH_LIST +`EXCLUDE_LOGIN_PATH_LIST` 表示排除的路由列表。 + +在 `默认无需登录策略: DEFAULT_NO_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才需要登录,相当于黑名单。 + +在 `默认需要登录策略: DEFAULT_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才不需要登录,相当于白名单。 + +### excludeLoginPath +definePage 中可以通过 `excludeLoginPath` 来配置路由是否需要登录。(类似过去的 needLogin 的功能) + +```ts +definePage({ + style: { + navigationBarTitleText: '关于', + }, + // 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹 + excludeLoginPath: true, + // 角色授权(可选):如果需要根据角色授权,就配置这个 + roleAuth: { + field: 'role', + value: 'admin', + redirect: '/pages/auth/403', + }, +}) +``` + +## 登录注册页路由 + +登录页 `login.vue` 对应路由是 `/pages/login/login`. +注册页 `register.vue` 对应路由是 `/pages/login/register`. + +## 登录注册页适用性 + +登录注册页主要适用于 `h5` 和 `App`,默认不适用于 `小程序`,因为 `小程序` 通常会使用平台提供的快捷登录。 + +特殊情况例外,如业务需要跨平台复用登录注册页时,也可以用在 `小程序` 上,所以主要还是看业务需求。 + +通过一个参数 `LOGIN_PAGE_ENABLE_IN_MP` 来控制是否在 `小程序` 中使用 `H5登录页` 的登录逻辑。 diff --git a/src/router/interceptor.ts b/src/router/interceptor.ts new file mode 100644 index 0000000..e5b6046 --- /dev/null +++ b/src/router/interceptor.ts @@ -0,0 +1,53 @@ +/** + * by 菲鸽 on 2025-08-19 + * 路由拦截,通常也是登录拦截 + * 黑、白名单的配置,请看 config.ts 文件, EXCLUDE_LOGIN_PATH_LIST + */ +import { tabbarStore } from '@/tabbar/store' +import { getAllPages, getLastPage, parseUrlToObj } from '@/utils/index' + +export const FG_LOG_ENABLE = false + +export const navigateToInterceptor = { + // 注意,这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同 + // 增加对相对路径的处理,BY 网友 @ideal + invoke({ url, query }: { url: string, query?: Record }) { + if (url === undefined) { + return + } + let { path, query: _query } = parseUrlToObj(url) + + FG_LOG_ENABLE && console.log('\n\n路由拦截器:-------------------------------------') + FG_LOG_ENABLE && console.log('路由拦截器 1: url->', url, ', query ->', query) + const myQuery = { ..._query, ...query } + // /pages/route-interceptor/index?name=feige&age=30 + FG_LOG_ENABLE && console.log('路由拦截器 2: path->', path, ', _query ->', _query) + FG_LOG_ENABLE && console.log('路由拦截器 3: myQuery ->', myQuery) + + // 处理相对路径 + if (!path.startsWith('/')) { + const currentPath = getLastPage()?.route || '' + const normalizedCurrentPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}` + const baseDir = normalizedCurrentPath.substring(0, normalizedCurrentPath.lastIndexOf('/')) + path = `${baseDir}/${path}` + } + + // 处理路由不存在的情况 + if (path !== '/' && !getAllPages().some(page => page.path !== path)) { + console.warn('路由不存在:', path) + return false // 明确表示阻止原路由继续执行 + } + + // 处理直接进入路由非首页时,tabbarIndex 不正确的问题 + tabbarStore.setAutoCurIdx(path) + }, +} + +export const routeInterceptor = { + install() { + uni.addInterceptor('navigateTo', navigateToInterceptor) + uni.addInterceptor('reLaunch', navigateToInterceptor) + uni.addInterceptor('redirectTo', navigateToInterceptor) + uni.addInterceptor('switchTab', navigateToInterceptor) + }, +} diff --git a/src/service/index.ts b/src/service/index.ts new file mode 100644 index 0000000..3bfbb5b --- /dev/null +++ b/src/service/index.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +// @ts-ignore +export * from './types'; + +export * from './listAll'; +export * from './info'; diff --git a/src/service/info.ts b/src/service/info.ts new file mode 100644 index 0000000..fe09da5 --- /dev/null +++ b/src/service/info.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// @ts-ignore +import request from '@/http/vue-query'; +import { CustomRequestOptions_ } from '@/http/types'; + +import * as API from './types'; + +/** 用户信息 GET /user/info */ +export function infoUsingGet({ options }: { options?: CustomRequestOptions_ }) { + return request('/user/info', { + method: 'GET', + ...(options || {}), + }); +} diff --git a/src/service/listAll.ts b/src/service/listAll.ts new file mode 100644 index 0000000..bc1c683 --- /dev/null +++ b/src/service/listAll.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +// @ts-ignore +import request from '@/http/vue-query'; +import { CustomRequestOptions_ } from '@/http/types'; + +import * as API from './types'; + +/** 用户列表 GET /user/listAll */ +export function listAllUsingGet({ + options, +}: { + options?: CustomRequestOptions_; +}) { + return request('/user/listAll', { + method: 'GET', + ...(options || {}), + }); +} diff --git a/src/service/types.ts b/src/service/types.ts new file mode 100644 index 0000000..4e46b61 --- /dev/null +++ b/src/service/types.ts @@ -0,0 +1,29 @@ +/* eslint-disable */ +// @ts-ignore + +export type InfoUsingGetResponse = { + code: number; + msg: string; + data: UserItem; +}; + +export type InfoUsingGetResponses = { + 200: InfoUsingGetResponse; +}; + +export type ListAllUsingGetResponse = { + code: number; + msg: string; + data: UserItem[]; +}; + +export type ListAllUsingGetResponses = { + 200: ListAllUsingGetResponse; +}; + +export type UserItem = { + userId: number; + username: string; + nickname: string; + avatar: string; +}; diff --git a/src/static/app/icons/1024x1024.png b/src/static/app/icons/1024x1024.png new file mode 100644 index 0000000..08dbd5f Binary files /dev/null and b/src/static/app/icons/1024x1024.png differ diff --git a/src/static/app/icons/120x120.png b/src/static/app/icons/120x120.png new file mode 100644 index 0000000..718ca79 Binary files /dev/null and b/src/static/app/icons/120x120.png differ diff --git a/src/static/app/icons/144x144.png b/src/static/app/icons/144x144.png new file mode 100644 index 0000000..f78346b Binary files /dev/null and b/src/static/app/icons/144x144.png differ diff --git a/src/static/app/icons/152x152.png b/src/static/app/icons/152x152.png new file mode 100644 index 0000000..f979721 Binary files /dev/null and b/src/static/app/icons/152x152.png differ diff --git a/src/static/app/icons/167x167.png b/src/static/app/icons/167x167.png new file mode 100644 index 0000000..d0aef20 Binary files /dev/null and b/src/static/app/icons/167x167.png differ diff --git a/src/static/app/icons/180x180.png b/src/static/app/icons/180x180.png new file mode 100644 index 0000000..24bd062 Binary files /dev/null and b/src/static/app/icons/180x180.png differ diff --git a/src/static/app/icons/192x192.png b/src/static/app/icons/192x192.png new file mode 100644 index 0000000..a8ea1a2 Binary files /dev/null and b/src/static/app/icons/192x192.png differ diff --git a/src/static/app/icons/20x20.png b/src/static/app/icons/20x20.png new file mode 100644 index 0000000..0abed04 Binary files /dev/null and b/src/static/app/icons/20x20.png differ diff --git a/src/static/app/icons/29x29.png b/src/static/app/icons/29x29.png new file mode 100644 index 0000000..a20d373 Binary files /dev/null and b/src/static/app/icons/29x29.png differ diff --git a/src/static/app/icons/40x40.png b/src/static/app/icons/40x40.png new file mode 100644 index 0000000..2b41be6 Binary files /dev/null and b/src/static/app/icons/40x40.png differ diff --git a/src/static/app/icons/58x58.png b/src/static/app/icons/58x58.png new file mode 100644 index 0000000..8e18b42 Binary files /dev/null and b/src/static/app/icons/58x58.png differ diff --git a/src/static/app/icons/60x60.png b/src/static/app/icons/60x60.png new file mode 100644 index 0000000..167826b Binary files /dev/null and b/src/static/app/icons/60x60.png differ diff --git a/src/static/app/icons/72x72.png b/src/static/app/icons/72x72.png new file mode 100644 index 0000000..ddb91e3 Binary files /dev/null and b/src/static/app/icons/72x72.png differ diff --git a/src/static/app/icons/76x76.png b/src/static/app/icons/76x76.png new file mode 100644 index 0000000..0d9d28e Binary files /dev/null and b/src/static/app/icons/76x76.png differ diff --git a/src/static/app/icons/80x80.png b/src/static/app/icons/80x80.png new file mode 100644 index 0000000..1877042 Binary files /dev/null and b/src/static/app/icons/80x80.png differ diff --git a/src/static/app/icons/87x87.png b/src/static/app/icons/87x87.png new file mode 100644 index 0000000..251fb24 Binary files /dev/null and b/src/static/app/icons/87x87.png differ diff --git a/src/static/app/icons/96x96.png b/src/static/app/icons/96x96.png new file mode 100644 index 0000000..eccf396 Binary files /dev/null and b/src/static/app/icons/96x96.png differ diff --git a/src/static/images/avatar.jpg b/src/static/images/avatar.jpg new file mode 100644 index 0000000..2010a70 Binary files /dev/null and b/src/static/images/avatar.jpg differ diff --git a/src/static/images/default-avatar.png b/src/static/images/default-avatar.png new file mode 100644 index 0000000..4eb5879 Binary files /dev/null and b/src/static/images/default-avatar.png differ diff --git a/src/static/logo.svg b/src/static/logo.svg new file mode 100644 index 0000000..eaee669 --- /dev/null +++ b/src/static/logo.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/static/my-icons/copyright.svg b/src/static/my-icons/copyright.svg new file mode 100644 index 0000000..8e69a8c --- /dev/null +++ b/src/static/my-icons/copyright.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/static/tabbar/example.png b/src/static/tabbar/example.png new file mode 100644 index 0000000..fd1e942 Binary files /dev/null and b/src/static/tabbar/example.png differ diff --git a/src/static/tabbar/exampleHL.png b/src/static/tabbar/exampleHL.png new file mode 100644 index 0000000..7501011 Binary files /dev/null and b/src/static/tabbar/exampleHL.png differ diff --git a/src/static/tabbar/home.png b/src/static/tabbar/home.png new file mode 100644 index 0000000..8f82e21 Binary files /dev/null and b/src/static/tabbar/home.png differ diff --git a/src/static/tabbar/homeHL.png b/src/static/tabbar/homeHL.png new file mode 100644 index 0000000..26d3761 Binary files /dev/null and b/src/static/tabbar/homeHL.png differ diff --git a/src/static/tabbar/personal.png b/src/static/tabbar/personal.png new file mode 100644 index 0000000..0a569a2 Binary files /dev/null and b/src/static/tabbar/personal.png differ diff --git a/src/static/tabbar/personalHL.png b/src/static/tabbar/personalHL.png new file mode 100644 index 0000000..8c3e66e Binary files /dev/null and b/src/static/tabbar/personalHL.png differ diff --git a/src/static/tabbar/scan.png b/src/static/tabbar/scan.png new file mode 100644 index 0000000..f0f60c2 Binary files /dev/null and b/src/static/tabbar/scan.png differ diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..fe2ead1 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,20 @@ +import { createPinia, setActivePinia } from 'pinia' +import { createPersistedState } from 'pinia-plugin-persistedstate' // 数据持久化 + +const store = createPinia() +store.use( + createPersistedState({ + storage: { + getItem: uni.getStorageSync, + setItem: uni.setStorageSync, + }, + }), +) +// 立即激活 Pinia 实例, 这样即使在 app.use(store)之前调用 store 也能正常工作 (解决APP端白屏问题) +setActivePinia(store) + +export default store + +// 模块统一导出 +export * from './token' +export * from './user' diff --git a/src/store/token.ts b/src/store/token.ts new file mode 100644 index 0000000..363dc2b --- /dev/null +++ b/src/store/token.ts @@ -0,0 +1,290 @@ +import type { + ILoginForm, +} from '@/api/login' +import type { IAuthLoginRes } from '@/api/types/login' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' // 修复:导入 computed +import { + login as _login, + logout as _logout, + refreshToken as _refreshToken, + wxLogin as _wxLogin, + getWxCode, +} from '@/api/login' +import { isDoubleTokenRes, isSingleTokenRes } from '@/api/types/login' +import { isDoubleTokenMode } from '@/utils' +import { useUserStore } from './user' + +// 初始化状态 +const tokenInfoState = isDoubleTokenMode + ? { + accessToken: '', + accessExpiresIn: 0, + refreshToken: '', + refreshExpiresIn: 0, + } + : { + token: '', + expiresIn: 0, + } + +export const useTokenStore = defineStore( + 'token', + () => { + // 定义用户信息 + const tokenInfo = ref({ ...tokenInfoState }) + // 设置用户信息 + const setTokenInfo = (val: IAuthLoginRes) => { + tokenInfo.value = val + + // 计算并存储过期时间 + const now = Date.now() + if (isSingleTokenRes(val)) { + // 单token模式 + const expireTime = now + val.expiresIn * 1000 + uni.setStorageSync('accessTokenExpireTime', expireTime) + } + else if (isDoubleTokenRes(val)) { + // 双token模式 + const accessExpireTime = now + val.accessExpiresIn * 1000 + const refreshExpireTime = now + val.refreshExpiresIn * 1000 + uni.setStorageSync('accessTokenExpireTime', accessExpireTime) + uni.setStorageSync('refreshTokenExpireTime', refreshExpireTime) + } + } + + /** + * 判断token是否过期 + */ + const isTokenExpired = computed(() => { + if (!tokenInfo.value) { + return true + } + + const now = Date.now() + const expireTime = uni.getStorageSync('accessTokenExpireTime') + + if (!expireTime) + return true + return now >= expireTime + }) + + /** + * 判断refreshToken是否过期 + */ + const isRefreshTokenExpired = computed(() => { + if (!isDoubleTokenMode) + return true + + const now = Date.now() + const refreshExpireTime = uni.getStorageSync('refreshTokenExpireTime') + + if (!refreshExpireTime) + return true + return now >= refreshExpireTime + }) + + /** + * 登录成功后处理逻辑 + * @param tokenInfo 登录返回的token信息 + */ + async function _postLogin(tokenInfo: IAuthLoginRes) { + setTokenInfo(tokenInfo) + const userStore = useUserStore() + await userStore.fetchUserInfo() + } + + /** + * 用户登录 + * 有的时候后端会用一个接口返回token和用户信息,有的时候会分开2个接口,一个获取token,一个获取用户信息 + * (各有利弊,看业务场景和系统复杂度),这里使用2个接口返回的来模拟 + * @param loginForm 登录参数 + * @returns 登录结果 + */ + const login = async (loginForm: ILoginForm) => { + try { + const res = await _login(loginForm) + console.log('普通登录-res: ', res) + await _postLogin(res) + uni.showToast({ + title: '登录成功', + icon: 'success', + }) + return res + } + catch (error) { + console.error('登录失败:', error) + uni.showToast({ + title: '登录失败,请重试', + icon: 'error', + }) + throw error + } + } + + /** + * 微信登录 + * 有的时候后端会用一个接口返回token和用户信息,有的时候会分开2个接口,一个获取token,一个获取用户信息 + * (各有利弊,看业务场景和系统复杂度),这里使用2个接口返回的来模拟 + * @returns 登录结果 + */ + const wxLogin = async () => { + try { + // 获取微信小程序登录的code + const code = await getWxCode() + console.log('微信登录-code: ', code) + const res = await _wxLogin(code) + console.log('微信登录-res: ', res) + await _postLogin(res) + uni.showToast({ + title: '登录成功', + icon: 'success', + }) + return res + } + catch (error) { + console.error('微信登录失败:', error) + uni.showToast({ + title: '微信登录失败,请重试', + icon: 'error', + }) + throw error + } + } + + /** + * 退出登录 并 删除用户信息 + */ + const logout = async () => { + try { + // TODO 实现自己的退出登录逻辑 + await _logout() + } + catch (error) { + console.error('退出登录失败:', error) + } + finally { + // 无论成功失败,都需要清除本地token信息 + // 清除存储的过期时间 + uni.removeStorageSync('accessTokenExpireTime') + uni.removeStorageSync('refreshTokenExpireTime') + console.log('退出登录-清除用户信息') + tokenInfo.value = { ...tokenInfoState } + uni.removeStorageSync('token') + const userStore = useUserStore() + userStore.clearUserInfo() + } + } + + /** + * 刷新token + * @returns 刷新结果 + */ + const refreshToken = async () => { + if (!isDoubleTokenMode) { + console.error('单token模式不支持刷新token') + throw new Error('单token模式不支持刷新token') + } + + try { + // 安全检查,确保refreshToken存在 + if (!isDoubleTokenRes(tokenInfo.value) || !tokenInfo.value.refreshToken) { + throw new Error('无效的refreshToken') + } + + const refreshToken = tokenInfo.value.refreshToken + const res = await _refreshToken(refreshToken) + console.log('刷新token-res: ', res) + setTokenInfo(res) + return res + } + catch (error) { + console.error('刷新token失败:', error) + throw error + } + } + + /** + * 获取有效的token + * 注意:在computed中不直接调用异步函数,只做状态判断 + * 实际的刷新操作应由调用方处理 + */ + const getValidToken = computed(() => { + // token已过期,返回空 + if (isTokenExpired.value) { + return '' + } + + if (!isDoubleTokenMode) { + return isSingleTokenRes(tokenInfo.value) ? tokenInfo.value.token : '' + } + else { + return isDoubleTokenRes(tokenInfo.value) ? tokenInfo.value.accessToken : '' + } + }) + + /** + * 检查是否有登录信息(不考虑token是否过期) + */ + const hasLoginInfo = computed(() => { + if (!tokenInfo.value) { + return false + } + if (isDoubleTokenMode) { + return isDoubleTokenRes(tokenInfo.value) && !!tokenInfo.value.accessToken + } + else { + return isSingleTokenRes(tokenInfo.value) && !!tokenInfo.value.token + } + }) + + /** + * 检查是否已登录且token有效 + */ + const hasValidLogin = computed(() => { + console.log('hasValidLogin', hasLoginInfo.value, !isTokenExpired.value) + return hasLoginInfo.value && !isTokenExpired.value + }) + + /** + * 尝试获取有效的token,如果过期且可刷新,则刷新token + * @returns 有效的token或空字符串 + */ + const tryGetValidToken = async (): Promise => { + if (!getValidToken.value && isDoubleTokenMode && !isRefreshTokenExpired.value) { + try { + await refreshToken() + return getValidToken.value + } + catch (error) { + console.error('尝试刷新token失败:', error) + return '' + } + } + return getValidToken.value + } + + return { + // 核心API方法 + login, + wxLogin, + logout, + + // 认证状态判断(最常用的) + hasLogin: hasValidLogin, + + // 内部系统使用的方法 + refreshToken, + tryGetValidToken, + validToken: getValidToken, + + // 调试或特殊场景可能需要直接访问的信息 + tokenInfo, + setTokenInfo, + } + }, + { + // 添加持久化配置,确保刷新页面后token信息不丢失 + persist: true, + }, +) diff --git a/src/store/user.ts b/src/store/user.ts new file mode 100644 index 0000000..3f6d693 --- /dev/null +++ b/src/store/user.ts @@ -0,0 +1,61 @@ +import type { IUserInfoRes } from '@/api/types/login' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { + getUserInfo, +} from '@/api/login' + +// 初始化状态 +const userInfoState: IUserInfoRes = { + userId: -1, + username: '', + nickname: '', + avatar: '/static/images/default-avatar.png', +} + +export const useUserStore = defineStore( + 'user', + () => { + // 定义用户信息 + const userInfo = ref({ ...userInfoState }) + // 设置用户信息 + const setUserInfo = (val: IUserInfoRes) => { + console.log('设置用户信息', val) + // 若头像为空 则使用默认头像 + if (!val.avatar) { + val.avatar = userInfoState.avatar + } + userInfo.value = val + } + const setUserAvatar = (avatar: string) => { + userInfo.value.avatar = avatar + console.log('设置用户头像', avatar) + console.log('userInfo', userInfo.value) + } + // 删除用户信息 + const clearUserInfo = () => { + userInfo.value = { ...userInfoState } + uni.removeStorageSync('user') + } + + /** + * 获取用户信息 + */ + const fetchUserInfo = async () => { + const res = await getUserInfo() + setUserInfo(res) + return res + } + + return { + userInfo, + clearUserInfo, + fetchUserInfo, + setUserInfo, + setUserAvatar, + } + }, + { + persist: true, + }, +) diff --git a/src/style/iconfont.css b/src/style/iconfont.css new file mode 100644 index 0000000..35da86c --- /dev/null +++ b/src/style/iconfont.css @@ -0,0 +1,28 @@ +@font-face { + font-family: 'iconfont'; /* Project id 4543091 */ + src: + url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAOwAAsAAAAAB9AAAANjAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACDHAqDBIJqATYCJAMQCwoABCAFhGcHPRvnBsgusG3kMyE15/44PsBX09waBHv0REDt97oHAQDFrOIyPirRiULQ+TJcXV0hCYTuVFcBC915/2vX/32Q80hkZ5PZGZ9snvwruVLloidKqYN6iKC53bOtbKwVLSIi3W6zCWZbs3VbER3j9JpGX3ySYcc94IQRTK5s4epS/jSqIgvg37qlY2/jwQN7D9ADpfRCmIknQByTscVZPTBr+hnnCKg2o4bjakvXEPjuY65DJGeJNtBUhn1JxOBuB2UZmUpBOXdsFp4oxOv4GHgs3h/+wRDcicqSZJG1q9kK1z/Af9NpqxjpC2QaAdpHlCFh4spcYXs5sMWpSk5wUj31G2dLQKVKkZ/w7f/8/i/A3JVUSZK9f7xIKJeU14IFpBI/Qfkkz46GT/CuaGREfCtKJUougWeQWHvVC5Lcz2BGS+SePR99vj3yjJx7h574tp7uWcOh4yfaTjS/245TT/vkQrN+a7RLkK8+Vd+bz+FSGh+9srDQKPeJ2s29z7ah4+efdoxefRbbGwfy7ht+SuIWukzsu1b6ePP+6kN1aamb47qsPim1Ia3xdEpDcl1dckPKGYnneI23+57r2W1Mmkqs6ajrChRCs5qyQ66rTVWhgZaG7toOeHm5cxn0sSQuNDEgcUTdNTSupKI1JRZih/JssAUKezPeOJJzbNozF6zWJuuVavVU5Tgtkop/SDzHa7ytvnCTq0PhkEfi4xLLtb0PuwyOAYqmrYQApFJyoJjTnfz+ve94vvv2f/yWgxl8Jd8Di2DRDPuob59mU/+VfDCROQyR8xSnmP9fXm7liagmN39OlmbvjqG0sMsJKrU0EFXogaRSH5bNY1CmxhyUq7QC1cY1T67RwuQk5CoM2RUQNLoEUb03kDS6h2XzcyjT7iOUa/QXqq1Hn6/GUBAaGcGcWJFlGUmCoVOp8kLvABHnVczGYiOE2SVEUH5OXj/TSnTCDjHAviAWcE4RZYaGWszNiKoayGSGTASeY+PcrMjNpVMvyREMDRoxBMYRVojFMkQiMOhohubdzxtAiOapMMbERpKMnQT9SL4ceQysVdJZVa9kEbsFogIcRyEUE2kN0mL7CDVIGhBzupWMEHA5bDvipgq5hKJcKef8ivbx1kC15KgcYkghhzLxYNntxoKCReJ82jAHAAA=') + format('woff2'), + url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.woff?t=1715485842402') format('woff'), + url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.ttf?t=1715485842402') format('truetype'); +} + +.iconfont { + font-family: 'iconfont' !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-my:before { + content: '\e78c'; +} + +.icon-package:before { + content: '\e9c2'; +} + +.icon-chat:before { + content: '\e600'; +} diff --git a/src/style/index.scss b/src/style/index.scss new file mode 100644 index 0000000..1ee7de9 --- /dev/null +++ b/src/style/index.scss @@ -0,0 +1,36 @@ +// 测试用的 iconfont,可生效 +// @import './iconfont.css'; + +.test { + // 可以通过 @apply 多个样式封装整体样式 + @apply mt-4 ml-4; + + padding-top: 4px; + color: red; +} + +:root, +page { + // 修改按主题色 + // --wot-color-theme: #37c2bc; + + // 修改按钮背景色 + // --wot-button-primary-bg-color: green; +} + +/* +border-t-1 +由于uniapp中无法使用*选择器,使用魔法代替*,加上此规则可以简化border与divide的使用,并提升布局的兼容性 +1. 防止padding和border影响元素宽度。 (https://github.com/mozdevs/cssremedy/issues/4) +2. 允许仅通过添加边框宽度来向元素添加边框。 (https://github.com/tailwindcss/tailwindcss/pull/116) +3. [UnoCSS]: 允许使用css变量'--un-default-border-color'覆盖默认边框颜色 +*/ +// 这个样式有重大BUG,先去掉!!(2025-08-15) +// :not(not), +// ::before, +// ::after { +// box-sizing: border-box; /* 1 */ +// border-width: 0; /* 2 */ +// border-style: solid; /* 2 */ +// border-color: var(--un-default-border-color, #e5e7eb); /* 3 */ +// } diff --git a/src/tabbar/README.md b/src/tabbar/README.md new file mode 100644 index 0000000..07d161d --- /dev/null +++ b/src/tabbar/README.md @@ -0,0 +1,84 @@ +# tabbar 说明 + +## tabbar 4种策略 + +`tabbar` 分为 `4 种` 情况: + +- 0 `无 tabbar`,只有一个页面入口,底部无 `tabbar` 显示;常用语临时活动页。 + +- 1 `原生 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。 + - 优势:原生自带的 `tabbar`,最先渲染,有缓存。 + - 劣势:只能使用 2 组图片来切换选中和非选中状态,修改颜色只能重新换图片(或者用 iconfont)。 + +- 2 `有缓存自定义 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。使用了第三方 UI 库的 `tabbar` 组件,并隐藏了原生 `tabbar` 的显示。 + - 优势:可以随意配置自己想要的 `svg icon`,切换字体颜色方便。有缓存。可以实现各种花里胡哨的动效等。 + - 劣势:首次点击 `tabbar` 会闪烁。 + +- 3 `无缓存自定义 tabbar`,使用 `navigateTo` 切换 `tabbar`,`tabbar` 页面无缓存。使用了第三方 UI 库的 `tabbar` 组件。 + - 优势:可以随意配置自己想要的 svg icon,切换字体颜色方便。可以实现各种花里胡哨的动效等。 + - 劣势:首次点击 `tababr` 会闪烁,无缓存。 + +> 注意:花里胡哨的效果需要自己实现,本模版不提供。 + +## tabbar 配置说明 + +- 如果使用的是 `原生tabbar`,需要配置 `nativeTabbarList`,每个 `item` 需要配置 `path`、`text`、`iconPath`、`selectedIconPath` 等属性。 +- 如果使用的是 `自定义tabbar`,需要配置 `customTabbarList`,每个 `item` 需要配置 `path`、`text`、`icon` 、`iconType` 等属性(如果是 `image` 图片还需要配置2种图片)。 + +## 文件说明 + +`config.ts` 专门配置 `nativeTabbarList` 和 `customTabbarList` 的相关信息,请按照文件里面的注释配置相关项。 + +使用 `原生tabbar` 时,不需要关心下面2个文件: + +- `store.ts` ,专门给 `自定义 tabbar` 提供状态管理,代码几乎不需要修改。 +- `index.vue` ,专门给 `自定义 tabbar` 提供渲染逻辑,代码可以稍微修改,以符合自己的需求。 + +## 自定义tabbar的不同类型的配置 + +- uniUi 图标 + + ```js + { + // ... 其他配置 + "iconType": "uniUi", + "icon": "home", + } + ``` + +- unocss 图标 + + ```js + { + // ... 其他配置 + // 注意 unocss 图标需要如下处理:(二选一) + // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行) + // 2)配置到 unocss.config.ts 的 safelist 中 + iconType: 'unocss', + icon: 'i-carbon-code', + } + ``` + +- iconfont 图标 + + ```js + { + // ... 其他配置 + // 注意 iconfont 图标需要额外加上 'iconfont',如下 + iconType: 'iconfont', + icon: 'iconfont icon-my', + } + ``` + +- image 本地图片 + + ```js + { + // ... 其他配置 + // 使用 ‘image’时,需要配置 icon + iconActive 2张图片(不推荐) + // 既然已经用了自定义tabbar了,就不建议用图片了,所以不推荐 + iconType: 'image', + icon: '/static/tabbar/home.png', + iconActive: '/static/tabbar/homeHL.png', + } + ``` diff --git a/src/tabbar/config.ts b/src/tabbar/config.ts new file mode 100644 index 0000000..141c07a --- /dev/null +++ b/src/tabbar/config.ts @@ -0,0 +1,128 @@ +import type { TabBar } from '@uni-helper/vite-plugin-uni-pages' +import type { CustomTabBarItem, NativeTabBarItem } from './types' + +/** + * tabbar 选择的策略,更详细的介绍见 tabbar.md 文件 + * 0: 'NO_TABBAR' `无 tabbar` + * 1: 'NATIVE_TABBAR' `完全原生 tabbar` + * 2: 'CUSTOM_TABBAR_WITH_CACHE' `有缓存自定义 tabbar` + * 3: 'CUSTOM_TABBAR_WITHOUT_CACHE' `无缓存自定义 tabbar` + * + * 温馨提示:本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致配置不生效 + */ +export const TABBAR_STRATEGY_MAP = { + NO_TABBAR: 0, + NATIVE_TABBAR: 1, + CUSTOM_TABBAR_WITH_CACHE: 2, + CUSTOM_TABBAR_WITHOUT_CACHE: 3, +} + +// TODO: 1/3. 通过这里切换使用tabbar的策略 +// 如果是使用 NO_TABBAR(0),nativeTabbarList 和 customTabbarList 都不生效(里面的配置不用管) +// 如果是使用 NATIVE_TABBAR(1),只需要配置 nativeTabbarList,customTabbarList 不生效 +// 如果是使用 CUSTOM_TABBAR(2,3),只需要配置 customTabbarList,nativeTabbarList 不生效 +export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE + +// TODO: 2/3. 使用 NATIVE_TABBAR 时,更新下面的 tabbar 配置 +export const nativeTabbarList: NativeTabBarItem[] = [ + { + iconPath: 'static/tabbar/home.png', + selectedIconPath: 'static/tabbar/homeHL.png', + pagePath: 'pages/index/index', + text: '首页', + }, + { + iconPath: 'static/tabbar/personal.png', + selectedIconPath: 'static/tabbar/personalHL.png', + pagePath: 'pages/me/me', + text: '个人', + }, +] + +// TODO: 3/3. 使用 CUSTOM_TABBAR(2,3) 时,更新下面的 tabbar 配置 +// 如果需要配置鼓包,需要在 'tabbar/store.ts' 里面设置,最后在 `tabbar/index.vue` 里面更改鼓包的图片 +export const customTabbarList: CustomTabBarItem[] = [ + { + text: '首页', + pagePath: 'pages/index/index', + // 注意 unocss 图标需要如下处理:(二选一) + // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行) + // 2)配置到 unocss.config.ts 的 safelist 中 + iconType: 'unocss', + icon: 'i-carbon-home', + // badge: 'dot', + }, + { + pagePath: 'pages/me/me', + text: '我的', + // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行) + // 2)配置到 unocss.config.ts 的 safelist 中 + iconType: 'unocss', + icon: 'i-carbon-user', + // badge: 10, + }, + // 其他类型演示 + // 1、uiLib + // { + // pagePath: 'pages/index/index', + // text: '首页', + // iconType: 'uiLib', + // icon: 'home', + // }, + // 2、iconfont + // { + // pagePath: 'pages/index/index', + // text: '首页', + // // 注意 iconfont 图标需要额外加上 'iconfont',如下 + // iconType: 'iconfont', + // icon: 'iconfont icon-my', + // }, + // 3、image + // { + // pagePath: 'pages/index/index', + // text: '首页', + // // 使用 ‘image’时,需要配置 icon + iconActive 2张图片 + // iconType: 'image', + // icon: '/static/tabbar/home.png', + // iconActive: '/static/tabbar/homeHL.png', + // }, +] + +/** + * 是否启用 tabbar 缓存 + * NATIVE_TABBAR(1) 和 CUSTOM_TABBAR_WITH_CACHE(2) 时,需要tabbar缓存 + */ +export const tabbarCacheEnable + = [TABBAR_STRATEGY_MAP.NATIVE_TABBAR, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE].includes(selectedTabbarStrategy) + +/** + * 是否启用自定义 tabbar + * CUSTOM_TABBAR(2,3) 时,启用自定义tabbar + */ +export const customTabbarEnable + = [TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITHOUT_CACHE].includes(selectedTabbarStrategy) + +/** + * 是否需要隐藏原生 tabbar + * CUSTOM_TABBAR_WITH_CACHE(2) 时,需要隐藏原生tabbar + */ +export const needHideNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE + +const _tabbarList = customTabbarEnable ? customTabbarList.map(item => ({ text: item.text, pagePath: item.pagePath })) : nativeTabbarList +export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList + +const _tabbar: TabBar = { + // 只有微信小程序支持 custom。App 和 H5 不生效 + custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR_WITH_CACHE, + color: '#999999', + selectedColor: '#018d71', + backgroundColor: '#F8F8F8', + borderStyle: 'black', + height: '50px', + fontSize: '10px', + iconWidth: '24px', + spacing: '3px', + list: _tabbarList as unknown as TabBar['list'], +} + +export const tabBar = tabbarCacheEnable ? _tabbar : undefined diff --git a/src/tabbar/index.vue b/src/tabbar/index.vue new file mode 100644 index 0000000..e66de29 --- /dev/null +++ b/src/tabbar/index.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/src/tabbar/store.ts b/src/tabbar/store.ts new file mode 100644 index 0000000..c26b826 --- /dev/null +++ b/src/tabbar/store.ts @@ -0,0 +1,78 @@ +import type { CustomTabBarItem, CustomTabBarItemBadge } from './types' +import { reactive } from 'vue' + +import { tabbarList as _tabbarList, customTabbarEnable, selectedTabbarStrategy, TABBAR_STRATEGY_MAP } from './config' + +// TODO 1/2: 中间的鼓包tabbarItem的开关 +const BULGE_ENABLE = false + +/** tabbarList 里面的 path 从 pages.config.ts 得到 */ +const tabbarList = reactive(_tabbarList.map(item => ({ + ...item, + pagePath: item.pagePath.startsWith('/') ? item.pagePath : `/${item.pagePath}`, +}))) + +if (customTabbarEnable && BULGE_ENABLE) { + if (tabbarList.length % 2) { + console.error('有鼓包时 tabbar 数量必须是偶数,否则样式很奇怪!!') + } + tabbarList.splice(tabbarList.length / 2, 0, { + isBulge: true, + } as CustomTabBarItem) +} + +export function isPageTabbar(path: string) { + if (selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NO_TABBAR) { + return false + } + const _path = path.split('?')[0] + return tabbarList.some(item => item.pagePath === _path) +} + +/** + * 自定义 tabbar 的状态管理,原生 tabbar 无需关注本文件 + * tabbar 状态,增加 storageSync 保证刷新浏览器时在正确的 tabbar 页面 + * 使用reactive简单状态,而不是 pinia 全局状态 + */ +const tabbarStore = reactive({ + curIdx: uni.getStorageSync('app-tabbar-index') || 0, + prevIdx: uni.getStorageSync('app-tabbar-index') || 0, + setCurIdx(idx: number) { + this.curIdx = idx + uni.setStorageSync('app-tabbar-index', idx) + }, + setTabbarItemBadge(idx: number, badge: CustomTabBarItemBadge) { + if (tabbarList[idx]) { + tabbarList[idx].badge = badge + } + }, + setAutoCurIdx(path: string) { + // '/' 当做首页 + if (path === '/') { + this.setCurIdx(0) + return + } + const index = tabbarList.findIndex(item => item.pagePath === path) + // console.log('tabbarList:', tabbarList) + if (index === -1) { + const pagesPathList = getCurrentPages().map(item => item.route.startsWith('/') ? item.route : `/${item.route}`) + // console.log(pagesPathList) + const flag = tabbarList.some(item => pagesPathList.includes(item.pagePath)) + if (!flag) { + this.setCurIdx(0) + return + } + } + else { + this.setCurIdx(index) + } + }, + restorePrevIdx() { + if (this.prevIdx === this.curIdx) + return + this.setCurIdx(this.prevIdx) + this.prevIdx = uni.getStorageSync('app-tabbar-index') || 0 + }, +}) + +export { tabbarList, tabbarStore } diff --git a/src/tabbar/types.ts b/src/tabbar/types.ts new file mode 100644 index 0000000..d5b64ad --- /dev/null +++ b/src/tabbar/types.ts @@ -0,0 +1,34 @@ +import type { TabBar } from '@uni-helper/vite-plugin-uni-pages' +import type { RemoveLeadingSlashFromUnion } from '@/typings' + +/** + * 原生 tabbar 的单个选项配置 + */ +export type NativeTabBarItem = TabBar['list'][number] & { + pagePath: RemoveLeadingSlashFromUnion<_LocationUrl> +} + +/** badge 显示一个数字或 小红点(样式可以直接在 tabbar/index.vue 里面修改) */ +export type CustomTabBarItemBadge = number | 'dot' + +/** 自定义 tabbar 的单个选项配置 */ +export interface CustomTabBarItem { + text: string + pagePath: RemoveLeadingSlashFromUnion<_LocationUrl> + /** 图标类型,不建议用 image 模式,因为需要配置 2 张图,更麻烦 */ + iconType: 'uiLib' | 'unocss' | 'iconfont' | 'image' + /** + * icon 的路径 + * - uiLib: wot-design-uni 图标的 icon prop + * - unocss: unocss 图标的类名 + * - iconfont: iconfont 图标的类名 + * - image: 图片的路径 + */ + icon: string + /** 只有在 image 模式下才需要,传递的是高亮的图片(PS: 不建议用 image 模式) */ + iconActive?: string + /** badge 显示一个数字或 小红点 */ + badge?: CustomTabBarItemBadge + /** 是否是中间的鼓包tabbarItem */ + isBulge?: boolean +} diff --git a/src/typings.d.ts b/src/typings.d.ts new file mode 100644 index 0000000..c27a7ae --- /dev/null +++ b/src/typings.d.ts @@ -0,0 +1,171 @@ +// 全局要用的类型放到这里 + +declare global { + interface IResData { + code: number + msg: string + data: T + } + + // uni.uploadFile文件上传参数 + interface IUniUploadFileOptions { + file?: File + files?: UniApp.UploadFileOptionFiles[] + filePath?: string + name?: string + formData?: any + } + + interface IUserInfo { + nickname?: string + avatar?: string + /** 微信的 openid,非微信没有这个字段 */ + openid?: string + } + + interface IUserToken { + token: string + refreshToken?: string + refreshExpire?: number + } +} + +// 扩展 @uni-helper/vite-plugin-uni-pages 的 definePage 参数类型 +declare module '@uni-helper/vite-plugin-uni-pages' { + interface UserPageMeta { + /** + * 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page + * + * 尽量保证一个项目 只有一个 这个配置,如果有多个,会按照字母顺序来排列,最终可能不是您想要的效果。 + */ + type?: 'home' + /** + * 页面布局类型, 模板默认只有 default, 如果在 src/layouts 下新增了 layout, 可以扩展当前属性 + * @default 'default' + * + * 当前属性供 https://github.com/uni-helper/vite-plugin-uni-layouts 插件使用 + */ + layout?: 'default' + /** + * 是否从需要登录的路径中排除 + * + * 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹 + */ + excludeLoginPath?: boolean + } +} + +// patch uni 类型 +// 1. 补全 uni.hideToast() 的 options 类型 +// 2. 补全 uni.hideLoading() 的 options 类型 +// 3. 使用方式见:https://github.com/unibest-tech/unibest/pull/241 +declare global { + declare namespace UniNamespace { + /** 接口调用结束的回调函数(调用成功、失败都会执行) */ + type HideLoadingCompleteCallback = (res: GeneralCallbackResult) => void + /** 接口调用失败的回调函数 */ + type HideLoadingFailCallback = (res: GeneralCallbackResult) => void + /** 接口调用成功的回调函数 */ + type HideLoadingSuccessCallback = (res: GeneralCallbackResult) => void + + interface HideLoadingOption { + /** 接口调用结束的回调函数(调用成功、失败都会执行) */ + complete?: HideLoadingCompleteCallback + /** 接口调用失败的回调函数 */ + fail?: HideLoadingFailCallback + test: UniNamespace.GeneralCallbackResult + /** + * 微信小程序:需要基础库: `2.22.1` + * + * 微信小程序:目前 toast 和 loading 相关接口可以相互混用,此参数可用于取消混用特性 + */ + noConflict?: boolean + /** 接口调用成功的回调函数 */ + success?: HideLoadingSuccessCallback + } + + // ---------------------------------------------------------- + + /** 接口调用结束的回调函数(调用成功、失败都会执行) */ + type HideToastCompleteCallback = (res: GeneralCallbackResult) => void + /** 接口调用失败的回调函数 */ + type HideToastFailCallback = (res: GeneralCallbackResult) => void + /** 接口调用成功的回调函数 */ + type HideToastSuccessCallback = (res: GeneralCallbackResult) => void + interface HideToastOption { + /** 接口调用结束的回调函数(调用成功、失败都会执行) */ + complete?: HideToastCompleteCallback + /** 接口调用失败的回调函数 */ + fail?: HideToastFailCallback + /** + * 微信小程序:需要基础库: `2.22.1` + * + * 微信小程序:目前 toast 和 loading 相关接口可以相互混用,此参数可用于取消混用特性 + */ + noConflict?: boolean + /** 接口调用成功的回调函数 */ + success?: HideToastSuccessCallback + } + } + interface Uni { + /** + * 隐藏 loading 提示框 + * + * 文档: [http://uniapp.dcloud.io/api/ui/prompt?id=hideloading](http://uniapp.dcloud.io/api/ui/prompt?id=hideloading) + * @example ```typescript + * uni.showLoading({ + * title: '加载中' + * }); + * + * setTimeout(function () { + * uni.hideLoading(); + * }, 2000); + * + * ``` + * @tutorial [](https://uniapp.dcloud.net.cn/api/ui/prompt.html#hideloading) + * @uniPlatform { + * "app": { + * "android": { + * "osVer": "4.4.4", + * "uniVer": "√", + * "unixVer": "3.9.0" + * }, + * "ios": { + * "osVer": "9.0", + * "uniVer": "√", + * "unixVer": "3.9.0" + * } + * } + * } + */ + // eslint-disable-next-line ts/method-signature-style + hideLoading(options?: T): void + /** + * 隐藏消息提示框 + * + * 文档: [http://uniapp.dcloud.io/api/ui/prompt?id=hidetoast](http://uniapp.dcloud.io/api/ui/prompt?id=hidetoast) + * @example ```typescript + * uni.hideToast(); + * ``` + * @tutorial [](https://uniapp.dcloud.net.cn/api/ui/prompt.html#hidetoast) + * @uniPlatform { + * "app": { + * "android": { + * "osVer": "4.4.4", + * "uniVer": "√", + * "unixVer": "3.9.0" + * }, + * "ios": { + * "osVer": "9.0", + * "uniVer": "√", + * "unixVer": "3.9.0" + * } + * } + * } + */ + // eslint-disable-next-line ts/method-signature-style + hideToast(options?: T): void + } +} + +export {} // 防止模块污染 diff --git a/src/typings.ts b/src/typings.ts new file mode 100644 index 0000000..fefe1cb --- /dev/null +++ b/src/typings.ts @@ -0,0 +1,21 @@ +// 枚举定义 + +export enum TestEnum { + A = '1', + B = '2', +} + +// uni.uploadFile文件上传参数 +export interface IUniUploadFileOptions { + file?: File + files?: UniApp.UploadFileOptionFiles[] + filePath?: string + name?: string + formData?: any +} + +/** 工具类型:删除字符串开头的第一个斜杠 */ +export type RemoveLeadingSlash = S extends `/${infer Rest}` ? Rest : S + +/** 工具类型:删除联合类型中每个字符串的第一个斜杠 */ +export type RemoveLeadingSlashFromUnion = T extends any ? RemoveLeadingSlash : never diff --git a/src/uni.scss b/src/uni.scss new file mode 100644 index 0000000..21b9e5f --- /dev/null +++ b/src/uni.scss @@ -0,0 +1,77 @@ +/* stylelint-disable comment-empty-line-before */ +/** + * 这里是uni-app内置的常用样式变量 + * + * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量 + * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App + * + */ + +/** + * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能 + * + * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件 + */ + +/* 颜色变量 */ + +/* 行为相关颜色 */ +$uni-color-primary: #007aff; +$uni-color-success: #4cd964; +$uni-color-warning: #f0ad4e; +$uni-color-error: #dd524d; + +/* 文字基本颜色 */ +$uni-text-color: #333; // 基本色 +$uni-text-color-inverse: #fff; // 反色 +$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息 +$uni-text-color-placeholder: #808080; +$uni-text-color-disable: #c0c0c0; + +/* 背景颜色 */ +$uni-bg-color: #fff; +$uni-bg-color-grey: #f8f8f8; +$uni-bg-color-hover: #f1f1f1; // 点击状态颜色 +$uni-bg-color-mask: rgb(0 0 0 / 40%); // 遮罩颜色 + +/* 边框颜色 */ +$uni-border-color: #c8c7cc; + +/* 尺寸变量 */ + +/* 文字尺寸 */ +$uni-font-size-sm: 12px; +$uni-font-size-base: 14px; +$uni-font-size-lg: 16; + +/* 图片尺寸 */ +$uni-img-size-sm: 20px; +$uni-img-size-base: 26px; +$uni-img-size-lg: 40px; + +/* Border Radius */ +$uni-border-radius-sm: 2px; +$uni-border-radius-base: 3px; +$uni-border-radius-lg: 6px; +$uni-border-radius-circle: 50%; + +/* 水平间距 */ +$uni-spacing-row-sm: 5px; +$uni-spacing-row-base: 10px; +$uni-spacing-row-lg: 15px; + +/* 垂直间距 */ +$uni-spacing-col-sm: 4px; +$uni-spacing-col-base: 8px; +$uni-spacing-col-lg: 12px; + +/* 透明度 */ +$uni-opacity-disabled: 0.3; // 组件禁用态的透明度 + +/* 文章场景相关 */ +$uni-color-title: #2c405a; // 文章标题颜色 +$uni-font-size-title: 20px; +$uni-color-subtitle: #555; // 二级标题颜色 +$uni-font-size-subtitle: 18px; +$uni-color-paragraph: #3f536e; // 文章段落颜色 +$uni-font-size-paragraph: 15px; diff --git a/src/uni_modules/.gitkeep b/src/uni_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 0000000..c13f470 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,166 @@ +// fork from https://github.com/toss/es-toolkit/blob/main/src/function/debounce.ts +// 文档可查看:https://es-toolkit.dev/reference/function/debounce.html +// 如需要 throttle 功能,可 copy https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts + +interface DebounceOptions { + /** + * An optional AbortSignal to cancel the debounced function. + */ + signal?: AbortSignal + + /** + * An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. + * If `edges` includes "leading", the function will be invoked at the start of the delay period. + * If `edges` includes "trailing", the function will be invoked at the end of the delay period. + * If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period. + * @default ["trailing"] + */ + edges?: Array<'leading' | 'trailing'> +} + +export interface DebouncedFunction void> { + (...args: Parameters): void + + /** + * Schedules the execution of the debounced function after the specified debounce delay. + * This method resets any existing timer, ensuring that the function is only invoked + * after the delay has elapsed since the last call to the debounced function. + * It is typically called internally whenever the debounced function is invoked. + * + * @returns {void} + */ + schedule: () => void + + /** + * Cancels any pending execution of the debounced function. + * This method clears the active timer and resets any stored context or arguments. + */ + cancel: () => void + + /** + * Immediately invokes the debounced function if there is a pending execution. + * This method executes the function right away if there is a pending execution. + */ + flush: () => void +} + +/** + * Creates a debounced function that delays invoking the provided function until after `debounceMs` milliseconds + * have elapsed since the last time the debounced function was invoked. The debounced function also has a `cancel` + * method to cancel any pending execution. + * + * @template F - The type of function. + * @param {F} func - The function to debounce. + * @param {number} debounceMs - The number of milliseconds to delay. + * @param {DebounceOptions} options - The options object + * @param {AbortSignal} options.signal - An optional AbortSignal to cancel the debounced function. + * @returns A new debounced function with a `cancel` method. + * + * @example + * const debouncedFunction = debounce(() => { + * console.log('Function executed'); + * }, 1000); + * + * // Will log 'Function executed' after 1 second if not called again in that time + * debouncedFunction(); + * + * // Will not log anything as the previous call is canceled + * debouncedFunction.cancel(); + * + * // With AbortSignal + * const controller = new AbortController(); + * const signal = controller.signal; + * const debouncedWithSignal = debounce(() => { + * console.log('Function executed'); + * }, 1000, { signal }); + * + * debouncedWithSignal(); + * + * // Will cancel the debounced function call + * controller.abort(); + */ +export function debounce void>( + func: F, + debounceMs: number, + { signal, edges }: DebounceOptions = {}, +): DebouncedFunction { + let pendingThis: any + let pendingArgs: Parameters | null = null + + const leading = edges != null && edges.includes('leading') + const trailing = edges == null || edges.includes('trailing') + + const invoke = () => { + if (pendingArgs !== null) { + func.apply(pendingThis, pendingArgs) + pendingThis = undefined + pendingArgs = null + } + } + + const onTimerEnd = () => { + if (trailing) { + invoke() + } + + // eslint-disable-next-line ts/no-use-before-define + cancel() + } + + let timeoutId: ReturnType | null = null + + const schedule = () => { + if (timeoutId != null) { + clearTimeout(timeoutId) + } + + timeoutId = setTimeout(() => { + timeoutId = null + + onTimerEnd() + }, debounceMs) + } + + const cancelTimer = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId) + timeoutId = null + } + } + + const cancel = () => { + cancelTimer() + pendingThis = undefined + pendingArgs = null + } + + const flush = () => { + invoke() + } + + const debounced = function (this: any, ...args: Parameters) { + if (signal?.aborted) { + return + } + + // eslint-disable-next-line ts/no-this-alias + pendingThis = this + pendingArgs = args + + const isFirstCall = timeoutId == null + + schedule() + + if (leading && isFirstCall) { + invoke() + } + } + + debounced.schedule = schedule + debounced.cancel = cancel + debounced.flush = flush + + signal?.addEventListener('abort', cancel, { once: true }) + + return debounced +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..89d14c7 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,159 @@ +import type { PageMetaDatum, SubPackages } from '@uni-helper/vite-plugin-uni-pages' +import { isMpWeixin } from '@uni-helper/uni-env' +import { pages, subPackages } from '@/pages.json' + +export type PageInstance = Page.PageInstance & { $page: Page.PageInstance & { fullPath: string } } + +export function getLastPage() { + // getCurrentPages() 至少有1个元素,所以不再额外判断 + // const lastPage = getCurrentPages().at(-1) + // 上面那个在低版本安卓中打包会报错,所以改用下面这个【虽然我加了 src/interceptions/prototype.ts,但依然报错】 + const pages = getCurrentPages() + return pages[pages.length - 1] as PageInstance +} + +/** + * 获取当前页面路由的 path 路径和 redirectPath 路径 + * path 如 '/pages/login/login' + * redirectPath 如 '/pages/demo/base/route-interceptor' + */ +export function currRoute() { + const lastPage = getLastPage() as PageInstance + if (!lastPage) { + return { + path: '', + query: {}, + } + } + const currRoute = lastPage.$page + // console.log('lastPage.$page:', currRoute) + // console.log('lastPage.$page.fullpath:', currRoute.fullPath) + // console.log('lastPage.$page.options:', currRoute.options) + // console.log('lastPage.options:', (lastPage as any).options) + // 经过多端测试,只有 fullPath 靠谱,其他都不靠谱 + const { fullPath } = currRoute + // console.log(fullPath) + // eg: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor (小程序) + // eg: /pages/login/login?redirect=%2Fpages%2Froute-interceptor%2Findex%3Fname%3Dfeige%26age%3D30(h5) + return parseUrlToObj(fullPath) +} + +export function ensureDecodeURIComponent(url: string) { + if (url.startsWith('%')) { + return ensureDecodeURIComponent(decodeURIComponent(url)) + } + return url +} +/** + * 解析 url 得到 path 和 query + * 比如输入url: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor + * 输出: {path: /pages/login/login, query: {redirect: /pages/demo/base/route-interceptor}} + */ +export function parseUrlToObj(url: string) { + const [path, queryStr] = url.split('?') + // console.log(path, queryStr) + + if (!queryStr) { + return { + path, + query: {}, + } + } + const query: Record = {} + queryStr.split('&').forEach((item) => { + const [key, value] = item.split('=') + // console.log(key, value) + query[key] = ensureDecodeURIComponent(value) // 这里需要统一 decodeURIComponent 一下,可以兼容h5和微信y + }) + return { path, query } +} +/** + * 得到所有的需要登录的 pages,包括主包和分包的 + * 这里设计得通用一点,可以传递 key 作为判断依据,默认是 excludeLoginPath, 与 route-block 配对使用 + * 如果没有传 key,则表示所有的 pages,如果传递了 key, 则表示通过 key 过滤 + */ +export function getAllPages(key?: string) { + // 这里处理主包 + const mainPages = (pages as PageMetaDatum[]) + .filter(page => !key || page[key]) + .map(page => ({ + ...page, + path: `/${page.path}`, + })) + + // 这里处理分包 + const subPages: PageMetaDatum[] = [] + ;(subPackages as SubPackages).forEach((subPageObj) => { + // console.log(subPageObj) + const { root } = subPageObj + + subPageObj.pages + .filter(page => !key || page[key]) + .forEach((page) => { + subPages.push({ + ...page, + path: `/${root}/${page.path}`, + }) + }) + }) + const result = [...mainPages, ...subPages] + // console.log(`getAllPages by ${key} result: `, result) + return result +} + +export function getCurrentPageI18nKey() { + const routeObj = currRoute() + const currPage = (pages as PageMetaDatum[]).find(page => `/${page.path}` === routeObj.path) + if (!currPage) { + console.warn('路由不正确') + return '' + } + console.log(currPage) + console.log(currPage.style.navigationBarTitleText) + return currPage.style?.navigationBarTitleText || '' +} + +/** + * 根据微信小程序当前环境,判断应该获取的 baseUrl + */ +export function getEnvBaseUrl() { + // 请求基准地址 + let baseUrl = import.meta.env.VITE_SERVER_BASEURL + + // # 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。 + const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'https://ukw0y1.laf.run' + const VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'https://ukw0y1.laf.run' + const VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'https://ukw0y1.laf.run' + + // 微信小程序端环境区分 + if (isMpWeixin) { + const { + miniProgram: { envVersion }, + } = uni.getAccountInfoSync() + + switch (envVersion) { + case 'develop': + baseUrl = VITE_SERVER_BASEURL__WEIXIN_DEVELOP || baseUrl + break + case 'trial': + baseUrl = VITE_SERVER_BASEURL__WEIXIN_TRIAL || baseUrl + break + case 'release': + baseUrl = VITE_SERVER_BASEURL__WEIXIN_RELEASE || baseUrl + break + } + } + + return baseUrl +} + +/** + * 是否是双token模式 + */ +export const isDoubleTokenMode = import.meta.env.VITE_AUTH_MODE === 'double' + +/** + * 首页路径,通过 page.json 里面的 type 为 home 的页面获取,如果没有,则默认是第一个页面 + * 通常为 /pages/index/index + */ +export const HOME_PAGE = `/${(pages as PageMetaDatum[]).find(page => page.type === 'home')?.path || (pages as PageMetaDatum[])[0].path}` diff --git a/src/utils/systemInfo.ts b/src/utils/systemInfo.ts new file mode 100644 index 0000000..a60f82e --- /dev/null +++ b/src/utils/systemInfo.ts @@ -0,0 +1,38 @@ +/* eslint-disable import/no-mutable-exports */ +// 获取屏幕边界到安全区域距离 +let systemInfo +let safeAreaInsets + +// #ifdef MP-WEIXIN +// 微信小程序使用新的API +systemInfo = uni.getWindowInfo() +safeAreaInsets = systemInfo.safeArea + ? { + top: systemInfo.safeArea.top, + right: systemInfo.windowWidth - systemInfo.safeArea.right, + bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom, + left: systemInfo.safeArea.left, + } + : null +// #endif + +// #ifndef MP-WEIXIN +// 其他平台继续使用uni API +systemInfo = uni.getSystemInfoSync() +safeAreaInsets = systemInfo.safeAreaInsets +// #endif + +console.log('systemInfo', systemInfo) +// 微信里面打印 +// pixelRatio: 3 +// safeArea: {top: 47, left: 0, right: 390, bottom: 810, width: 390, …} +// safeAreaInsets: {top: 47, left: 0, right: 0, bottom: 34} +// screenHeight: 844 +// screenTop: 91 +// screenWidth: 390 +// statusBarHeight: 47 +// windowBottom: 0 +// windowHeight: 753 +// windowTop: 0 +// windowWidth: 390 +export { safeAreaInsets, systemInfo } diff --git a/src/utils/toLoginPage.ts b/src/utils/toLoginPage.ts new file mode 100644 index 0000000..de2f8dd --- /dev/null +++ b/src/utils/toLoginPage.ts @@ -0,0 +1,44 @@ +import { getLastPage } from '@/utils' +import { debounce } from '@/utils/debounce' + +interface ToLoginPageOptions { + /** + * 跳转模式, uni.navigateTo | uni.reLaunch + * @default 'navigateTo' + */ + mode?: 'navigateTo' | 'reLaunch' + /** + * 查询参数 + * @example '?redirect=/pages/home/index' + */ + queryString?: string +} + +// TODO: 自己增加登录页 +const LOGIN_PAGE = '/pages/login/index' + +/** + * 跳转到登录页, 带防抖处理 + * + * 如果要立即跳转,不做延时,可以使用 `toLoginPage.flush()` 方法 + */ +export const toLoginPage = debounce((options: ToLoginPageOptions = {}) => { + const { mode = 'navigateTo', queryString = '' } = options + + const url = `${LOGIN_PAGE}${queryString}` + + // 获取当前页面路径 + const currentPage = getLastPage() + const currentPath = `/${currentPage.route}` + // 如果已经在登录页,则不跳转 + if (currentPath === LOGIN_PAGE) { + return + } + + if (mode === 'navigateTo') { + uni.navigateTo({ url }) + } + else { + uni.reLaunch({ url }) + } +}, 500) diff --git a/src/utils/updateManager.wx.ts b/src/utils/updateManager.wx.ts new file mode 100644 index 0000000..20b8b50 --- /dev/null +++ b/src/utils/updateManager.wx.ts @@ -0,0 +1,29 @@ +export default () => { + if (!wx.canIUse('getUpdateManager')) { + return + } + + const updateManager = wx.getUpdateManager() + + updateManager.onCheckForUpdate((res) => { + // 请求完新版本信息的回调 + console.log('版本信息', res) + }) + + updateManager.onUpdateReady(() => { + wx.showModal({ + title: '更新提示', + content: '新版本已经准备好,是否重启应用?', + success(res) { + if (res.confirm) { + // 新的版本已经下载好,调用 applyUpdate 应用新版本并重启 + updateManager.applyUpdate() + } + }, + }) + }) + + updateManager.onUpdateFailed(() => { + // 新版本下载失败 + }) +} diff --git a/src/utils/uploadFile.ts b/src/utils/uploadFile.ts new file mode 100644 index 0000000..71848a2 --- /dev/null +++ b/src/utils/uploadFile.ts @@ -0,0 +1,325 @@ +/** + * 文件上传钩子函数使用示例 + * @example + * const { loading, error, data, progress, run } = useUpload( + * uploadUrl, + * {}, + * { + * maxSize: 5, // 最大5MB + * sourceType: ['album'], // 仅支持从相册选择 + * onProgress: (p) => console.log(`上传进度:${p}%`), + * onSuccess: (res) => console.log('上传成功', res), + * onError: (err) => console.error('上传失败', err), + * }, + * ) + */ + +/** + * 上传文件的URL配置 + */ +export const uploadFileUrl = { + /** 用户头像上传地址 */ + USER_AVATAR: `${import.meta.env.VITE_SERVER_BASEURL}/user/avatar`, +} + +/** + * 通用文件上传函数(支持直接传入文件路径) + * @param url 上传地址 + * @param filePath 本地文件路径 + * @param formData 额外表单数据 + * @param options 上传选项 + */ +export function useFileUpload(url: string, filePath: string, formData: Record = {}, options: Omit = {}) { + return useUpload( + url, + formData, + { + ...options, + sourceType: ['album'], + sizeType: ['original'], + }, + filePath, + ) +} + +export interface UploadOptions { + /** 最大可选择的图片数量,默认为1 */ + count?: number + /** 所选的图片的尺寸,original-原图,compressed-压缩图 */ + sizeType?: Array<'original' | 'compressed'> + /** 选择图片的来源,album-相册,camera-相机 */ + sourceType?: Array<'album' | 'camera'> + /** 文件大小限制,单位:MB */ + maxSize?: number // + /** 上传进度回调函数 */ + onProgress?: (progress: number) => void + /** 上传成功回调函数 */ + onSuccess?: (res: Record) => void + /** 上传失败回调函数 */ + onError?: (err: Error | UniApp.GeneralCallbackResult) => void + /** 上传完成回调函数(无论成功失败) */ + onComplete?: () => void +} + +/** + * 文件上传钩子函数 + * @template T 上传成功后返回的数据类型 + * @param url 上传地址 + * @param formData 额外的表单数据 + * @param options 上传选项 + * @returns 上传状态和控制对象 + */ +export function useUpload(url: string, formData: Record = {}, options: UploadOptions = {}, + /** 直接传入文件路径,跳过选择器 */ + directFilePath?: string) { + /** 上传中状态 */ + const loading = ref(false) + /** 上传错误状态 */ + const error = ref(false) + /** 上传成功后的响应数据 */ + const data = ref() + /** 上传进度(0-100) */ + const progress = ref(0) + + /** 解构上传选项,设置默认值 */ + const { + /** 最大可选择的图片数量 */ + count = 1, + /** 所选的图片的尺寸 */ + sizeType = ['original', 'compressed'], + /** 选择图片的来源 */ + sourceType = ['album', 'camera'], + /** 文件大小限制(MB) */ + maxSize = 10, + /** 进度回调 */ + onProgress, + /** 成功回调 */ + onSuccess, + /** 失败回调 */ + onError, + /** 完成回调 */ + onComplete, + } = options + + /** + * 检查文件大小是否超过限制 + * @param size 文件大小(字节) + * @returns 是否通过检查 + */ + const checkFileSize = (size: number) => { + const sizeInMB = size / 1024 / 1024 + if (sizeInMB > maxSize) { + uni.showToast({ + title: `文件大小不能超过${maxSize}MB`, + icon: 'none', + }) + return false + } + return true + } + /** + * 触发文件选择和上传 + * 根据平台使用不同的选择器: + * - 微信小程序使用 chooseMedia + * - 其他平台使用 chooseImage + */ + const run = () => { + if (directFilePath) { + // 直接使用传入的文件路径 + loading.value = true + progress.value = 0 + uploadFile({ + url, + tempFilePath: directFilePath, + formData, + data, + error, + loading, + progress, + onProgress, + onSuccess, + onError, + onComplete, + }) + return + } + + // #ifdef MP-WEIXIN + // 微信小程序环境下使用 chooseMedia API + uni.chooseMedia({ + count, + mediaType: ['image'], // 仅支持图片类型 + sourceType, + success: (res) => { + const file = res.tempFiles[0] + // 检查文件大小是否符合限制 + if (!checkFileSize(file.size)) + return + + // 开始上传 + loading.value = true + progress.value = 0 + uploadFile({ + url, + tempFilePath: file.tempFilePath, + formData, + data, + error, + loading, + progress, + onProgress, + onSuccess, + onError, + onComplete, + }) + }, + fail: (err) => { + console.error('选择媒体文件失败:', err) + error.value = true + onError?.(err) + }, + }) + // #endif + + // #ifndef MP-WEIXIN + // 非微信小程序环境下使用 chooseImage API + uni.chooseImage({ + count, + sizeType, + sourceType, + success: (res) => { + console.log('选择图片成功:', res) + + // 开始上传 + loading.value = true + progress.value = 0 + uploadFile({ + url, + tempFilePath: res.tempFilePaths[0], + formData, + data, + error, + loading, + progress, + onProgress, + onSuccess, + onError, + onComplete, + }) + }, + fail: (err) => { + console.error('选择图片失败:', err) + error.value = true + onError?.(err) + }, + }) + // #endif + } + + return { loading, error, data, progress, run } +} + +/** + * 文件上传选项接口 + * @template T 上传成功后返回的数据类型 + */ +interface UploadFileOptions { + /** 上传地址 */ + url: string + /** 临时文件路径 */ + tempFilePath: string + /** 额外的表单数据 */ + formData: Record + /** 上传成功后的响应数据 */ + data: Ref + /** 上传错误状态 */ + error: Ref + /** 上传中状态 */ + loading: Ref + /** 上传进度(0-100) */ + progress: Ref + /** 上传进度回调 */ + onProgress?: (progress: number) => void + /** 上传成功回调 */ + onSuccess?: (res: Record) => void + /** 上传失败回调 */ + onError?: (err: Error | UniApp.GeneralCallbackResult) => void + /** 上传完成回调 */ + onComplete?: () => void +} + +/** + * 执行文件上传 + * @template T 上传成功后返回的数据类型 + * @param options 上传选项 + */ +function uploadFile({ + url, + tempFilePath, + formData, + data, + error, + loading, + progress, + onProgress, + onSuccess, + onError, + onComplete, +}: UploadFileOptions) { + try { + // 创建上传任务 + const uploadTask = uni.uploadFile({ + url, + filePath: tempFilePath, + name: 'file', // 文件对应的 key + formData, + header: { + // H5环境下不需要手动设置Content-Type,让浏览器自动处理multipart格式 + // #ifndef H5 + 'Content-Type': 'multipart/form-data', + // #endif + }, + // 确保文件名称合法 + success: (uploadFileRes) => { + console.log('上传文件成功:', uploadFileRes) + try { + // 解析响应数据 + const { data: _data } = JSON.parse(uploadFileRes.data) + // 上传成功 + data.value = _data as T + onSuccess?.(_data) + } + catch (err) { + // 响应解析错误 + console.error('解析上传响应失败:', err) + error.value = true + onError?.(new Error('上传响应解析失败')) + } + }, + fail: (err) => { + // 上传请求失败 + console.error('上传文件失败:', err) + error.value = true + onError?.(err) + }, + complete: () => { + // 无论成功失败都执行 + loading.value = false + onComplete?.() + }, + }) + + // 监听上传进度 + uploadTask.onProgressUpdate((res) => { + progress.value = res.progress + onProgress?.(res.progress) + }) + } + catch (err) { + // 创建上传任务失败 + console.error('创建上传任务失败:', err) + error.value = true + loading.value = false + onError?.(new Error('创建上传任务失败')) + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..62afeed --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,57 @@ +{ + "compilerOptions": { + "composite": true, + "lib": [ + "esnext", + "dom" + ], + "baseUrl": ".", + "module": "esnext", + "moduleResolution": "bundler", + "paths": { + "@/*": [ + "./src/*" + ], + "@img/*": [ + "./src/static/*" + ] + }, + "resolveJsonModule": true, + "types": [ + "@dcloudio/types", + "@uni-helper/uni-types", + "@uni-helper/vite-plugin-uni-pages", + "miniprogram-api-typings", + "z-paging/types", + "./src/types/async-component.d.ts", + "./src/types/async-import.d.ts", + "./src/typings.d.ts", + "wot-design-uni/global.d.ts" + ], + "allowJs": true, + "noImplicitThis": true, + "outDir": "dist", + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true + }, + "vueCompilerOptions": { + "plugins": [ + "@uni-helper/uni-types/volar-plugin" + ] + }, + "include": [ + "package.json", + "src/**/*.ts", + "src/**/*.js", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.jsx", + "src/**/*.vue", + "src/**/*.json" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/uno.config.ts b/uno.config.ts new file mode 100644 index 0000000..7a2f94c --- /dev/null +++ b/uno.config.ts @@ -0,0 +1,119 @@ +import type { + Preset, +} from 'unocss' +import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders' + +// https://www.npmjs.com/package/@uni-helper/unocss-preset-uni +import { presetUni } from '@uni-helper/unocss-preset-uni' +// @see https://unocss.dev/presets/legacy-compat +import { presetLegacyCompat } from '@unocss/preset-legacy-compat' +import { + defineConfig, + presetIcons, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetUni({ + attributify: false, + }), + presetIcons({ + scale: 1.2, + warn: true, + extraProperties: { + 'display': 'inline-block', + 'vertical-align': 'middle', + }, + collections: { + // 注册本地 SVG 图标集合, 从本地文件系统加载图标 + // 在 './src/static/my-icons' 目录下的所有 svg 文件将被注册为图标, + // my-icons 是图标集合名称,使用 `i-my-icons-图标名` 调用 + 'my-icons': FileSystemIconLoader( + './src/static/my-icons', + // 可选的,你可以提供一个 transform 回调来更改每个图标 + (svg) => { + let svgStr = svg + + // 如果 SVG 文件未定义 `fill` 属性,则默认填充 `currentColor`, 这样图标颜色会继承文本颜色,方便在不同场景下适配 + svgStr = svgStr.includes('fill="') ? svgStr : svgStr.replace(/^ `rgb(255, 0, 0)` + // `rgba(255 0 0 / 0.5)` -> `rgba(255, 0, 0, 0.5)` + presetLegacyCompat({ + commaStyleColorFunction: true, + legacyColorSpace: true, // by QQ4群-量子蔷薇 + // @菲鸽 unocss 配置中,建议在 presetLegacyCompat 中添加 legacyColorSpace: true,以去除生成的颜色样式中的 in oklch 关键字,现在发现有些渐变色生成不符合预期 + }) as Preset, + ], + transformers: [ + // 启用指令功能:主要用于支持 @apply、@screen 和 theme() 等 CSS 指令 + transformerDirectives(), + // 启用 () 分组功能 + // 支持css class组合,eg: `
测试 unocss
` + transformerVariantGroup(), + ], + shortcuts: [ + { + center: 'flex justify-center items-center', + }, + ], + // 动态图标需要在这里配置,或者写在vue页面中注释掉 + safelist: ['i-carbon-code', 'i-carbon-home', 'i-carbon-user'], + rules: [ + [ + 'p-safe', + { + padding: + 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)', + }, + ], + ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }], + ['pb-safe', { 'padding-bottom': 'env(safe-area-inset-bottom)' }], + ], + theme: { + colors: { + /** 主题色,用法如: text-primary */ + primary: 'var(--wot-color-theme,#0957DE)', + }, + fontSize: { + /** 提供更小号的字体,用法如:text-2xs */ + '2xs': ['20rpx', '28rpx'], + '3xs': ['18rpx', '26rpx'], + }, + }, + // windows 系统会报错:[plugin:unocss:transformers:pre] Cannot overwrite a zero-length range - use append Left or prependRight instead. + // 去掉下面的就正常了 + // content: { + // /** + // * 解决小程序报错 `./app.wxss(78:2814): unexpected unexpected at pos 5198` + // * 为什么同时使用include和exclude?虽然看起来多余,但同时配置两者是一种常见的 `防御性编程` 做法。 + // 1. 结构变化保障 : 如果未来项目结构发生变化,某些排除目录可能被移动到包含路径下,exclude配置可以确保它们仍被排除 + // 2. 明确性 : 明确列出要排除的目录使配置意图更加清晰 + // 3. 性能优化 : 避免处理不必要的文件,提高构建性能 + // 4. 防止冲突 : 排除第三方库和构建输出目录,避免潜在的CSS冲突 + // */ + // pipeline: { + // exclude: [ + // 'node_modules/**/*', + // 'public/**/*', + // 'dist/**/*', + // ], + // include: [ + // './src/**/*', + // ], + // }, + // }, +}) diff --git a/vite-plugins/README.md b/vite-plugins/README.md new file mode 100644 index 0000000..956a73e --- /dev/null +++ b/vite-plugins/README.md @@ -0,0 +1,236 @@ +# unibest原生插件资源复制插件 + +## 概述 + +`copy-native-resources.ts` 是一个专为 基于unibest框架的UniApp 项目设计的 Vite 插件,用于解决使用原生插件时打包后出现"插件找不到"的问题。该插件会在构建过程中自动将本地原生插件资源复制到正确的目标目录。 + +## 功能特性 + +- ✅ 自动复制原生插件资源到构建目录 +- ✅ 支持环境变量控制插件启用/禁用 +- ✅ 支持详细日志输出用于调试 +- ✅ 智能检测源目录是否存在 + +## 目录结构 + +根据 [UniApp 官方文档](https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6),本地原生插件应存储在项目根目录的 `nativeplugins` 目录下: + +``` +项目根目录/ +├── nativeplugins/ # 原生插件存储目录(官方规范) +│ ├── HL-HHWUHFController/ # 示例:RFID 控制器插件 +│ │ ├── android/ # Android 平台资源 +│ │ │ ├── libs/ # Android 库文件 +│ │ │ └── res/ # Android 资源文件 +│ │ ├── ios/ # iOS 平台资源(如果有) +│ │ └── package.json # 插件配置文件 +│ └── 其他原生插件/ +├── src/ +├── vite-plugins/ +│ ├── copy-native-resources.ts # 本插件文件 +│ └── README.md # 本文档 +└── vite.config.ts +``` + +## 安装配置 + +### 1. 环境变量配置 + +在 `env/.env` 文件中添加以下配置: + +```bash +# 是否启用原生插件资源复制 +VITE_COPY_NATIVE_RES_ENABLE = true +``` + +### 2. Vite 配置 + +在 `vite.config.ts` 中引入并使用插件: + +```typescript +import { createCopyNativeResourcesPlugin } from './vite-plugins/copy-native-resources' + +export default defineConfig({ + plugins: [ + // 其他插件... + + // 原生插件资源复制插件 + createCopyNativeResourcesPlugin( + UNI_PLATFORM === 'app' && VITE_COPY_NATIVE_RES_ENABLE === 'true', + { + verbose: mode === 'development', // 开发模式显示详细日志 + }, + ), + + // 其他插件... + ], +}) +``` + +### 3. manifest.config.ts 配置 + +在 `manifest.config.ts` 中配置原生插件: + +```typescript +export default defineManifest({ + // 其他配置... + + 'app-plus': { + // 其他配置... + + // 原生插件配置 + nativePlugins: { + // RFID 控制器插件示例 + 'HL-HHWUHFController': { + __plugin_info__: { + name: 'HL-HHWUHFController', + description: 'RFID UHF 控制器插件', + platforms: 'Android', + url: '', + android_package_name: '', + ios_bundle_id: '', + isCloud: false, + bought: -1, + pid: '', + parameters: {} + } + } + } + } +}) +``` + +## 插件配置选项 + +```typescript +interface CopyNativeResourcesOptions { + /** 是否启用插件 */ + enable?: boolean + + /** + * 源目录路径,相对于项目根目录 + * 默认为 'nativeplugins',符合 UniApp 官方规范 + */ + sourceDir?: string + + /** + * 目标目录名称,构建后在 dist 目录中的文件夹名 + * 默认为 'nativeplugins',与源目录保持一致 + */ + targetDirName?: string + + /** 是否显示详细日志 */ + verbose?: boolean +} +``` + +## 使用示例 + +### 基础使用 + +```typescript +// 使用默认配置 +createCopyNativeResourcesPlugin(true) +``` + +### 自定义配置 + +```typescript +// 自定义配置 +createCopyNativeResourcesPlugin(true, { + sourceDir: 'nativeplugins', // 源目录 + targetDirName: 'nativeplugins', // 目标目录名 + verbose: true // 显示详细日志 +}) +``` + +### 条件启用 + +```typescript +// 仅在 app 平台且环境变量启用时生效 +createCopyNativeResourcesPlugin( + UNI_PLATFORM === 'app' && VITE_COPY_NATIVE_RES_ENABLE === 'true', + { verbose: mode === 'development' } +) +``` + +## 工作原理 + +1. **构建时机**:插件在 Vite 的 `writeBundle` 阶段执行 +2. **目录检测**:检查源目录 `nativeplugins` 是否存在 +3. **资源复制**:将整个 `nativeplugins` 目录复制到构建输出目录 +4. **路径处理**:自动处理不同平台的路径差异 +5. **日志输出**:根据配置显示复制过程的详细信息 + +## 构建输出结构 + +插件会将原生插件资源复制到以下位置: + +``` +dist/ +├── build/ +│ └── app/ +│ └── nativeplugins/ # 生产环境构建 +│ └── HL-HHWUHFController/ +└── dev/ + └── app/ + └── nativeplugins/ # 开发环境构建 + └── HL-HHWUHFController/ +``` + +## 常见问题 + +### Q: 为什么要使用这个插件? + +A: 目前使用unibest框架在打包时可能不会自动复制原生插件资源,导致运行时出现"插件找不到"的错误。此插件确保原生插件资源被正确复制到构建目录。 + +### Q: 插件不生效怎么办? + +A: 检查以下几点: +1. 确认 `nativeplugins` 目录存在且包含插件文件 +2. 确认环境变量 `VITE_COPY_NATIVE_RES_ENABLE` 设置为 `true` +3. 确认当前平台为 `app`(插件仅在 app 平台生效) +4. 开启 `verbose: true` 查看详细日志 + +### Q: 可以自定义源目录吗? + +A: 可以,但不推荐。UniApp 官方规范要求使用 `nativeplugins` 目录,自定义可能导致其他问题。 + +### Q: 支持哪些平台? + +A: 插件本身支持所有平台,但通常只在 `app` 平台(目前只测试了Android环境,iOS有条件的伙伴可以测试后反馈)使用原生插件。 + + +### Q: 产生权限冲突问题? + +A: 有伙伴反馈过接入的原生插件之前使用【Lastly1999】提交的版本初步解决了问题,但是又遇到两个新的问题: +- 导入的两个插件内的权限配置有版本冲突,在云打包的最后一步会报错,然后通过修改其中一个aar配置版本解决的。 +- 测试发现在android版本大于12的手机,获取相册权限后,打开相册看不到里面的照片,将两个插件删除就没问题 ,可以正常显示,不删除就会有问题,怀疑是插件的AndroidManifest.xml覆盖了项目内manifest.config.ts的安卓权限申请 +也欢迎其他有伙伴反馈,望能一起解决。 + +## 更新日志 + +### v1.0.0 +- 初始版本发布 +- 支持基础的原生插件资源复制功能 + +### v1.1.0 +- 更新为符合 UniApp 官方规范的 `nativeplugins` 目录结构 +- 修复 ESLint 警告 +- 增加详细的代码注释和文档 +- 优化错误处理和日志输出 + +## 技术支持 + +如果在使用过程中遇到问题,请检查: + +1. UniApp 官方文档:[本地插件配置](https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6) +2. 插件配置是否正确 +3. 目录结构是否符合规范 +4. 环境变量是否正确设置 + +## 特别声明及感谢 + +- 感谢【Lastly1999】,此插件时基于他pr的代码进行的还原和修改。[fix: app-plus、dev/prod、nativeResources插件未被正确移](https://gitee.com/feige996/unibest/commit/22e0bd5cfb47a4927373fe88be6809216f43d046) +- 感谢【菲鸽】造了这么好用的框架 + diff --git a/vite-plugins/copy-native-resources.ts b/vite-plugins/copy-native-resources.ts new file mode 100644 index 0000000..77c1b1f --- /dev/null +++ b/vite-plugins/copy-native-resources.ts @@ -0,0 +1,201 @@ +import type { Plugin } from 'vite' +import path from 'node:path' +import process from 'node:process' +import fs from 'fs-extra' + +/** + * 原生插件资源复制配置接口 + * + * 根据 UniApp 官方文档:https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6 + * 本地插件应该存储在项目根目录的 nativeplugins 目录下 + */ +export interface CopyNativeResourcesOptions { + /** 是否启用插件 */ + enable?: boolean + /** + * 源目录路径,相对于项目根目录 + * 默认为 'nativeplugins',符合 UniApp 官方规范 + * @see https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6 + */ + sourceDir?: string + /** + * 目标目录名称,构建后在 dist 目录中的文件夹名 + * 默认为 'nativeplugins',与源目录保持一致 + */ + targetDirName?: string + /** 是否显示详细日志,便于调试和监控复制过程 */ + verbose?: boolean + /** 自定义日志前缀,用于区分不同插件的日志输出 */ + logPrefix?: string +} + +/** + * 默认配置 + * + * 根据 UniApp 官方文档规范设置默认值: + * - sourceDir: 'nativeplugins' - 符合官方本地插件存储规范 + * - targetDirName: 'nativeplugins' - 构建后保持相同的目录结构 + */ +const DEFAULT_OPTIONS: Required = { + enable: true, + sourceDir: 'nativeplugins', + targetDirName: 'nativeplugins', + verbose: true, + logPrefix: '[copy-native-resources]', +} + +/** + * UniApp 原生插件资源复制插件 + * + * 功能说明: + * 1. 解决 UniApp 使用本地原生插件时,打包后原生插件资源找不到的问题 + * 2. 将项目根目录下的 nativeplugins 目录复制到构建输出目录中 + * 3. 支持 Android 和 iOS 平台的原生插件资源复制 + * 4. 仅在 app 平台构建时生效,其他平台(H5、小程序)不执行 + * + * 使用场景: + * - 使用了 UniApp 本地原生插件(非云端插件) + * - 原生插件包含额外的资源文件(如 .so 库文件、配置文件等) + * - 需要在打包后保持原生插件的完整目录结构 + * + * 官方文档参考: + * @see https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6 + * @see https://uniapp.dcloud.net.cn/tutorial/nvue-api.html#dom + * + * @param options 插件配置选项 + * @returns Vite 插件对象 + */ +export function copyNativeResources(options: CopyNativeResourcesOptions = {}): Plugin { + const config = { ...DEFAULT_OPTIONS, ...options } + + // 如果插件被禁用,返回一个空插件 + if (!config.enable) { + return { + name: 'copy-native-resources-disabled', + apply: 'build', + writeBundle() { + // 插件已禁用,不执行任何操作 + }, + } + } + + return { + name: 'copy-native-resources', + apply: 'build', // 只在构建时应用 + enforce: 'post', // 在其他插件执行完毕后执行 + + async writeBundle() { + const { sourceDir, targetDirName, verbose, logPrefix } = config + + try { + // 获取项目根目录路径 + const projectRoot = process.cwd() + + // 构建源目录绝对路径(项目根目录下的 nativeplugins 目录) + const sourcePath = path.resolve(projectRoot, sourceDir) + + // 构建目标路径:dist/[build|dev]/[platform]/nativeplugins + // buildMode: 'build' (生产环境) 或 'dev' (开发环境) + // platform: 'app' (App平台) 或其他平台标识 + const buildMode = process.env.NODE_ENV === 'production' ? 'build' : 'dev' + const platform = process.env.UNI_PLATFORM || 'app' + const targetPath = path.resolve( + projectRoot, + 'dist', + buildMode, + platform, + targetDirName, + ) + + // 检查源目录是否存在 + // 如果不存在 nativeplugins 目录,说明项目没有使用本地原生插件 + const sourceExists = await fs.pathExists(sourcePath) + if (!sourceExists) { + if (verbose) { + console.warn(`${logPrefix} 源目录不存在,跳过复制操作`) + console.warn(`${logPrefix} 源目录路径: ${sourcePath}`) + console.warn(`${logPrefix} 如需使用本地原生插件,请在项目根目录创建 nativeplugins 目录`) + console.warn(`${logPrefix} 并按照官方文档放入原生插件文件`) + console.warn(`${logPrefix} 参考: https://uniapp.dcloud.net.cn/plugin/native-plugin.html`) + } + return + } + + // 检查源目录是否为空 + // 如果目录存在但为空,也跳过复制操作 + const sourceFiles = await fs.readdir(sourcePath) + if (sourceFiles.length === 0) { + if (verbose) { + console.warn(`${logPrefix} 源目录为空,跳过复制操作`) + console.warn(`${logPrefix} 源目录路径: ${sourcePath}`) + console.warn(`${logPrefix} 请在 nativeplugins 目录中放入原生插件文件`) + } + return + } + + // 确保目标目录及其父目录存在 + await fs.ensureDir(targetPath) + + if (verbose) { + console.log(`${logPrefix} 开始复制 UniApp 本地原生插件...`) + console.log(`${logPrefix} 源目录: ${sourcePath}`) + console.log(`${logPrefix} 目标目录: ${targetPath}`) + console.log(`${logPrefix} 构建模式: ${buildMode}`) + console.log(`${logPrefix} 目标平台: ${platform}`) + console.log(`${logPrefix} 发现 ${sourceFiles.length} 个原生插件文件/目录`) + } + + // 执行文件复制操作 + // 将整个 nativeplugins 目录复制到构建输出目录 + await fs.copy(sourcePath, targetPath, { + overwrite: true, // 覆盖已存在的文件,确保使用最新版本 + errorOnExist: false, // 如果目标文件存在不报错 + preserveTimestamps: true, // 保持文件的时间戳 + }) + + if (verbose) { + console.log(`${logPrefix} ✅ UniApp 本地原生插件复制完成`) + console.log(`${logPrefix} 已成功复制 ${sourceFiles.length} 个文件/目录到构建目录`) + console.log(`${logPrefix} 原生插件现在可以在 App 中正常使用`) + } + } + catch (error) { + console.error(`${config.logPrefix} ❌ 复制 UniApp 本地原生插件失败:`, error) + console.error(`${config.logPrefix} 错误详情:`, error instanceof Error ? error.message : String(error)) + console.error(`${config.logPrefix} 请检查源目录权限和磁盘空间`) + // 不抛出错误,避免影响整个构建过程,但会记录详细的错误信息 + } + }, + } +} + +/** + * 创建 UniApp 本地原生插件资源复制插件的便捷函数 + * + * 这是一个便捷的工厂函数,用于快速创建插件实例 + * 特别适用于在 vite.config.ts 中进行条件性插件配置 + * + * 使用示例: + * ```typescript + * // 在 vite.config.ts 中 + * plugins: [ + * // 仅在 app 平台且启用时生效 + * UNI_PLATFORM === 'app' + * ? createCopyNativeResourcesPlugin( + * VITE_COPY_NATIVE_RES_ENABLE === 'true', + * { verbose: mode === 'development' } + * ) + * : null, + * ] + * ``` + * + * @param enable 是否启用插件,通常通过环境变量控制 + * @param options 其他配置选项,不包含 enable 属性 + * @returns Vite 插件对象 + */ +export function createCopyNativeResourcesPlugin( + enable: boolean = true, + options: Omit = {}, +): Plugin { + return copyNativeResources({ enable, ...options }) +} diff --git a/vite-plugins/sync-manifest-plugins.ts b/vite-plugins/sync-manifest-plugins.ts new file mode 100644 index 0000000..4f5b273 --- /dev/null +++ b/vite-plugins/sync-manifest-plugins.ts @@ -0,0 +1,68 @@ +import type { Plugin } from 'vite' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +interface ManifestType { + 'plus'?: { + distribute?: { + plugins?: Record + } + } + 'app-plus'?: { + distribute?: { + plugins?: Record + } + } +} + +export default function syncManifestPlugin(): Plugin { + return { + name: 'sync-manifest', + apply: 'build', + enforce: 'post', + writeBundle: { + order: 'post', + handler() { + const srcManifestPath = path.resolve(process.cwd(), './src/manifest.json') + const distAppPath = path.resolve(process.cwd(), './dist/dev/app/manifest.json') + + try { + // 读取源文件 + const srcManifest = JSON.parse(fs.readFileSync(srcManifestPath, 'utf8')) as ManifestType + + // 确保目标目录存在 + const distAppDir = path.dirname(distAppPath) + if (!fs.existsSync(distAppDir)) { + fs.mkdirSync(distAppDir, { recursive: true }) + } + + // 读取目标文件(如果存在) + let distManifest: ManifestType = {} + if (fs.existsSync(distAppPath)) { + distManifest = JSON.parse(fs.readFileSync(distAppPath, 'utf8')) + } + + // 如果源文件存在 plugins + if (srcManifest['app-plus']?.distribute?.plugins) { + // 确保目标文件中有必要的对象结构 + if (!distManifest.plus) + distManifest.plus = {} + if (!distManifest.plus.distribute) + distManifest.plus.distribute = {} + + // 复制 plugins 内容 + distManifest.plus.distribute.plugins = srcManifest['app-plus'].distribute.plugins + + // 写入更新后的内容 + fs.writeFileSync(distAppPath, JSON.stringify(distManifest, null, 2)) + console.log('✅ Manifest plugins 同步成功') + } + } + catch (error) { + console.error('❌ 同步 manifest plugins 失败:', error) + } + }, + }, + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7a8a2a9 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,199 @@ +import path from 'node:path' +import process from 'node:process' +import Uni from '@uni-helper/plugin-uni' +import Components from '@uni-helper/vite-plugin-uni-components' +// @see https://uni-helper.js.org/vite-plugin-uni-layouts +import UniLayouts from '@uni-helper/vite-plugin-uni-layouts' +// @see https://github.com/uni-helper/vite-plugin-uni-manifest +import UniManifest from '@uni-helper/vite-plugin-uni-manifest' +// @see https://uni-helper.js.org/vite-plugin-uni-pages +import UniPages from '@uni-helper/vite-plugin-uni-pages' +// @see https://github.com/uni-helper/vite-plugin-uni-platform +// 需要与 @uni-helper/vite-plugin-uni-pages 插件一起使用 +import UniPlatform from '@uni-helper/vite-plugin-uni-platform' +/** + * 分包优化、模块异步跨包调用、组件异步跨包引用 + * @see https://github.com/uni-ku/bundle-optimizer + */ +import Optimization from '@uni-ku/bundle-optimizer' +// https://github.com/uni-ku/root +import UniKuRoot from '@uni-ku/root' +import dayjs from 'dayjs' +import { visualizer } from 'rollup-plugin-visualizer' +import UnoCSS from 'unocss/vite' +import AutoImport from 'unplugin-auto-import/vite' +import { defineConfig, loadEnv } from 'vite' +import ViteRestart from 'vite-plugin-restart' +import openDevTools from './scripts/open-dev-tools' +import { createCopyNativeResourcesPlugin } from './vite-plugins/copy-native-resources' +import syncManifestPlugin from './vite-plugins/sync-manifest-plugins' + +// https://vitejs.dev/config/ +export default defineConfig(({ command, mode }) => { + // @see https://unocss.dev/ + // const UnoCSS = (await import('unocss/vite')).default + // console.log(mode === process.env.NODE_ENV) // true + + // mode: 区分生产环境还是开发环境 + console.log('command, mode -> ', command, mode) + // pnpm dev:h5 时得到 => serve development + // pnpm build:h5 时得到 => build production + // pnpm dev:mp-weixin 时得到 => build development (注意区别,command为build) + // pnpm build:mp-weixin 时得到 => build production + // pnpm dev:app 时得到 => build development (注意区别,command为build) + // pnpm build:app 时得到 => build production + // dev 和 build 命令可以分别使用 .env.development 和 .env.production 的环境变量 + + const { UNI_PLATFORM } = process.env + console.log('UNI_PLATFORM -> ', UNI_PLATFORM) // 得到 mp-weixin, h5, app 等 + + const env = loadEnv(mode, path.resolve(process.cwd(), 'env')) + const { + VITE_APP_PORT, + VITE_SERVER_BASEURL, + VITE_APP_TITLE, + VITE_DELETE_CONSOLE, + VITE_APP_PUBLIC_BASE, + VITE_APP_PROXY_ENABLE, + VITE_APP_PROXY_PREFIX, + VITE_COPY_NATIVE_RES_ENABLE, + } = env + console.log('环境变量 env -> ', env) + + return defineConfig({ + envDir: './env', // 自定义env目录 + base: VITE_APP_PUBLIC_BASE, + plugins: [ + UniLayouts(), + UniPlatform(), + UniManifest(), + UniPages({ + exclude: ['**/components/**/**.*'], + // pages 目录为 src/pages,分包目录不能配置在pages目录下!! + // 是个数组,可以配置多个,但是不能为pages里面的目录!! + subPackages: [], + dts: 'src/types/uni-pages.d.ts', + }), + // Optimization 插件需要 page.json 文件,故应在 UniPages 插件之后执行 + Optimization({ + enable: { + 'optimization': true, + 'async-import': true, + 'async-component': true, + }, + dts: { + base: 'src/types', + }, + logger: false, + }), + // UniXXX 需要在 Uni 之前引入 + // 若存在改变 pages.json 的插件,请将 UniKuRoot 放置其后 + UniKuRoot({ + excludePages: ['**/components/**/**.*'], + }), + Uni(), + { + // 临时解决 dcloudio 官方的 @dcloudio/uni-mp-compiler 出现的编译 BUG + // 参考 github issue: https://github.com/dcloudio/uni-app/issues/4952 + // 自定义插件禁用 vite:vue 插件的 devToolsEnabled,强制编译 vue 模板时 inline 为 true + name: 'fix-vite-plugin-vue', + configResolved(config) { + const plugin = config.plugins.find(p => p.name === 'vite:vue') + if (plugin && plugin.api && plugin.api.options) { + plugin.api.options.devToolsEnabled = false + } + }, + }, + UnoCSS(), + AutoImport({ + imports: ['vue', 'uni-app'], + dts: 'src/types/auto-import.d.ts', + dirs: ['src/hooks'], // 自动导入 hooks + vueTemplate: true, // default false + }), + ViteRestart({ + // 通过这个插件,在修改vite.config.js文件则不需要重新运行也生效配置 + restart: ['vite.config.js'], + }), + // h5环境增加 BUILD_TIME 和 BUILD_BRANCH + UNI_PLATFORM === 'h5' && { + name: 'html-transform', + transformIndexHtml(html) { + return html.replace('%BUILD_TIME%', dayjs().format('YYYY-MM-DD HH:mm:ss')).replace('%VITE_APP_TITLE%', VITE_APP_TITLE) + }, + }, + // 打包分析插件,h5 + 生产环境才弹出 + UNI_PLATFORM === 'h5' + && mode === 'production' + && visualizer({ + filename: './node_modules/.cache/visualizer/stats.html', + open: true, + gzipSize: true, + brotliSize: true, + }), + // 原生插件资源复制插件 - 仅在 app 平台且启用时生效 + createCopyNativeResourcesPlugin( + UNI_PLATFORM === 'app' && VITE_COPY_NATIVE_RES_ENABLE === 'true', + { + verbose: mode === 'development', // 开发模式显示详细日志 + }, + ), + syncManifestPlugin(), + Components({ + extensions: ['vue'], + deep: true, // 是否递归扫描子目录, + directoryAsNamespace: false, // 是否把目录名作为命名空间前缀,true 时组件名为 目录名+组件名, + dts: 'src/types/components.d.ts', // 自动生成的组件类型声明文件路径(用于 TypeScript 支持) + }), + // 自动打开开发者工具插件 (必须修改 .env 文件中的 VITE_WX_APPID) + openDevTools(), + ], + define: { + __VITE_APP_PROXY__: JSON.stringify(VITE_APP_PROXY_ENABLE), + }, + css: { + postcss: { + plugins: [ + // autoprefixer({ + // // 指定目标浏览器 + // overrideBrowserslist: ['> 1%', 'last 2 versions'], + // }), + ], + }, + }, + + resolve: { + alias: { + '@': path.join(process.cwd(), './src'), + '@img': path.join(process.cwd(), './src/static/images'), + }, + }, + server: { + host: '0.0.0.0', + hmr: true, + port: Number.parseInt(VITE_APP_PORT, 10), + // 仅 H5 端生效,其他端不生效(其他端走build,不走devServer) + proxy: JSON.parse(VITE_APP_PROXY_ENABLE) + ? { + [VITE_APP_PROXY_PREFIX]: { + target: VITE_SERVER_BASEURL, + changeOrigin: true, + // 后端有/api前缀则不做处理,没有则需要去掉 + rewrite: path => path.replace(new RegExp(`^${VITE_APP_PROXY_PREFIX}`), ''), + }, + } + : undefined, + }, + esbuild: { + drop: VITE_DELETE_CONSOLE === 'true' ? ['console', 'debugger'] : [], + }, + build: { + sourcemap: false, + // 方便非h5端调试 + // sourcemap: VITE_SHOW_SOURCEMAP === 'true', // 默认是false + target: 'es6', + // 开发环境不用压缩 + minify: mode === 'development' ? false : 'esbuild', + }, + }) +})