feat: 创建项目,首次提交

master
chris 6 days ago
commit 8b2663daa2

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

@ -0,0 +1,51 @@
# API 和 HTTP 请求规范
## HTTP 请求封装
- 可以使用 `简单http` 或者 `alova` 或者 `@tanstack/vue-query` 进行请求管理
- HTTP 配置在 [src/http/](mdc:src/http/) 目录下
- `简单http` - [src/http/http.ts](mdc:src/http/http.ts)
- `alova` - [src/http/alova.ts](mdc:src/http/alova.ts)
- `vue-query` - [src/http/vue-query.ts](mdc:src/http/vue-query.ts)
- 请求拦截器在 [src/http/interceptor.ts](mdc:src/http/interceptor.ts)
- 支持请求重试、缓存、错误处理
## API 接口规范
- API 接口定义在 [src/api/](mdc:src/api/) 目录下
- 按功能模块组织 API 文件
- 使用 TypeScript 定义请求和响应类型
- 支持 `简单http`、`alova` 和 `vue-query` 三种请求方式
## 示例代码结构
```typescript
// API 接口定义
export interface LoginParams {
username: string
password: string
}
export interface LoginResponse {
token: string
userInfo: UserInfo
}
// alova 方式
export const login = (params: LoginParams) =>
http.Post<LoginResponse>('/api/login', params)
// vue-query 方式
export const useLogin = () => {
return useMutation({
mutationFn: (params: LoginParams) =>
http.post<LoginResponse>('/api/login', params)
})
}
```
## 错误处理
- 统一错误处理在拦截器中配置
- 支持网络错误、业务错误、认证错误等
- 自动处理 token 过期和刷新
---
globs: src/api/*.ts,src/http/*.ts
---

@ -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: 开发工作流程和最佳实践指南
---

@ -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` - 构建生产版本

@ -0,0 +1,54 @@
# 样式和 CSS 开发规范
## UnoCSS 原子化 CSS
- 项目使用 UnoCSS 作为原子化 CSS 框架
- 配置在 [uno.config.ts](mdc:uno.config.ts)
- 支持预设和自定义规则
- 优先使用原子化类名,减少自定义 CSS
## SCSS 规范
- 使用 SCSS 预处理器
- 样式文件使用 `lang="scss"` 和 `scoped` 属性
- 遵循 BEM 命名规范
- 使用变量和混入提高复用性
## 样式组织
- 全局样式在 [src/style/](mdc:src/style/) 目录下
- 组件样式使用 scoped 作用域
- 图标字体在 [src/style/iconfont.css](mdc:src/style/iconfont.css)
- 主题变量在 [src/uni_modules/uni-scss/](mdc:src/uni_modules/uni-scss/) 目录下
## 示例代码结构
```vue
<template>
<view class="container flex flex-col items-center p-4">
<text class="title text-lg font-bold mb-2">标题</text>
<view class="content bg-gray-100 rounded-lg p-3">
<!-- 内容 -->
</view>
</view>
</template>
<style lang="scss" scoped>
.container {
min-height: 100vh;
.title {
color: var(--primary-color);
}
.content {
width: 100%;
max-width: 600rpx;
}
}
</style>
## 响应式设计
- 使用 rpx 单位适配不同屏幕
- 支持横屏和竖屏布局
- 使用 flexbox 和 grid 布局
- 考虑不同平台的样式差异
---
globs: *.vue,*.scss,*.css
---

@ -0,0 +1,62 @@
# uni-app 开发规范
## 页面开发
- 页面文件放在 [src/pages/](mdc:src/pages/) 目录下
- 使用约定式路由,文件名即路由路径
- 页面配置在仅需要在 宏`definePage` 中配置标题等内容即可,会自动生成到 `pages.json` 中
## 组件开发
- 组件文件放在 [src/components/](mdc:src/components/) 或者 [src/pages/xx/components/](mdc:src/pages/xx/components/) 目录下
- 使用 uni-app 内置组件和第三方组件库
- 支持 wot-ui\uview-pro\uv-ui\sard-ui\uview-plus 等多种第三方组件库 和 z-paging 组件
- 自定义组件遵循 uni-app 组件规范
## 平台适配
- 使用条件编译处理平台差异
- 支持 H5、小程序、APP 多平台
- 注意各平台的 API 差异
- 使用 uni.xxx API 替代原生 API
## 示例代码结构
```vue
<script setup lang="ts">
// #ifdef H5
import { h5Api } from '@/utils/h5'
// #endif
// #ifdef MP-WEIXIN
import { mpApi } from '@/utils/mp'
// #endif
const handleClick = () => {
// #ifdef H5
h5Api.showToast('H5 平台')
// #endif
// #ifdef MP-WEIXIN
mpApi.showToast('微信小程序')
// #endif
}
</script>
<template>
<view class="page">
<!-- uni-app 组件 -->
<button @click="handleClick">点击</button>
<!-- 条件渲染 -->
<!-- #ifdef H5 -->
<view>H5 特有内容</view>
<!-- #endif -->
</view>
</template>
```
## 生命周期
- 使用 uni-app 页面生命周期
- onLoad、onShow、onReady、onHide、onUnload
- 组件生命周期遵循 Vue3 规范
- 注意页面栈和导航管理
---
globs: src/pages/*.vue,src/components/*.vue
---

@ -0,0 +1,53 @@
# Vue3 + TypeScript 开发规范
## Vue 组件规范
- 使用 Composition API 和 `<script setup>` 语法
- 组件文件使用 PascalCase 命名
- 页面文件放在 `src/pages/` 目录下
- 全局组件文件放在 `src/components/` 目录下
- 局部组件文件放在页面的 `/components/` 目录下
## Vue SFC 组件规范
- `<script setup lang="ts">` 标签必须是第一个子元素
- `<template>` 标签必须是第二个子元素
- `<style scoped>` 标签必须是最后一个子元素(因为推荐使用原子化类名,所以很可能没有)
## TypeScript 规范
- 严格使用 TypeScript避免使用 `any` 类型
- 为 API 响应数据定义接口类型
- 使用 `interface` 定义对象类型,`type` 定义联合类型
- 导入类型时使用 `import type` 语法
## 状态管理
- 使用 Pinia 进行状态管理
- Store 文件放在 `src/store/` 目录下
- 使用 `defineStore` 定义 store
- 支持持久化存储
## 示例代码结构
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { UserInfo } from '@/types/user'
const userInfo = ref<UserInfo | null>(null)
onMounted(() => {
// 初始化逻辑
})
</script>
<template>
<view class="container">
<!-- 模板内容 -->
</view>
</template>
<style lang="scss" scoped>
.container {
// 样式
}
</style>
---
globs: *.vue,*.ts,*.tsx
---

@ -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 # 关闭末尾空格修剪

@ -0,0 +1,31 @@
categories:
- title: 🚀 新功能
labels: [feat, feature]
- title: 🛠️ 修复
labels: [fix, bugfix]
- title: 💅 样式
labels: [style]
- title: 📄 文档
labels: [docs]
- title: ⚡️ 性能
labels: [perf]
- title: 🧪 测试
labels: [test]
- title: ♻️ 重构
labels: [refactor]
- title: 📦 构建
labels: [build]
- title: 🚨 补丁
labels: [patch, hotfix]
- title: 🌐 发布
labels: [release, publish]
- title: 🔧 流程
labels: [ci, cd, workflow]
- title: ⚙️ 配置
labels: [config, chore]
- title: 📁 文件
labels: [file]
- title: 🎨 格式化
labels: [format]
- title: 🔀 其他
labels: [other, misc]

@ -0,0 +1,188 @@
name: Auto Merge Main to Other Branches
on:
push:
branches:
- main
workflow_dispatch: # 手动触发
jobs:
# merge-to-release:
# name: Merge main into release
# runs-on: ubuntu-latest
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
# token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
# - name: Merge main into release
# run: |
# git config user.name "GitHub Actions"
# git config user.email "actions@github.com"
# git checkout release
# git merge main --no-ff -m "Auto merge main into release"
# git push origin release
merge-to-i18n:
name: Merge main into i18n
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
- name: Merge main into i18n
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git checkout i18n
git merge main --no-ff -m "Auto merge main into i18n"
git push origin i18n
merge-to-base-sard-ui:
name: Merge main into base-sard-ui
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
- name: Merge main into base-sard-ui
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git checkout base-sard-ui
git merge main --no-ff -m "Auto merge main into base-sard-ui"
git push origin base-sard-ui
merge-to-base-uv-ui:
name: Merge main into base-uv-ui
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
- name: Merge main into base-uv-ui
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git checkout base-uv-ui
git merge main --no-ff -m "Auto merge main into base-uv-ui"
git push origin base-uv-ui
merge-to-base-uview-pro:
name: Merge main into base-uview-pro
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
- name: Merge main into base-uview-pro
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git checkout base-uview-pro
git merge main --no-ff -m "Auto merge main into base-uview-pro"
git push origin base-uview-pro
merge-to-base-uview-plus:
name: Merge main into base-uview-plus
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
- name: Merge main into base-uview-plus
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git checkout base-uview-plus
git merge main --no-ff -m "Auto merge main into base-uview-plus"
git push origin base-uview-plus
# merge-to-base-tm-ui:
# name: Merge main into base-tm-ui
# runs-on: ubuntu-latest
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
# token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
# - name: Merge main into base-tm-ui
# run: |
# git config user.name "GitHub Actions"
# git config user.email "actions@github.com"
# git checkout base-tm-ui
# git merge main --no-ff -m "Auto merge main into base-tm-ui"
# git push origin base-tm-ui
merge-to-base-skiyee-ui:
name: Merge main into base-skiyee-ui
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
- name: Merge main into base-skiyee-ui
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git checkout base-skiyee-ui
git merge main --no-ff -m "Auto merge main into base-skiyee-ui"
git push origin base-skiyee-ui
merge-to-main-v4:
name: Merge main into main-v4
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
- name: Merge main into main-v4
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git checkout main-v4
git merge main --no-ff -m "Auto merge main into main-v4"
git push origin main-v4
merge-to-i18n-v4:
name: Merge main into i18n-v4
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_TOKEN_AUTO_MERGE }}
- name: Merge main into i18n-v4
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git checkout i18n-v4
git merge main --no-ff -m "Auto merge main into i18n-v4"
git push origin i18n-v4

@ -0,0 +1,119 @@
name: Auto Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
pull-requests: read
issues: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install yq
run: sudo snap install yq
- name: Generate changelog
id: changelog
env:
CONFIG_FILE: .github/release.yml
run: |
# 解析配置文件
declare -A category_map
while IFS=";" read -r title labels; do
for label in $labels; do
category_map[$label]="$title"
done
done < <(yq -o=tsv '.categories[] | [.title, (.labels | join(" "))] | join(";")' $CONFIG_FILE)
# 获取版本范围
mapfile -t tags < <(git tag -l --sort=-version:refname)
current_tag=${tags[0]}
previous_tag=${tags[1]:-}
if [[ -z "$previous_tag" ]]; then
commit_range="$current_tag"
echo "首次发布版本: $current_tag"
else
commit_range="$previous_tag..$current_tag"
echo "版本范围: $commit_range"
fi
# 获取所有符合规范的提交
commits=$(git log --pretty=format:"%s|%h" "$commit_range")
# 生成分类日志
declare -A log_entries
while IFS="|" read -r subject hash; do
# type=$(echo "$subject" | cut -d':' -f1 | tr -d ' ')
type=$(echo "$subject" | sed -E 's/^([[:alnum:]]+)(\(.*\))?:.*/\1/' | tr -d ' ')
found=0
for label in "${!category_map[@]}"; do
if [[ "$type" == "$label" ]]; then
entry="- ${subject} (${hash:0:7})"
log_entries[${category_map[$label]}]+="$entry"$'\n'
found=1
break
fi
done
if [[ $found -eq 0 ]]; then
entry="- ${subject} (${hash:0:7})"
log_entries["其他"]+="$entry"$'\n'
fi
done <<< "$commits"
# 统计提交数量
commit_count=$(git log --oneline "$commit_range" | wc -l)
# 统计受影响的文件数量
file_count=$(git diff --name-only "$commit_range" | wc -l)
# 统计贡献者信息
contributor_stats=$(git shortlog -sn "$commit_range")
contributor_notes=""
while IFS= read -r line; do
commits=$(echo "$line" | awk '{print $1}')
name=$(echo "$line" | awk '{$1=""; print $0}' | sed 's/^ //')
contributor_notes+="- @${name} (${commits} commits)\n"
done <<< "$contributor_stats"
# 构建输出内容
release_notes="## 版本更新日志 ($current_tag)\n\n"
while IFS= read -r category; do
if [[ -n "${log_entries[$category]}" ]]; then
release_notes+="### $category\n${log_entries[$category]}\n"
fi
done < <(yq '.categories[].title' $CONFIG_FILE)
# 构建输出内容
release_notes="## 版本更新日志 ($current_tag)\n\n"
current_date=$(date +"%Y-%m-%d")
# 添加发布日期和下载统计信息
release_notes+=" ### 📅 发布日期: ${current_date}\n"
while IFS= read -r category; do
if [[ -n "${log_entries[$category]}" ]]; then
release_notes+="### $category\n${log_entries[$category]}\n"
fi
done < <(yq '.categories[].title' $CONFIG_FILE)
# 添加统计信息
release_notes+="### 📊 统计信息\n"
release_notes+="- 本次发布包含 ${commit_count} 个提交\n"
release_notes+="- 影响 ${file_count} 个文件\n\n"
# 添加贡献者信息
release_notes+="### 👥 贡献者\n"
release_notes+="感谢这些优秀的贡献者(按提交次数排序):\n"
release_notes+="${contributor_notes}\n"
release_notes+="---\n"
# 写入文件
echo -e "$release_notes" > changelog.md
echo "生成日志内容:"
cat changelog.md
- name: Create Release
uses: ncipollo/release-action@v1
with:
generateReleaseNotes: false
bodyFile: changelog.md
tag: ${{ github.ref_name }}

48
.gitignore vendored

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

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

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

@ -0,0 +1,8 @@
# 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

@ -0,0 +1,122 @@
# unibest 项目概览
这是一个基于 uniapp + Vue3 + TypeScript + Vite5 + UnoCSS 的跨平台开发框架。
## 项目特点
- 支持 H5、小程序、APP 多平台开发
- 使用最新的前端技术栈
- 内置约定式路由、layout布局、请求封装等功能
- 无需依赖 HBuilderX支持命令行开发
## 核心配置文件
- [package.json](mdc:package.json) - 项目依赖和脚本配置
- [vite.config.ts](mdc:vite.config.ts) - Vite 构建配置
- [pages.config.ts](mdc:pages.config.ts) - 页面路由配置
- [manifest.config.ts](mdc:manifest.config.ts) - 应用清单配置
- [uno.config.ts](mdc:uno.config.ts) - UnoCSS 配置
## 主要目录结构
- `src/pages/` - 页面文件
- `src/components/` - 组件文件
- `src/layouts/` - 布局文件
- `src/api/` - API 接口
- `src/http/` - HTTP 请求封装
- `src/store/` - 状态管理
- `src/tabbar/` - 底部导航栏
- `src/App.ku.vue` - 全局根组件(类似 App.vue 里面的 template作用
## 开发命令
- `pnpm dev` - 开发 H5 版本
- `pnpm dev:mp` - 开发微信小程序
- `pnpm dev:mp-alipay` - 开发支付宝小程序(含钉钉)
- `pnpm dev:app` - 开发 APP 版本
- `pnpm build` - 构建生产版本
## Vue 组件规范
- 使用 Composition API 和 `<script setup>` 语法
- 组件文件使用 PascalCase 命名
- 页面文件放在 `src/pages/` 目录下
- 全局组件文件放在 `src/components/` 目录下
- 局部组件文件放在页面的 `/components/` 目录下
## TypeScript 规范
- 严格使用 TypeScript避免使用 `any` 类型
- 为 API 响应数据定义接口类型
- 使用 `interface` 定义对象类型,`type` 定义联合类型
- 导入类型时使用 `import type` 语法
## 状态管理
- 使用 Pinia 进行状态管理
- Store 文件放在 `src/store/` 目录下
- 使用 `defineStore` 定义 store
- 支持持久化存储
## UnoCSS 原子化 CSS
- 项目使用 UnoCSS 作为原子化 CSS 框架
- 配置在 [uno.config.ts]
- 支持预设和自定义规则
- 优先使用原子化类名,减少自定义 CSS
## Vue SFC 组件规范
- `<script setup lang="ts">` 标签必须是第一个子元素
- `<template>` 标签必须是第二个子元素
- `<style scoped>` 标签必须是最后一个子元素(因为推荐使用原子化类名,所以很可能没有)
## 页面开发
- 页面文件放在 [src/pages/]目录下
- 使用约定式路由,文件名即路由路径
- 页面配置在仅需要在 宏`definePage` 中配置标题等内容即可,会自动生成到 `pages.json`
## 组件开发
- 全局组件文件放在 `src/components/` 目录下
- 局部组件文件放在页面的 `/components/` 目录下
- 使用 uni-app 内置组件和第三方组件库
- 支持 wot-ui\uview-pro\uv-ui\sard-ui\uview-plus 等多种第三方组件库 和 z-paging 组件
- 自定义组件遵循 uni-app 组件规范
## 平台适配
- 使用条件编译处理平台差异
- 支持 H5、小程序、APP 多平台
- 注意各平台的 API 差异
- 使用 uni.xxx API 替代原生 API
## 示例代码结构
```vue
<script setup lang="ts">
// #ifdef H5
import { h5Api } from '@/utils/h5'
// #endif
// #ifdef MP-WEIXIN
import { mpApi } from '@/utils/mp'
// #endif
const handleClick = () => {
// #ifdef H5
h5Api.showToast('H5 平台')
// #endif
// #ifdef MP-WEIXIN
mpApi.showToast('微信小程序')
// #endif
}
</script>
<template>
<view class="page">
<!-- uni-app 组件 -->
<button @click="handleClick">点击</button>
<!-- 条件渲染 -->
<!-- #ifdef H5 -->
<view>H5 特有内容</view>
<!-- #endif -->
</view>
</template>
```
## 生命周期
- 使用 uni-app 页面生命周期
- onLoad、onShow、onReady、onHide、onUnload
- 组件生命周期遵循 Vue3 规范
- 注意页面栈和导航管理

@ -0,0 +1,17 @@
{
"recommendations": [
"vue.volar",
"esbenp.prettier-vscode",
"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",
"christian-kohler.path-intellisense"
]
}

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

@ -0,0 +1,77 @@
{
// Place your unibest 工作区 snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
// "Print to console": {
// "scope": "javascript,typescript",
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"Print unibest Vue3 SFC": {
"scope": "vue",
"prefix": "v3",
"body": [
"<script lang=\"ts\" setup>",
"definePage({",
" style: {",
" navigationBarTitleText: '$1',",
" },",
"})",
"</script>\n",
"<template>",
" <view class=\"\">$3</view>",
"</template>\n",
"<style lang=\"scss\" scoped>",
"//$4",
"</style>\n",
],
},
"Print unibest style": {
"scope": "vue",
"prefix": "st",
"body": [
"<style lang=\"scss\" scoped>",
"//",
"</style>\n"
],
},
"Print unibest script": {
"scope": "vue",
"prefix": "sc",
"body": [
"<script lang=\"ts\" setup>",
"//$1",
"</script>\n"
],
},
"Print unibest script with definePage": {
"scope": "vue",
"prefix": "scdp",
"body": [
"<script lang=\"ts\" setup>",
"definePage({",
" style: {",
" navigationBarTitleText: '$1',",
" },",
"})",
"</script>\n"
],
},
"Print unibest template": {
"scope": "vue",
"prefix": "te",
"body": [
"<template>",
" <view class=\"\">$1</view>",
"</template>\n"
],
},
}

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

@ -0,0 +1,93 @@
<p align="center">
<a href="https://github.com/unibest-tech/unibest">
<img width="160" src="./src/static/logo.svg">
</a>
</p>
<h1 align="center">
<a href="https://github.com/unibest-tech/unibest" target="_blank">unibest - 最好的 uniapp 开发框架</a>
</h1>
<div align="center">
旧仓库 codercup 进不去了star 也拿不回来,这里也展示一下那个地址的 star.
[![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)
</div>
<div align="center">
[![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)
</div>
`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)
<p align="center">
<a href="https://unibest.tech/" target="_blank">📖 文档地址(new)</a>
<span style="margin:0 10px;">|</span>
<a href="https://feige996.github.io/hello-unibest/" target="_blank">📱 DEMO 地址</a>
</p>
---
注意旧的地址 [codercup](https://github.com/codercup/unibest) 我进不去了,使用新的 [feige996](https://github.com/feige996/unibest)。PR和 issue 也请使用新地址,否则无法合并。
## 平台兼容性
| H5 | IOS | 安卓 | 微信小程序 | 字节小程序 | 快手小程序 | 支付宝小程序 | 钉钉小程序 | 百度小程序 |
| --- | --- | ---- | ---------- | ---------- | ---------- | ------------ | ---------- | ---------- |
| √ | √ | √ | √ | √ | √ | √ | √ | √ |
注意每种 `UI框架` 支持的平台有所不同,详情请看各 `UI框架` 的官网,也可以看 `unibest` 文档。
## ⚙️ 环境
- node>=18
- pnpm>=7.30
- Vue Official>=2.1.10
- TypeScript>=5.0
## &#x1F4C2; 快速开始
执行 `pnpm create unibest` 创建项目
执行 `pnpm i` 安装依赖
执行 `pnpm dev` 运行 `H5`
执行 `pnpm dev:mp` 运行 `微信小程序`
## 📦 运行(支持热更新)
- web平台 `pnpm dev:h5`, 然后打开 [http://localhost:9000/](http://localhost:9000/)。
- weixin平台`pnpm dev:mp` 然后打开微信开发者工具,导入本地文件夹,选择本项目的`dist/dev/mp-weixin` 文件。
- APP平台`pnpm dev:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/dev/app` 文件夹,选择运行到模拟器(开发时优先使用),或者运行的安卓/ios基座。(如果是 `安卓``鸿蒙` 平台则不用这个方式可以把整个unibest项目导入到hbx通过hbx的菜单来运行到对应的平台。)
## 🔗 发布
- web平台 `pnpm build:h5`,打包后的文件在 `dist/build/h5`可以放到web服务器如nginx运行。如果最终不是放在根目录可以在 `manifest.config.ts` 文件的 `h5.router.base` 属性进行修改。
- weixin平台`pnpm build:mp`, 打包后的文件在 `dist/build/mp-weixin`,然后通过微信开发者工具导入,并点击右上角的“上传”按钮进行上传。
- APP平台`pnpm build:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/build/app` 文件夹,选择发行 - APP云打包。(如果是 `安卓``鸿蒙` 平台则不用这个方式可以把整个unibest项目导入到hbx通过hbx的菜单来发行到对应的平台。)
## 📄 License
[MIT](https://opensource.org/license/mit/)
Copyright (c) 2025 菲鸽
## 捐赠
<p align='center'>
<img alt="special sponsor appwrite" src="https://oss.laf.run/ukw0y1-site/pay/wepay.png" height="330" style="display:inline-block; height:330px;">
<img alt="special sponsor appwrite" src="https://oss.laf.run/ukw0y1-site/pay/alipay.jpg" height="330" style="display:inline-block; height:330px; margin-left:10px;">
</p>

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

@ -0,0 +1,31 @@
# 依赖目录
node_modules
# 版本控制
.git
.gitignore
# 构建产物
/dist
# 开发工具配置
.vscode/
.idea/
.trae/
.cursor/
# 其他配置文件
.github/
.husky/
# 日志文件
logs/
# 缓存文件
.cache/
*.swp
*.swo
# 操作系统文件
.DS_Store

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

@ -0,0 +1,28 @@
## Docker
根据提供的 `Dockerfile`,可以通过以下步骤构建并运行镜像:
### 1. 构建Docker镜像
在项目根目录执行以下命令:
- `-t unibest:v1-2025091701`为镜像指定名称和标签YYYYMMDD+编号
- `.`表示使用当前目录的Dockerfile
```bash
docker build -t unibest:v1-2025091701 .
docker build -t unibest:v1-2025091702 .
```
### 2. 运行Docker容器
使用以下命令运行容器:
```bash
docker run -d --name unibest-v1-2025091701 -p 80:80 unibest:v1-2025091701
docker run -d --name unibest-v1-2025091702 -p 80:80 unibest:v1-2025091702
```
- `-d`:表示在后台运行容器
- `-p 80:80`将容器的80端口映射到主机的80端口
- `--name unibest-v1-2025091701`:为容器指定一个名称

@ -0,0 +1,145 @@
# 配置工作进程数,通常设置为 CPU 核心数
worker_processes auto;
# 错误日志配置
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
# 开启多路复用
use epoll;
}
# 文件描述符限制 - 移到这里在http块之前
worker_rlimit_nofile 65535;
http {
# 日志格式定义
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
# 访问日志配置
access_log /var/log/nginx/access.log main;
# 高效文件传输设置
sendfile on;
tcp_nopush on;
tcp_nodelay on;
# 连接超时设置
keepalive_timeout 65;
keepalive_requests 100;
# gzip 压缩优化
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1000;
gzip_buffers 16 8k;
gzip_http_version 1.1;
# 增加更多文件类型
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon;
# 全局设置
# 合理限制请求体大小,根据实际需求调整
client_max_body_size 10m;
client_body_buffer_size 128k;
client_header_timeout 60s;
client_body_timeout 60s;
server {
listen 80;
server_name _;
gunzip on;
gzip_static always;
include /etc/nginx/mime.types;
absolute_redirect off;
root /usr/share/nginx/html;
# 安全相关响应头
add_header X-Frame-Options SAMEORIGIN;
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options nosniff;
# 根据实际情况调整 CSP
# add_header Content-Security-Policy "default-src 'self'";
# 处理 SPA 应用路由
location / {
try_files $uri $uri/ /index.html;
index index.html index.htm;
}
# HTML JSON 文件 - 短缓存策略
location ~ .*\.(html|json)$ {
add_header Cache-Control "public, max-age=300, must-revalidate";
}
# 静态资源 - 长缓存策略
location ~ .*\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|ttf|woff|woff2|eot|mp4|mp3|swf)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 365d;
access_log off;
}
# JS CSS - 带版本号的长缓存
location ~ .*\.(js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 365d;
access_log off;
}
# 接口转发 - 替换为实际后端地址
# location ^~ /fg-api {
# proxy_http_version 1.1;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Host $host;
# # 后端是HTTPS时的必要配置
# proxy_ssl_server_name on;
# proxy_ssl_protocols TLSv1.2 TLSv1.3;
# proxy_ssl_session_reuse on;
# # 对于生产环境,应该尽量使用有效的证书而不是依赖``proxy_ssl_verify off;`` ,因为这会带来安全风险
# proxy_ssl_verify off;
# # TODO替换为实际后端服务地址
# # 注意在URL末尾添加了斜杠这样Nginx会去掉 /fg-api 前缀
# # 前端请求 http://your-domain.com/fg-api/users 转发到 https://ukw0y1.laf.run/users
# proxy_pass https://ukw0y1.laf.run/;
# # 上面一行的效果与下面2行一样的效果都是为了去掉 /fg-api 前缀
# # 显式移除/fg-api前缀
# # rewrite ^/fg-api(.*)$ $1 break;
# # 域名末尾不需要斜杠了
# # proxy_pass https://ukw0y1.laf.run;
# proxy_connect_timeout 60s;
# proxy_send_timeout 60s;
# proxy_read_timeout 60s;
# proxy_buffers 8 32k;
# proxy_buffer_size 64k;
# proxy_busy_buffers_size 128k;
# proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
# }
# 错误页面配置
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
}

30
env/.env vendored

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

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

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

9
env/.env.test vendored

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

@ -0,0 +1,60 @@
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',
'ts/no-require-imports': 'off',
'import/first': 'off',
'vue/singleline-html-element-content-newline': [
'error',
{
externalIgnores: ['text'],
},
],
// vue SFC 调换顺序改这里
'vue/block-order': ['error', {
order: [['script', 'template'], 'style'],
}],
},
formatters: {
/**
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
* By default uses Prettier
*/
css: true,
/**
* Format HTML files
* By default uses Prettier
*/
html: true,
},
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@ -0,0 +1,26 @@
<!doctype html>
<html build-time="%BUILD_TIME%">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<script>
var coverSupport =
'CSS' in window &&
typeof CSS.supports === 'function' &&
(CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') +
'" />',
)
</script>
<title>%VITE_APP_TITLE%</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

@ -0,0 +1,164 @@
import path from 'node:path'
import process from 'node:process'
// manifest.config.ts
import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
import { loadEnv } from 'vite'
// 手动解析命令行参数获取 mode
function getMode() {
const args = process.argv.slice(2)
const modeFlagIndex = args.findIndex(arg => arg === '--mode')
return modeFlagIndex !== -1 ? args[modeFlagIndex + 1] : args[0] === 'build' ? 'production' : 'development' // 默认 development
}
// 获取环境变量的范例
const env = loadEnv(getMode(), path.resolve(process.cwd(), 'env'))
const {
VITE_APP_TITLE,
VITE_UNI_APPID,
VITE_WX_APPID,
VITE_APP_PUBLIC_BASE,
VITE_FALLBACK_LOCALE,
} = env
// console.log('manifest.config.ts env:', env)
export default defineManifestConfig({
'name': VITE_APP_TITLE,
'appid': VITE_UNI_APPID,
'description': '',
'versionName': '1.0.0',
'versionCode': '100',
'transformPx': false,
'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
'h5': {
router: {
base: VITE_APP_PUBLIC_BASE,
},
},
/* 5+App特有相关 */
'app-plus': {
usingComponents: true,
nvueStyleCompiler: 'uni-app',
compilerVersion: 3,
compatible: {
ignoreVersion: true,
},
splashscreen: {
alwaysShowBeforeRender: true,
waiting: true,
autoclose: true,
delay: 0,
},
/* 模块配置 */
modules: {},
/* 应用发布信息 */
distribute: {
/* android打包配置 */
android: {
minSdkVersion: 21,
targetSdkVersion: 30,
abiFilters: ['armeabi-v7a', 'arm64-v8a'],
permissions: [
'<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>',
'<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>',
'<uses-permission android:name="android.permission.VIBRATE"/>',
'<uses-permission android:name="android.permission.READ_LOGS"/>',
'<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>',
'<uses-feature android:name="android.hardware.camera.autofocus"/>',
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
'<uses-permission android:name="android.permission.CAMERA"/>',
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
'<uses-permission android:name="android.permission.READ_PHONE_STATE"/>',
'<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>',
'<uses-permission android:name="android.permission.WAKE_LOCK"/>',
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
'<uses-feature android:name="android.hardware.camera"/>',
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
],
},
/* ios打包配置 */
ios: {},
/* SDK配置 */
sdkConfigs: {},
/* 图标配置 */
icons: {
android: {
hdpi: 'static/app/icons/72x72.png',
xhdpi: 'static/app/icons/96x96.png',
xxhdpi: 'static/app/icons/144x144.png',
xxxhdpi: 'static/app/icons/192x192.png',
},
ios: {
appstore: 'static/app/icons/1024x1024.png',
ipad: {
'app': 'static/app/icons/76x76.png',
'app@2x': 'static/app/icons/152x152.png',
'notification': 'static/app/icons/20x20.png',
'notification@2x': 'static/app/icons/40x40.png',
'proapp@2x': 'static/app/icons/167x167.png',
'settings': 'static/app/icons/29x29.png',
'settings@2x': 'static/app/icons/58x58.png',
'spotlight': 'static/app/icons/40x40.png',
'spotlight@2x': 'static/app/icons/80x80.png',
},
iphone: {
'app@2x': 'static/app/icons/120x120.png',
'app@3x': 'static/app/icons/180x180.png',
'notification@2x': 'static/app/icons/40x40.png',
'notification@3x': 'static/app/icons/60x60.png',
'settings@2x': 'static/app/icons/58x58.png',
'settings@3x': 'static/app/icons/87x87.png',
'spotlight@2x': 'static/app/icons/80x80.png',
'spotlight@3x': 'static/app/icons/120x120.png',
},
},
},
},
},
/* 快应用特有相关 */
'quickapp': {},
/* 小程序特有相关 */
'mp-weixin': {
appid: VITE_WX_APPID,
setting: {
urlCheck: false,
// 是否启用 ES6 转 ES5
es6: true,
minified: true,
},
optimization: {
subPackages: true,
},
// 是否合并组件虚拟节点外层属性uni-app 3.5.1+ 开始支持。目前仅支持 style、class 属性。
// 默认不开启undefined这里设置为开启。
mergeVirtualHostAttributes: true,
// styleIsolation: 'shared',
usingComponents: true,
// __usePrivacyCheck__: true,
},
'mp-alipay': {
usingComponents: true,
styleIsolation: 'shared',
optimization: {
subPackages: true,
},
// 解决支付宝小程序开发工具报错 【globalThis is not defined】
compileOptions: {
globalObjectMode: 'enable',
transpile: {
script: {
ignore: ['node_modules/**'],
},
},
},
},
'mp-baidu': {
usingComponents: true,
},
'mp-toutiao': {
usingComponents: true,
},
'uniStatistics': {
enable: false,
},
'vueVersion': '3',
})

@ -0,0 +1,13 @@
import type { GenerateServiceProps } from 'openapi-ts-request'
export default [
{
schemaPath: 'https://ukw0y1.laf.run/unibest-opapi-test.json',
serversPath: './src/service',
requestLibPath: `import request from '@/http/vue-query';\n import { CustomRequestOptions } from '@/http/types';`,
requestOptionsType: 'CustomRequestOptions',
isGenReactQuery: false,
reactQueryMode: 'vue',
isGenJavaScript: false,
},
] as GenerateServiceProps[]

@ -0,0 +1,194 @@
{
"name": "pest_uni",
"type": "module",
"version": "3.18.8",
"unibest-version": "3.18.8",
"update-time": "2025-10-13",
"packageManager": "pnpm@10.10.0",
"description": "unibest - 最好的 uniapp 开发模板",
"generate-time": "用户创建项目时生成",
"author": {
"name": "feige996",
"zhName": "菲鸽",
"email": "1020103647@qq.com",
"github": "https://github.com/feige996",
"gitee": "https://gitee.com/feige996"
},
"license": "MIT",
"homepage": "https://unibest.tech",
"repository": "https://github.com/feige996/unibest",
"bugs": {
"url": "https://github.com/feige996/unibest/issues",
"url-old": "https://github.com/codercup/unibest/issues"
},
"engines": {
"node": ">=20",
"pnpm": ">=9"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
"uvm": "npx @dcloudio/uvm@latest",
"uvm-rm": "node ./scripts/postupgrade.js",
"postuvm": "echo upgrade uni-app success!",
"dev:app": "uni -p app",
"dev:app:test": "uni -p app --mode test",
"dev:app:prod": "uni -p app --mode production",
"dev:app-android": "uni -p app-android",
"dev:app-ios": "uni -p app-ios",
"dev:custom": "uni -p",
"dev": "uni",
"dev:test": "uni --mode test",
"dev:prod": "uni --mode production",
"dev:h5": "uni",
"dev:h5:test": "uni --mode test",
"dev:h5:prod": "uni --mode production",
"dev:h5:ssr": "uni --ssr",
"dev:mp": "uni -p mp-weixin",
"dev:mp:test": "uni -p mp-weixin --mode test",
"dev:mp:prod": "uni -p mp-weixin --mode production",
"dev:mp-alipay": "uni -p mp-alipay",
"dev:mp-baidu": "uni -p mp-baidu",
"dev:mp-jd": "uni -p mp-jd",
"dev:mp-kuaishou": "uni -p mp-kuaishou",
"dev:mp-lark": "uni -p mp-lark",
"dev:mp-qq": "uni -p mp-qq",
"dev:mp-toutiao": "uni -p mp-toutiao",
"dev:mp-weixin": "uni -p mp-weixin",
"dev:mp-xhs": "uni -p mp-xhs",
"dev:quickapp-webview": "uni -p quickapp-webview",
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build:app": "uni build -p app",
"build:app:test": "uni build -p app --mode test",
"build:app:prod": "uni build -p app --mode production",
"build:app-android": "uni build -p app-android",
"build:app-ios": "uni build -p app-ios",
"build:custom": "uni build -p",
"build:h5": "uni build",
"build:h5:test": "uni build --mode test",
"build:h5:prod": "uni build --mode production",
"build": "uni build",
"build:test": "uni build --mode test",
"build:prod": "uni build --mode production",
"build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay",
"build:mp": "uni build -p mp-weixin",
"build:mp:test": "uni build -p mp-weixin --mode test",
"build:mp:prod": "uni build -p mp-weixin --mode production",
"build:mp-baidu": "uni build -p mp-baidu",
"build:mp-jd": "uni build -p mp-jd",
"build:mp-kuaishou": "uni build -p mp-kuaishou",
"build:mp-lark": "uni build -p mp-lark",
"build:mp-qq": "uni build -p mp-qq",
"build:mp-toutiao": "uni build -p mp-toutiao",
"build:mp-weixin": "uni build -p mp-weixin",
"build:mp-xhs": "uni build -p mp-xhs",
"build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"type-check": "vue-tsc --noEmit",
"openapi": "openapi-ts",
"init-husky": "git init && husky",
"init-baseFile": "node ./scripts/create-base-files.js",
"prepare": "pnpm init-husky & pnpm init-baseFile",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"@alova/adapter-uniapp": "^2.0.14",
"@alova/shared": "^1.3.1",
"@dcloudio/uni-app": "3.0.0-4070620250821001",
"@dcloudio/uni-app-harmony": "3.0.0-4070620250821001",
"@dcloudio/uni-app-plus": "3.0.0-4070620250821001",
"@dcloudio/uni-components": "3.0.0-4070620250821001",
"@dcloudio/uni-h5": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-alipay": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-baidu": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-harmony": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-jd": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-lark": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-qq": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-toutiao": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
"@qiun/ucharts": "2.5.0-20230101",
"abortcontroller-polyfill": "^1.7.8",
"alova": "^3.3.3",
"dayjs": "1.11.10",
"js-cookie": "^3.0.5",
"pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1",
"vue": "^3.4.21",
"vue-i18n": "^9.14.5",
"vue-router": "4.5.1",
"wot-design-uni": "^1.12.4",
"z-paging": "2.8.7"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-4070620250821001",
"@dcloudio/uni-cli-shared": "3.0.0-4070620250821001",
"@dcloudio/uni-stacktracey": "3.0.0-4070620250821001",
"@dcloudio/vite-plugin-uni": "3.0.0-4070620250821001",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@iconify-json/carbon": "^1.2.4",
"@rollup/rollup-darwin-x64": "^4.28.0",
"@types/node": "^20.17.9",
"@uni-helper/eslint-config": "0.5.0",
"@uni-helper/plugin-uni": "0.1.0",
"@uni-helper/uni-env": "0.1.8",
"@uni-helper/uni-types": "1.0.0-alpha.6",
"@uni-helper/unocss-preset-uni": "0.2.11",
"@uni-helper/vite-plugin-uni-components": "0.2.3",
"@uni-helper/vite-plugin-uni-layouts": "0.1.11",
"@uni-helper/vite-plugin-uni-manifest": "0.2.8",
"@uni-helper/vite-plugin-uni-pages": "0.3.19",
"@uni-helper/vite-plugin-uni-platform": "0.0.5",
"@uni-ku/bundle-optimizer": "v1.3.15-beta.2",
"@uni-ku/root": "1.4.1",
"@unocss/eslint-plugin": "^66.2.3",
"@unocss/preset-legacy-compat": "66.0.0",
"@vue/runtime-core": "^3.4.21",
"@vue/tsconfig": "^0.1.3",
"autoprefixer": "^10.4.20",
"cross-env": "^10.0.0",
"eslint": "^9.31.0",
"eslint-plugin-format": "^1.0.1",
"husky": "^9.1.7",
"lint-staged": "^15.2.10",
"miniprogram-api-typings": "^4.1.0",
"openapi-ts-request": "^1.6.7",
"postcss": "^8.4.49",
"postcss-html": "^1.8.0",
"postcss-scss": "^4.0.9",
"rollup-plugin-visualizer": "^6.0.3",
"sass": "1.77.8",
"std-env": "^3.9.0",
"typescript": "~5.8.0",
"unocss": "66.0.0",
"unplugin-auto-import": "^20.0.0",
"vite": "5.2.8",
"vite-plugin-restart": "^1.0.0",
"vue-tsc": "^3.0.6"
},
"pnpm": {
"overrides": {
"unconfig": "7.3.2"
}
},
"overrides": {
"unconfig": "7.3.2"
},
"resolutions": {
"bin-wrapper": "npm:bin-wrapper-china",
"unconfig": "7.3.2"
},
"lint-staged": {
"*": "eslint --fix"
}
}

@ -0,0 +1,29 @@
/*
* @Author: chris
* @Date: 2025-10-20 10:45:46
* @LastEditors: chris
* @LastEditTime: 2025-10-20 11:40:32
*/
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
import { tabBar } from './src/tabbar/config'
export default defineUniPages({
globalStyle: {
navigationStyle: 'default',
navigationBarTitleText: '果园',
navigationBarBackgroundColor: '#f8f8f8',
navigationBarTextStyle: 'black',
backgroundColor: '#FFFFFF',
},
easycom: {
autoscan: true,
custom: {
'^fg-(.*)': '@/components/fg-$1/fg-$1.vue',
'^wd-(.*)': 'wot-design-uni/components/wd-$1/wd-$1.vue',
'^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
'z-paging/components/z-paging$1/z-paging$1.vue',
},
},
// tabbar 的配置统一在 “./src/tabbar/config.ts” 文件中
tabBar: tabBar as any,
})

File diff suppressed because it is too large Load Diff

@ -0,0 +1,52 @@
// 基础配置文件生成脚本
// 此脚本用于生成 src/manifest.json 和 src/pages.json 基础文件
// 由于这两个配置文件会被添加到 .gitignore 中,因此需要通过此脚本确保项目能正常运行
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// 获取当前文件的目录路径(替代 CommonJS 中的 __dirname
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// 最简可运行配置
const manifest = { }
const pages = {
pages: [
{
path: 'pages/index/index',
type: 'home',
style: {
navigationStyle: 'custom',
navigationBarTitleText: '首页',
},
},
{
path: 'pages/me/me',
type: 'page',
style: {
navigationBarTitleText: '我的',
},
},
],
}
// 使用修复后的 __dirname 来解析文件路径
const manifestPath = path.resolve(__dirname, '../src/manifest.json')
const pagesPath = path.resolve(__dirname, '../src/pages.json')
// 确保 src 目录存在
const srcDir = path.resolve(__dirname, '../src')
if (!fs.existsSync(srcDir)) {
fs.mkdirSync(srcDir, { recursive: true })
}
// 如果 src/manifest.json 不存在,就创建它;存在就不处理,以免覆盖
if (!fs.existsSync(manifestPath)) {
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
}
// 如果 src/pages.json 不存在,就创建它;存在就不处理,以免覆盖
if (!fs.existsSync(pagesPath)) {
fs.writeFileSync(pagesPath, JSON.stringify(pages, null, 2))
}

@ -0,0 +1,83 @@
import { exec } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
/**
* 打开开发者工具
*/
function _openDevTools() {
const platform = process.platform // darwin, win32, linux
const { UNI_PLATFORM } = process.env // mp-weixin, mp-alipay
const uniPlatformText = UNI_PLATFORM === 'mp-weixin' ? '微信小程序' : UNI_PLATFORM === 'mp-alipay' ? '支付宝小程序' : '小程序'
// 项目路径(构建输出目录)
const projectPath = path.resolve(process.cwd(), `dist/dev/${UNI_PLATFORM}`)
// 检查构建输出目录是否存在
if (!fs.existsSync(projectPath)) {
console.log(`${uniPlatformText}构建目录不存在:`, projectPath)
return
}
console.log(`🚀 正在打开${uniPlatformText}开发者工具...`)
// 根据不同操作系统执行不同命令
let command = ''
if (platform === 'darwin') {
// macOS
if (UNI_PLATFORM === 'mp-weixin') {
command = `/Applications/wechatwebdevtools.app/Contents/MacOS/cli -o "${projectPath}"`
}
else if (UNI_PLATFORM === 'mp-alipay') {
command = `/Applications/小程序开发者工具.app/Contents/MacOS/小程序开发者工具 --p "${projectPath}"`
}
}
else if (platform === 'win32' || platform === 'win64') {
// Windows
if (UNI_PLATFORM === 'mp-weixin') {
command = `"C:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat" -o "${projectPath}"`
}
}
else {
// Linux 或其他系统
console.log('❌ 当前系统不支持自动打开微信开发者工具')
return
}
exec(command, (error, stdout, stderr) => {
if (error) {
console.log(`❌ 打开${uniPlatformText}开发者工具失败:`, error.message)
console.log(`💡 请确保${uniPlatformText}开发者工具服务端口已启用`)
console.log(`💡 可以手动打开${uniPlatformText}开发者工具并导入项目:`, projectPath)
return
}
if (stderr) {
console.log('⚠️ 警告:', stderr)
}
console.log(`${uniPlatformText}开发者工具已打开`)
if (stdout) {
console.log(stdout)
}
})
}
export default function openDevTools() {
// 首次构建标记
let isFirstBuild = true
return {
name: 'uni-devtools',
writeBundle() {
if (isFirstBuild && process.env.UNI_PLATFORM?.includes('mp')) {
isFirstBuild = false
_openDevTools()
}
},
}
}

@ -0,0 +1,101 @@
// # 执行 `pnpm upgrade` 后会升级 `uniapp` 相关依赖
// # 在升级完后,会自动添加很多无用依赖,这需要删除以减小依赖包体积
// # 只需要执行下面的命令即可
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
// 日志控制开关,设置为 true 可以启用所有日志输出
const FG_LOG_ENABLE = true
// 将 exec 转换为返回 Promise 的函数
const execPromise = promisify(exec)
// 定义要执行的命令
const dependencies = [
'@dcloudio/uni-app-harmony',
// TODO: 如果不需要某个平台的小程序,请手动删除或注释掉
'@dcloudio/uni-mp-alipay',
'@dcloudio/uni-mp-baidu',
'@dcloudio/uni-mp-jd',
'@dcloudio/uni-mp-kuaishou',
'@dcloudio/uni-mp-lark',
'@dcloudio/uni-mp-qq',
'@dcloudio/uni-mp-toutiao',
'@dcloudio/uni-mp-xhs',
'@dcloudio/uni-quickapp-webview',
// i18n模板要注释掉下面的
'vue-i18n',
]
/**
* 带开关的日志输出函数
* @param {string} message 日志消息
* @param {string} type 日志类型 (log, error)
*/
function log(message, type = 'log') {
if (FG_LOG_ENABLE) {
if (type === 'error') {
console.error(message)
}
else {
console.log(message)
}
}
}
/**
* 卸载单个依赖包
* @param {string} dep 依赖包名
* @returns {Promise<boolean>} 是否成功卸载
*/
async function uninstallDependency(dep) {
try {
log(`开始卸载依赖: ${dep}`)
const { stdout, stderr } = await execPromise(`pnpm un ${dep}`)
if (stdout) {
log(`stdout [${dep}]: ${stdout}`)
}
if (stderr) {
log(`stderr [${dep}]: ${stderr}`, 'error')
}
log(`成功卸载依赖: ${dep}`)
return true
}
catch (error) {
// 单个依赖卸载失败不影响其他依赖
log(`卸载依赖 ${dep} 失败: ${error.message}`, 'error')
return false
}
}
/**
* 串行卸载所有依赖包
*/
async function uninstallAllDependencies() {
log(`开始串行卸载 ${dependencies.length} 个依赖包...`)
let successCount = 0
let failedCount = 0
// 串行执行所有卸载命令
for (const dep of dependencies) {
const success = await uninstallDependency(dep)
if (success) {
successCount++
}
else {
failedCount++
}
// 为了避免命令执行过快导致的问题,添加短暂延迟
await new Promise(resolve => setTimeout(resolve, 100))
}
log(`卸载操作完成: 成功 ${successCount} 个, 失败 ${failedCount}`)
}
// 执行串行卸载
uninstallAllDependencies().catch((err) => {
log(`串行卸载过程中出现未捕获的错误: ${err}`, 'error')
})

@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useThemeStore } from '@/store'
import FgTabbar from '@/tabbar/index.vue'
import { isPageTabbar } from './tabbar/store'
import { currRoute } from './utils'
const themeStore = useThemeStore()
const isCurrentPageTabbar = ref(true)
onShow(() => {
console.log('App.ku.vue onShow', currRoute())
const { path } = currRoute()
// '/pages/index/index'线 '/' 线 tabbar
// '/' tabbar
if (path === '/') {
isCurrentPageTabbar.value = true
}
else {
isCurrentPageTabbar.value = isPageTabbar(path)
}
})
const helloKuRoot = ref('Hello AppKuVue')
const exposeRef = ref('this is form app.Ku.vue')
defineExpose({
exposeRef,
})
</script>
<template>
<wd-config-provider :theme-vars="themeStore.themeVars" :theme="themeStore.theme">
<!-- 这个先隐藏了知道这样用就行 -->
<view class="hidden text-center">
{{ helloKuRoot }}这里可以配置全局的东西
</view>
<KuRootView />
<FgTabbar v-if="isCurrentPageTabbar" />
<wd-toast />
<wd-message-box />
</wd-config-provider>
</template>

@ -0,0 +1,37 @@
<script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
import { navigateToInterceptor } from '@/router/interceptor'
onLaunch((options) => {
console.log('App Launch', options)
})
onShow((options) => {
console.log('App Show', options)
// h5
// https://github.com/unibest-tech/unibest/issues/192
if (options?.path) {
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
}
else {
navigateToInterceptor.invoke({ url: '/' })
}
})
onHide(() => {
console.log('App Hide')
})
</script>
<style lang="scss">
swiper,
scroll-view {
flex: 1;
height: 100%;
overflow: hidden;
}
image {
width: 100%;
height: 100%;
vertical-align: middle;
}
</style>

@ -0,0 +1,17 @@
import { API_DOMAINS, http } from '@/http/alova'
export interface IFoo {
id: number
name: string
}
export function foo() {
return http.Get<IFoo>('/foo', {
params: {
name: '菲鸽',
page: 1,
pageSize: 10,
},
meta: { domain: API_DOMAINS.SECONDARY }, // 用于切换请求地址
})
}

@ -0,0 +1,43 @@
import { http } from '@/http/http'
export interface IFoo {
id: number
name: string
}
export function foo() {
return http.Get<IFoo>('/foo', {
params: {
name: '菲鸽',
page: 1,
pageSize: 10,
},
})
}
export interface IFooItem {
id: string
name: string
}
/** GET 请求 */
export async function getFooAPI(name: string) {
return await http.get<IFooItem>('/foo', { name })
}
/** GET 请求;支持 传递 header 的范例 */
export function getFooAPI2(name: string) {
return http.get<IFooItem>('/foo', { name }, { 'Content-Type-100': '100' })
}
/** POST 请求 */
export function postFooAPI(name: string) {
return http.post<IFooItem>('/foo', { name })
}
/** POST 请求;需要传递 query 参数的范例微信小程序经常有同时需要query参数和body参数的场景 */
export function postFooAPI2(name: string) {
return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 })
}
/** POST 请求;支持 传递 header 的范例 */
export function postFooAPI3(name: string) {
return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 }, { 'Content-Type-100': '100' })
}

@ -0,0 +1,85 @@
import type { IAuthLoginRes, ICaptcha, IDoubleTokenRes, IUpdateInfo, IUpdatePassword, IUserInfoRes } from './types/login'
import { http } from '@/http/http'
/**
*
*/
export interface ILoginForm {
username: string
password: string
}
/**
*
* @returns ICaptcha
*/
export function getCode() {
return http.get<ICaptcha>('/user/getCode')
}
/**
*
* @param loginForm
*/
export function login(loginForm: ILoginForm) {
return http.post<IAuthLoginRes>('/auth/login', loginForm)
}
/**
* token
* @param refreshToken token
*/
export function refreshToken(refreshToken: string) {
return http.post<IDoubleTokenRes>('/auth/refreshToken', { refreshToken })
}
/**
*
*/
export function getUserInfo() {
return http.get<IUserInfoRes>('/user/info')
}
/**
* 退
*/
export function logout() {
return http.get<void>('/auth/logout')
}
/**
*
*/
export function updateInfo(data: IUpdateInfo) {
return http.post('/user/updateInfo', data)
}
/**
*
*/
export function updateUserPassword(data: IUpdatePassword) {
return http.post('/user/updatePassword', data)
}
/**
*
* @returns Promise (code)
*/
export function getWxCode() {
return new Promise<UniApp.LoginRes>((resolve, reject) => {
uni.login({
provider: 'weixin',
success: res => resolve(res),
fail: err => reject(new Error(err)),
})
})
}
/**
*
* @param params code
* @returns Promise
*/
export function wxLogin(data: { code: string }) {
return http.post<IAuthLoginRes>('/auth/wxLogin', data)
}

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

35
src/env.d.ts vendored

@ -0,0 +1,35 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
/** 网站标题,应用名称 */
readonly VITE_APP_TITLE: string
/** 服务端口号 */
readonly VITE_SERVER_PORT: string
/** 后台接口地址 */
readonly VITE_SERVER_BASEURL: string
/** H5是否需要代理 */
readonly VITE_APP_PROXY_ENABLE: 'true' | 'false'
/** H5是否需要代理需要的话有个前缀 */
readonly VITE_APP_PROXY_PREFIX: string
/** 后端是否有统一前缀 /api */
readonly VITE_SERVER_HAS_API_PREFIX: 'true' | 'false'
/** 认证模式,'single' | 'double' ==> 单token | 双token */
readonly VITE_AUTH_MODE: 'single' | 'double'
/** 是否清除console */
readonly VITE_DELETE_CONSOLE: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const __VITE_APP_PROXY__: 'true' | 'false'

@ -0,0 +1,54 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
interface IUseRequestOptions<T> {
/** 是否立即执行 */
immediate?: boolean
/** 初始化数据 */
initialData?: T
}
interface IUseRequestReturn<T, P = undefined> {
loading: Ref<boolean>
error: Ref<boolean | Error>
data: Ref<T | undefined>
run: (args?: P) => Promise<T | undefined>
}
/**
* useRequest
* @param func Promise
* @param options {immediate, initialData}
* @param options.immediate false
* @param options.initialData undefined
* @returns {loading, error, data, run}
*/
export default function useRequest<T, P = undefined>(
func: (args?: P) => Promise<T>,
options: IUseRequestOptions<T> = { immediate: false },
): IUseRequestReturn<T, P> {
const loading = ref(false)
const error = ref(false)
const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
const run = async (args?: P) => {
loading.value = true
return func(args)
.then((res) => {
data.value = res
error.value = false
return data.value
})
.catch((err) => {
error.value = err
throw err
})
.finally(() => {
loading.value = false
})
}
if (options.immediate) {
(run as (args: P) => Promise<T | undefined>)({} as P)
}
return { loading, error, data, run }
}

@ -0,0 +1,116 @@
# 上拉刷新和下拉加载更多
在 unibest 框架中,我们通过组合 `useScroll` Hook 可结合 `scroll-view` 组件来轻松实现上拉刷新和下拉加载更多的功能。
场景一 页面滚动
```
definePage({
style: {
navigationBarTitleText: '上拉刷新和下拉加载更多',
enablePullDownRefresh: true,
onReachBottomDistance: 100,
},
})
```
场景二 局部滚动 结合 `scroll-view`
## 关键文件
- `src/hooks/useScroll.ts`: 提供了核心的滚动逻辑处理 Hook。
- `src/pages-sub/demo/scroll.vue`: 一个具体的实现示例页面。
## `useScroll` Hook
`useScroll` 是一个 Vue Composition API Hook它封装了处理下拉刷新和上拉加载的通用逻辑。
### 主要功能
- **管理加载状态**: 自动处理 `loading`(加载中)、`finished`(已加载全部)和 `error`(加载失败)等状态。
- **分页逻辑**: 内部维护分页参数(页码 `page` 和每页数量 `pageSize`)。
- **事件处理**: 提供 `onScrollToLower`(滚动到底部)、`onRefresherRefresh`(下拉刷新)等方法,用于在视图层触发。
- **数据合并**: 自动将新加载的数据追加到现有列表 `list` 中。
### 使用方法
```typescript
import { useScroll } from '@/hooks/useScroll'
import { getList } from '@/service/list' // 你的数据请求API
const {
list, // 响应式的数据列表
loading, // 是否加载中
finished, // 是否已全部加载
error, // 是否加载失败
onScrollToLower, // 滚动到底部时触发的事件
onRefresherRefresh, // 下拉刷新时触发的事件
} = useScroll(getList) // 将获取数据的API函数传入
```
## `scroll-view` 组件
`scroll-view` 是 uni-app 提供的可滚动视图区域组件,它提供了一系列属性来支持下拉刷新和上拉加载。
### 关键属性
- `scroll-y`: 允许纵向滚动。
- `refresher-enabled`: 启用下拉刷新。
- `refresher-triggered`: 控制下拉刷新动画的显示与隐藏,通过 `loading` 状态绑定。
- `@scrolltolower`: 滚动到底部时触发的事件,绑定 `onScrollToLower` 方法。
- `@refresherrefresh`: 触发下拉刷新时触发的事件,绑定 `onRefresherRefresh` 方法。
## 示例代码
以下是 `src/pages-sub/demo/scroll.vue` 中的核心代码,展示了如何将 `useScroll``scroll-view` 结合使用。
```vue
<template>
<view class="scroll-page">
<scroll-view
class="scroll-view"
scroll-y
:refresher-enabled="true"
:refresher-triggered="loading"
@scrolltolower="onScrollToLower"
@refresherrefresh="onRefresherRefresh"
>
<view v-for="item in list" :key="item.id" class="scroll-item">
{{ item.name }}
</view>
<!-- 加载状态提示 -->
<view v-if="loading" class="loading-tip">加载中...</view>
<view v-if="finished" class="finished-tip">没有更多了</view>
<view v-if="error" class="error-tip">加载失败,请重试</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { useScroll } from '@/hooks/useScroll'
import { getList } from '@/service/list'
const { list, loading, finished, error, onScrollToLower, onRefresherRefresh } = useScroll(getList)
</script>
<style scoped>
/* 样式省略 */
.scroll-page, .scroll-view {
height: 100%;
}
</style>
```
## 实现步骤总结
1. **创建API**: 确保你有一个返回分页数据的API请求函数例如 `getList`),它应该接受页码和页面大小作为参数。
2. **调用 `useScroll`**: 在你的页面脚本中,导入并调用 `useScroll` Hook将你的API函数作为参数传入。
3. **模板绑定**:
- 使用 `scroll-view` 组件作为滚动容器。
- 将其 `refresher-triggered` 属性绑定到 `useScroll` 返回的 `loading` 状态。
- 将其 `@scrolltolower` 事件绑定到 `onScrollToLower` 方法。
- 将其 `@refresherrefresh` 事件绑定到 `onRefresherRefresh` 方法。
4. **渲染列表**: 使用 `v-for` 指令渲染 `useScroll` 返回的 `list` 数组。
5. **添加加载提示**: 根据 `loading`, `finished`, `error` 状态,在列表底部显示不同的提示信息,提升用户体验。
通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。

@ -0,0 +1,74 @@
import type { Ref } from 'vue'
import { onMounted, ref } from 'vue'
interface UseScrollOptions<T> {
fetchData: (page: number, pageSize: number) => Promise<T[]>
pageSize?: number
}
interface UseScrollReturn<T> {
list: Ref<T[]>
loading: Ref<boolean>
finished: Ref<boolean>
error: Ref<any>
refresh: () => Promise<void>
loadMore: () => Promise<void>
}
export function useScroll<T>({
fetchData,
pageSize = 10,
}: UseScrollOptions<T>): UseScrollReturn<T> {
const list = ref<T[]>([]) as Ref<T[]>
const loading = ref(false)
const finished = ref(false)
const error = ref<any>(null)
const page = ref(1)
const loadData = async () => {
if (loading.value || finished.value)
return
loading.value = true
error.value = null
try {
const data = await fetchData(page.value, pageSize)
if (data.length < pageSize) {
finished.value = true
}
list.value.push(...data)
page.value++
}
catch (err) {
error.value = err
}
finally {
loading.value = false
}
}
const refresh = async () => {
page.value = 1
finished.value = false
list.value = []
await loadData()
}
const loadMore = async () => {
await loadData()
}
onMounted(() => {
refresh()
})
return {
list,
loading,
finished,
error,
refresh,
loadMore,
}
}

@ -0,0 +1,171 @@
import { ref } from 'vue'
import { getEnvBaseUrl } from '@/utils/index'
const VITE_UPLOAD_BASEURL = `${getEnvBaseUrl()}/upload`
type TfileType = 'image' | 'file'
type TImage = 'png' | 'jpg' | 'jpeg' | 'webp' | '*'
type TFile = 'doc' | 'docx' | 'ppt' | 'zip' | 'xls' | 'xlsx' | 'txt' | TImage
interface TOptions<T extends TfileType> {
formData?: Record<string, any>
maxSize?: number
accept?: T extends 'image' ? TImage[] : TFile[]
fileType?: T
success?: (params: any) => void
error?: (err: any) => void
}
export default function useUpload<T extends TfileType>(options: TOptions<T> = {} as TOptions<T>) {
const {
formData = {},
maxSize = 5 * 1024 * 1024,
accept = ['*'],
fileType = 'image',
success,
error: onError,
} = options
const loading = ref(false)
const error = ref<Error | null>(null)
const data = ref<any>(null)
const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string, size: number }) => {
if (size > maxSize) {
uni.showToast({
title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`,
icon: 'none',
})
return
}
// const fileExtension = file?.tempFiles?.name?.split('.').pop()?.toLowerCase()
// const isTypeValid = accept.some((type) => type === '*' || type.toLowerCase() === fileExtension)
// if (!isTypeValid) {
// uni.showToast({
// title: `仅支持 ${accept.join(', ')} 格式的文件`,
// icon: 'none',
// })
// return
// }
loading.value = true
uploadFile({
tempFilePath,
formData,
onSuccess: (res) => {
// 修改这里的解析逻辑,适应不同平台的返回格式
let parsedData = res
try {
// 尝试解析为JSON
const jsonData = JSON.parse(res)
// 检查是否包含data字段
parsedData = jsonData.data || jsonData
}
catch (e) {
// 如果解析失败,使用原始数据
console.log('Response is not JSON, using raw data:', res)
}
data.value = parsedData
// console.log('上传成功', res)
success?.(parsedData)
},
onError: (err) => {
error.value = err
onError?.(err)
},
onComplete: () => {
loading.value = false
},
})
}
const run = () => {
// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
// 微信小程序在2023年10月17日之后使用本API需要配置隐私协议
const chooseFileOptions = {
count: 1,
success: (res: any) => {
console.log('File selected successfully:', res)
// 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]}
// h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]}
// h5的File有以下字段{name: "girl.jpeg", size: 48976, type: "image/jpeg"}
// App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]}
// App的File有以下字段{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976}
let tempFilePath = ''
let size = 0
// #ifdef MP-WEIXIN
tempFilePath = res.tempFiles[0].tempFilePath
size = res.tempFiles[0].size
// #endif
// #ifndef MP-WEIXIN
tempFilePath = res.tempFilePaths[0]
size = res.tempFiles[0].size
// #endif
handleFileChoose({ tempFilePath, size })
},
fail: (err: any) => {
console.error('File selection failed:', err)
error.value = err
onError?.(err)
},
}
if (fileType === 'image') {
// #ifdef MP-WEIXIN
uni.chooseMedia({
...chooseFileOptions,
mediaType: ['image'],
})
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage(chooseFileOptions)
// #endif
}
else {
uni.chooseFile({
...chooseFileOptions,
type: 'all',
})
}
}
return { loading, error, data, run }
}
async function uploadFile({
tempFilePath,
formData,
onSuccess,
onError,
onComplete,
}: {
tempFilePath: string
formData: Record<string, any>
onSuccess: (data: any) => void
onError: (err: any) => void
onComplete: () => void
}) {
uni.uploadFile({
url: VITE_UPLOAD_BASEURL,
filePath: tempFilePath,
name: 'file',
formData,
success: (uploadFileRes) => {
try {
const data = uploadFileRes.data
onSuccess(data)
}
catch (err) {
onError(err)
}
},
fail: (err) => {
console.error('Upload failed:', err)
onError(err)
},
complete: onComplete,
})
}

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

@ -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 { LOGIN_PAGE } from '@/router/config'
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) {
// 切换到登录页
await uni.reLaunch({ url: LOGIN_PAGE })
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

@ -0,0 +1,207 @@
import type { IDoubleTokenRes } from '@/api/types/login'
import type { CustomRequestOptions, IResponse } from '@/http/types'
import { nextTick } from 'vue'
import { LOGIN_PAGE } from '@/router/config'
import { useTokenStore } from '@/store/token'
import { isDoubleTokenMode } from '@/utils'
import { ResultEnum } from './tools/enum'
// 刷新 token 状态管理
let refreshing = false // 防止重复刷新 token 标识
let taskQueue: (() => void)[] = [] // 刷新 token 请求队列
export function http<T>(options: CustomRequestOptions) {
// 1. 返回 Promise 对象
return new Promise<T>((resolve, reject) => {
uni.request({
...options,
dataType: 'json',
// #ifndef MP-WEIXIN
responseType: 'json',
// #endif
// 响应成功
success: async (res) => {
const responseData = res.data as IResponse<T>
const { code } = responseData
// 检查是否是401错误包括HTTP状态码401或业务码401
const isTokenExpired = res.statusCode === 401 || code === 401
if (isTokenExpired) {
const tokenStore = useTokenStore()
if (!isDoubleTokenMode) {
// 未启用双token策略清理用户信息跳转到登录页
tokenStore.logout()
uni.navigateTo({ url: LOGIN_PAGE })
return reject(res)
}
/* -------- 无感刷新 token ----------- */
const { refreshToken } = tokenStore.tokenInfo as IDoubleTokenRes || {}
// token 失效的,且有刷新 token 的,才放到请求队列里
if (refreshToken) {
taskQueue.push(() => {
resolve(http<T>(options))
})
}
// 如果有 refreshToken 且未在刷新中,发起刷新 token 请求
if (refreshToken && !refreshing) {
refreshing = true
try {
// 发起刷新 token 请求(使用 store 的 refreshToken 方法)
await tokenStore.refreshToken()
// 刷新 token 成功
refreshing = false
nextTick(() => {
// 关闭其他弹窗
uni.hideToast()
uni.showToast({
title: 'token 刷新成功',
icon: 'none',
})
})
// 将任务队列的所有任务重新请求
taskQueue.forEach(task => task())
}
catch (refreshErr) {
console.error('刷新 token 失败:', refreshErr)
refreshing = false
// 刷新 token 失败,跳转到登录页
nextTick(() => {
// 关闭其他弹窗
uni.hideToast()
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none',
})
})
// 清除用户信息
await tokenStore.logout()
// 跳转到登录页
setTimeout(() => {
uni.navigateTo({ url: LOGIN_PAGE })
}, 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 || '请求错误',
})
// 这里不直接抛出错误会中断promise链条无法进入finally在下方return resolve(responseData as T)继续执行在接口调用可通过判断code来处理业务逻辑
// async getSampleEnums() {
// const res = await getSampleEnumsApi();
// if (res.code === ResultEnum.Success200) {
// // 处理成功
// }
// },
// throw new Error(`请求错误[${code}]${responseData.message || responseData.msg}`)
}
return resolve(responseData as T)
}
// 处理其他错误
!options.hideErrorToast
&& uni.showToast({
icon: 'none',
title: (res.data as any).msg || '请求错误',
})
reject(res)
},
// 响应失败
fail(err) {
uni.showToast({
icon: 'none',
title: '网络错误,换个网络试试',
})
reject(err)
},
})
})
}
/**
* GET
* @param url
* @param query query
* @param header json
* @returns
*/
export function httpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
method: 'GET',
header,
...options,
})
}
/**
* POST
* @param url
* @param data body
* @param query querypostquery
* @param header json
* @returns
*/
export function httpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
data,
method: 'POST',
header,
...options,
})
}
/**
* PUT
*/
export function httpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
data,
query,
method: 'PUT',
header,
...options,
})
}
/**
* DELETE query
*/
export function httpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
method: 'DELETE',
header,
...options,
})
}
// 支持与 axios 类似的API调用
http.get = httpGet
http.post = httpPost
http.put = httpPut
http.delete = httpDelete
// 支持与 alovaJS 类似的API调用
http.Get = httpGet
http.Post = httpPost
http.Put = httpPut
http.Delete = httpDelete

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

@ -0,0 +1,68 @@
export enum ResultEnum {
// 0和200当做成功都很普遍这里直接兼容两者PS0和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},请检查网络或联系管理员!`
}

@ -0,0 +1,29 @@
/**
* URL qs
*
* @param obj
* @returns
*/
export function stringifyQuery(obj: Record<string, any>): string {
if (!obj || typeof obj !== 'object' || Array.isArray(obj))
return ''
return Object.entries(obj)
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
// 对键进行编码
const encodedKey = encodeURIComponent(key)
// 处理数组类型
if (Array.isArray(value)) {
return value
.filter(item => item !== undefined && item !== null)
.map(item => `${encodedKey}=${encodeURIComponent(item)}`)
.join('&')
}
// 处理基本类型
return `${encodedKey}=${encodeURIComponent(value)}`
})
.join('&')
}

@ -0,0 +1,41 @@
/**
* uniapp RequestOptions IUniUploadFileOptions
*/
export type CustomRequestOptions = UniApp.RequestOptions & {
query?: Record<string, any>
/** 出错时是否隐藏错误提示 */
hideErrorToast?: boolean
} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
export interface HttpRequestResult<T> {
promise: Promise<T>
requestTask: UniApp.RequestTask
}
// 通用响应格式(兼容 msg + message 字段)
export type IResponse<T = any> = {
code: number
data: T
message: string
[key: string]: any // 允许额外属性
} | {
code: number
data: T
msg: string
[key: string]: any // 允许额外属性
}
// 分页请求参数
export interface PageParams {
page: number
pageSize: number
[key: string]: any
}
// 分页响应数据
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}

@ -0,0 +1,30 @@
import type { CustomRequestOptions } from '@/http/types'
import { http } from './http'
/*
* openapi-ts-request request
*/
export default function request<T = unknown>(
url: string,
options: Omit<CustomRequestOptions, 'url'> & {
params?: Record<string, unknown>
headers?: Record<string, unknown>
},
) {
const requestOptions = {
url,
...options,
}
if (options.params) {
requestOptions.query = requestOptions.params
delete requestOptions.params
}
if (options.headers) {
requestOptions.header = options.headers
delete requestOptions.headers
}
return http<T>(requestOptions)
}

@ -0,0 +1,10 @@
<script lang="ts" setup>
const testUniLayoutExposedData = ref('testUniLayoutExposedData')
defineExpose({
testUniLayoutExposedData,
})
</script>
<template>
<slot />
</template>

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

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

@ -0,0 +1,30 @@
<script lang="ts" setup>
import { HOME_PAGE } from '@/utils'
definePage({
style: {
// 'custom' 'default'
navigationStyle: 'custom',
},
})
function goBack() {
// pages.config.tstabbar使switchTab
// 使navigateTo
uni.switchTab({ url: HOME_PAGE })
}
</script>
<template>
<view class="h-screen flex flex-col items-center justify-center">
<view> 404 </view>
<view> 页面不存在 </view>
<button class="mt-6 w-40 text-center" @click="goBack">
返回首页
</button>
</view>
</template>
<style lang="scss" scoped>
//
</style>

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

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

@ -0,0 +1,87 @@
<script lang="ts" setup>
import { useTokenStore } from '@/store/token'
import { useUserStore } from '@/store/user'
import { tabbarList } from '@/tabbar/config'
import { isPageTabbar } from '@/tabbar/store'
import { ensureDecodeURIComponent } from '@/utils'
import { parseUrlToObj } from '@/utils/index'
definePage({
style: {
navigationBarTitleText: '登录',
},
})
const redirectUrl = ref('')
onLoad((options) => {
console.log('login options: ', options)
if (options.redirect) {
redirectUrl.value = ensureDecodeURIComponent(options.redirect)
}
else {
redirectUrl.value = tabbarList[0].pagePath
}
console.log('redirectUrl.value: ', redirectUrl.value)
})
const userStore = useUserStore()
const tokenStore = useTokenStore()
async function doLogin() {
if (tokenStore.hasLogin) {
uni.navigateBack()
return
}
try {
//
await tokenStore.login({
username: '菲鸽',
password: '123456',
})
console.log(redirectUrl.value)
}
catch (error) {
console.log('登录失败', error)
}
let path = redirectUrl.value
if (!path.startsWith('/')) {
path = `/${path}`
}
const { path: _path, query } = parseUrlToObj(path)
console.log('_path:', _path, 'query:', query, 'path:', path)
console.log('isPageTabbar(_path):', isPageTabbar(_path))
if (isPageTabbar(_path)) {
// switchTab query , url query ,
// query
uni.switchTab({
url: path,
})
// uni.switchTab({
// url: _path,
// query,
// })
}
else {
// redirectTo navigateBack
// uni.redirectTo({
// url: path,
// })
uni.navigateBack()
}
}
</script>
<template>
<view class="login">
<!-- 本页面是非MP的登录页主要用于 h5 APP -->
<view class="text-center">
登录页
</view>
<button class="mt-4 w-40 text-center" @click="doLogin">
点击模拟登录
</button>
</view>
</template>
<style lang="scss" scoped>
//
</style>

@ -0,0 +1,34 @@
<script lang="ts" setup>
import { LOGIN_PAGE } from '@/router/config'
definePage({
style: {
navigationBarTitleText: '注册',
},
})
function doRegister() {
uni.showToast({
title: '注册成功',
})
//
uni.navigateTo({
url: LOGIN_PAGE,
})
}
</script>
<template>
<view class="login">
<view class="text-center">
注册页
</view>
<button class="mt-4 w-40 text-center" @click="doRegister">
点击模拟注册
</button>
</view>
</template>
<style lang="scss" scoped>
//
</style>

@ -0,0 +1,134 @@
<script lang="ts" setup>
import { isApp, isAppAndroid, isAppHarmony, isAppIOS, isAppPlus, isH5, isMpWeixin, isWeb } from '@uni-helper/uni-env'
import { LOGIN_PAGE } from '@/router/config'
import { useTokenStore } from '@/store'
import RequestOpenApiComp from './components/request-openapi.vue'
import RequestComp from './components/request.vue'
import UploadComp from './components/Upload.vue'
import VBindCss from './components/VBindCss.vue'
definePage({
style: {
navigationBarTitleText: '关于',
},
// () needLogin arc/router
excludeLoginPath: false,
})
const tokenStore = useTokenStore()
// isH5true, isWebfalse isH5
console.log({ isApp, isAppAndroid, isAppHarmony, isAppIOS, isAppPlus, isH5, isMpWeixin, isWeb })
function gotoLogin() {
if (tokenStore.hasLogin) {
uni.showToast({
title: '已登录,不能去登录页',
icon: 'none',
})
return
}
uni.navigateTo({
url: `${LOGIN_PAGE}?redirect=${encodeURIComponent('/pages-sub/about/about?a=1&b=2')}`,
})
}
function logout() {
//
tokenStore.logout()
// 退
uni.showToast({
title: '退出登录成功',
icon: 'success',
})
}
function gotoScroll() {
uni.navigateTo({
url: '/pages-sub/demo/scroll',
})
}
function gotoAlova() {
uni.navigateTo({
url: '/pages-sub/about/alova',
})
}
function gotoSubPage() {
uni.navigateTo({
url: '/pages-sub/demo/index',
})
}
// uniLayout expose onReady onLoad
const uniLayout = ref()
onLoad(() => {
console.log('onLoad:', uniLayout.value) // onLoad: undefined
})
onReady(() => {
console.log('onReady:', uniLayout.value) // onReady: Proxy(Object)
console.log('onReady:', uniLayout.value.testUniLayoutExposedData) // onReady: testUniLayoutExposedData
})
// onShow onReadyonShow
onShow(() => {
console.log('onShow:', uniLayout.value) // onReady: Proxy(Object)
console.log('onShow:', uniLayout.value?.testUniLayoutExposedData) // onReady: testUniLayoutExposedData
})
const uniKuRoot = ref()
// (onShow onReadyonShow
onReady(() => {
console.log('onReady uniKuRoot exposeRef', uniKuRoot.value?.exposeRef)
})
onShow(() => {
console.log('onShow uniKuRoot exposeRef', uniKuRoot.value?.exposeRef)
})
</script>
<template root="uniKuRoot">
<!-- page-meta 使用范例 -->
<page-meta page-style="overflow: auto" />
<view>
<view class="mt-8 text-center text-xl text-gray-400">
请求调用unocssstatic图片
</view>
<view class="my-2 text-center">
<image src="/static/images/avatar.jpg" class="h-100px w-100px" />
</view>
<view class="my-2 text-center">
当前是否登录{{ tokenStore.hasLogin }}
</view>
<view class="m-auto max-w-600px flex items-center">
<button class="mt-4 w-40 text-center" @click="gotoLogin">
点击去登录页
</button>
<button class="mt-4 w-40 text-center" @click="logout">
点击退出登录
</button>
</view>
<RequestOpenApiComp />
<RequestComp />
<UploadComp />
<VBindCss />
<view class="mb-6 h-1px bg-#eee" />
<view class="text-center">
<button type="primary" size="mini" class="w-240px" @click="gotoScroll">
前往上拉和下拉加载更多页面
</button>
</view>
<view class="text-center">
<button type="primary" size="mini" class="w-160px" @click="gotoAlova">
前往 alova 示例页面
</button>
</view>
<view class="text-center">
<button type="primary" size="mini" class="w-160px" @click="gotoSubPage">
前往分包页面
</button>
</view>
<view class="mt-6 text-center text-sm">
<view class="inline-block w-80% text-gray-400">
为了方便脚手架动态生成不同UI模板本页的按钮统一使用UI库无关的原生button
</view>
</view>
<view class="h-6" />
</view>
</template>

@ -0,0 +1,53 @@
<script lang="ts" setup>
import { useRequest } from 'alova/client'
import { foo } from '@/api/foo-alova'
definePage({
style: {
navigationBarTitleText: 'Alova 演示',
},
})
const initialData = undefined
const { loading, data, send } = useRequest(foo, {
initialData,
immediate: true,
})
console.log(data)
function reset() {
data.value = initialData
}
</script>
<template>
<view class="p-6 text-center">
<button type="primary" size="mini" class="my-6 w-160px" @click="send">
发送请求
</button>
<view class="h-16">
<view v-if="loading">
loading...
</view>
<block v-else>
<view class="text-xl">
请求数据如下
</view>
<view class="text-green leading-8">
{{ JSON.stringify(data) }}
</view>
</block>
<view class="text-red">
{{ data?.id }}
</view>
</view>
<button type="default" size="mini" class="my-6 w-160px" @click="reset">
重置数据
</button>
</view>
</template>
<style lang="scss" scoped>
//
</style>

@ -0,0 +1,25 @@
<template>
<view class="p-4 text-center">
<button type="primary" size="mini" class="w-160px" @click="run">
选择图片并上传
</button>
<view v-if="loading" class="h-10 text-blue">
上传...
</view>
<template v-else>
<view class="m-2">
上传后返回的接口数据
</view>
<view class="m-2">
{{ data }}
</view>
<view class="m-auto h-40 max-w-40">
<image v-if="data" :src="data.url" mode="scaleToFill" />
</view>
</template>
</view>
</template>
<script lang="ts" setup>
const { loading, data, run } = useUpload()
</script>

@ -0,0 +1,28 @@
<script lang="ts" setup>
// root 1.3.4
const testBindCssVariable = ref('red')
function changeTestBindCssVariable() {
if (testBindCssVariable.value === 'red') {
testBindCssVariable.value = 'green'
}
else {
testBindCssVariable.value = 'red'
}
}
</script>
<template>
<button class="mt-4 w-60 text-center" @click="changeTestBindCssVariable">
toggle v-bind css变量
</button>
<view class="test-css my-2 text-center">
测试v-bind css变量的具体文案
</view>
</template>
<style lang="scss" scoped>
.test-css {
color: v-bind(testBindCssVariable);
font-size: 24px;
}
</style>

@ -0,0 +1,64 @@
<script lang="ts" setup>
import type { UserItem } from '@/service'
import { infoUsingGet, listAllUsingGet } from '@/service'
const loading = ref(false)
const error = ref<Error | null>(null)
const data = ref<UserItem>()
// openapi
async function getUserInfo() {
try {
loading.value = true
const res = await infoUsingGet({})
console.log(res)
data.value = res
error.value = null
}
catch (err) {
error.value = err as Error
data.value = null
}
finally {
loading.value = false
}
}
// openapi + useRequest
const { data: data2, loading: loading2, run } = useRequest(() => listAllUsingGet({}), {
immediate: false,
})
</script>
<template>
<view class="p-6 text-center">
<view class="my-4 text-center">
1)直接使用 openapi 生成的请求
</view>
<view class="my-4 text-center">
<button type="primary" size="mini" class="w-160px" @click="getUserInfo">
发送请求
</button>
<view class="text-xl">
请求数据如下
</view>
<view class="text-green leading-8">
{{ JSON.stringify(data) }}
</view>
</view>
<view class="my-4 text-center">
2)直接使用 openapi + useRequest 生成的请求
</view>
<view class="my-4 flex items-center gap-2 text-center">
<button type="primary" size="mini" class="w-160px" @click="run">
发送请求
</button>
</view>
<view class="text-xl">
请求数据如下
</view>
<view class="text-green leading-8">
{{ JSON.stringify(data2) }}
</view>
</view>
</template>

@ -0,0 +1,75 @@
<script lang="ts" setup>
import type { IFooItem } from '@/api/foo'
import { getFooAPI } from '@/api/foo'
// const initialData = {
// name: 'initialData',
// id: '1234',
// }
const initialData = undefined
//
async function reqFooAPI() {
try {
const res = await getFooAPI('菲鸽')
console.log('直接请求示例res', res)
}
catch (err) {
console.log(err)
}
}
reqFooAPI()
// useRequest
const { loading, error, data, run } = useRequest<IFooItem>(() => getFooAPI('菲鸽'), {
immediate: true,
initialData,
})
function reset() {
data.value = initialData
}
</script>
<template>
<view class="p-6 text-center">
<view class="my-2">
pages 里面的 vue 文件会扫描成页面将自动添加到 pages.json 里面
</view>
<view class="my-2 text-green-400">
但是 components 里面的 vue 不会
</view>
<view class="my-4 text-center">
<button type="primary" size="mini" class="w-160px" @click="run">
发送请求
</button>
</view>
<view class="h-16">
<view v-if="loading">
loading...
</view>
<block v-else>
<view v-if="error instanceof Error" class="text-red leading-8">
错误: {{ error.message }}
</view>
<view v-else-if="error" class="text-red leading-8">
错误: 未知错误
</view>
<view v-else>
<view class="text-xl">
请求数据如下
</view>
<view class="text-green leading-8">
{{ JSON.stringify(data) }}
</view>
</view>
</block>
</view>
<view class="my-4 text-center">
<button type="warn" size="mini" class="w-160px" :disabled="!data" @click="reset">
重置数据
</button>
</view>
</view>
</template>

@ -0,0 +1,48 @@
<script lang="ts" setup>
import type { IFooItem } from '@/api/foo'
import { getFooAPI } from '@/api/foo'
const recommendUrl = ref('http://laf.run/signup?code=ohaOgIX')
// const initialData = {
// name: 'initialData',
// id: '1234',
// }
const initialData = undefined
const { loading, error, data, run } = useRequest<IFooItem>(() => getFooAPI('菲鸽'), {
immediate: true,
initialData,
})
function reset() {
data.value = initialData
}
</script>
<template>
<view class="p-6 text-center">
<view class="my-2 text-center">
<button type="primary" size="mini" class="w-160px" @click="run">
发送请求
</button>
</view>
<view class="h-16">
<view v-if="loading">
loading...
</view>
<block v-else>
<view class="text-xl">
请求数据如下
</view>
<view class="text-green leading-8">
{{ JSON.stringify(data) }}
</view>
</block>
</view>
<view class="my-6 text-center">
<button type="warn" size="mini" class="w-160px" :disabled="!data" @click="reset">
重置数据
</button>
</view>
</view>
</template>

@ -0,0 +1,40 @@
<script lang="ts" setup>
// code here
import RequestComp from './components/request.vue'
definePage({
style: {
navigationBarTitleText: '分包页面',
},
})
function gotoScroll() {
uni.navigateTo({
url: '/pages-sub/demo/scroll',
})
}
</script>
<template>
<view class="text-center">
<view class="m-8">
http://localhost:9000/#/pages-sub/demo/index
</view>
<view class="my-4 text-green-500">
分包页面demo
</view>
<view class="text-blue-500">
分包页面里面的components示例
</view>
<button class="my-4" type="primary" size="mini" @click="gotoScroll">
跳转到上拉刷新和下拉加载更多
</button>
<view>
<RequestComp />
</view>
</view>
</template>
<style lang="scss" scoped>
//
</style>

@ -0,0 +1,72 @@
<script setup lang="ts">
// uniapp
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { useScroll } from '@/hooks/useScroll'
definePage({
style: {
navigationBarTitleText: '上拉刷新和下拉加载更多',
enablePullDownRefresh: true,
onReachBottomDistance: 100,
},
})
//
function mockFetchData(page: number, pageSize: number): Promise<{ id: number, name: string }[]> {
return new Promise((resolve) => {
setTimeout(() => {
if (page > 5) {
//
resolve([])
return
}
const data = Array.from({ length: pageSize }, (_, i) => ({
id: (page - 1) * pageSize + i + 1,
name: `item ${(page - 1) * pageSize + i + 1}`,
}))
resolve(data)
}, 1000)
})
}
const { list, loading, finished, error, refresh, loadMore } = useScroll({
fetchData: mockFetchData,
pageSize: 10,
})
onPullDownRefresh(async () => {
console.log('onPullDownRefresh')
console.log('onPullDownRefresh')
console.log('onPullDownRefresh')
await refresh()
uni.stopPullDownRefresh()
})
onReachBottom(() => {
loadMore()
})
</script>
<template>
<view class="h-screen p-4">
<view v-if="error" class="text-center text-red-500">
加载失败请重试
</view>
<view v-else>
<view
v-for="item in list"
:key="item.id"
class="my-2 h-20 flex items-center justify-center rounded bg-gray-100"
>
{{ item.name }}
</view>
<view v-if="loading" class="py-4 text-center text-gray-500">
加载中...
</view>
<view v-if="finished" class="py-4 text-center text-gray-500">
没有更多了
</view>
</view>
</view>
</template>

@ -0,0 +1,354 @@
<!--
* @Author: chris
* @Date: 2025-09-15 09:51:59
* @LastEditors: chris
* @LastEditTime: 2025-10-22 11:12:44
-->
<script setup>
import bug from '@/static/svg/bug.svg'
import jiankongyun from '@/static/svg/jiankongyun.svg'
import tianqiyujing from '@/static/svg/tianqiyujing.svg'
import turangshuishi from '@/static/svg/turangshuishi.svg'
definePage({
style: {
navigationBarTitleText: '主页',
},
})
//
const itemDict = {
plantingArea: {
svg: 'nongshi',
text: '种植面积(亩)',
value: 250,
},
terrain: {
svg: 'dixing',
text: '地形',
value: '丘陵山地',
},
variety: {
svg: 'dapengzhongmiaoguanli',
text: '主要品种',
value: '桂味',
},
plantingNum: {
svg: 'shuguo',
text: '种植荔枝(棵)',
value: 8500,
},
}
//
const modules = [
{
id: 'pest',
title: '虫情监控',
icon: bug,
desc: '实时监测果园病虫害情况',
route: '/pages/pest/pest',
},
{
id: 'soil',
title: '土壤墒情',
icon: turangshuishi,
desc: '监测土壤湿度、养分等数据',
route: '/pages/soil/soil',
},
{
id: 'weather',
title: '气象监测',
icon: tianqiyujing,
desc: '温度、湿度、光照等数据',
route: '/pages/weather/index',
},
{
id: 'drone',
title: '无人机巡查',
icon: jiankongyun,
desc: '无人机航拍及数据分析',
route: '/pages/drone/index',
},
]
//
function handleModuleClick(module) {
//
console.log('跳转到:', module.route)
//
uni.navigateTo({
url: module.route,
})
}
</script>
<template>
<view class="home-page">
<!-- 页面标题 -->
<view class="home-title">
<view class="title-container">
<text class="title-text">荔枝园监控系统</text>
<view class="title-accent" />
</view>
</view>
<!-- 首页内容区域 -->
<view class="home-content">
<!-- 果园基本信息卡片 -->
<view class="info-section">
<wd-card hoverable :shadow="false" class="basic-card">
<!-- 基本信息网格 -->
<wd-grid :column="2" :border="false" :gutter="12">
<wd-grid-item
v-for="(item, key) in itemDict"
:key="key"
class="info-item-wrapper"
>
<view class="info-item">
<text class="info-label">{{ item.text }}</text>
<text class="info-value">{{ item.value }}</text>
</view>
</wd-grid-item>
</wd-grid>
</wd-card>
</view>
<!-- 监控模块区域 -->
<view class="monitor-section">
<text class="section-title">实时监控</text>
<!-- 模块网格 -->
<wd-grid :column="2" :border="false" bg-color="transparent" :gutter="15">
<wd-grid-item v-for="module in modules" :key="module.id" class="module-item-wrapper" custom-class="module-item">
<view
class="module-card"
@click="handleModuleClick(module)"
>
<view class="module-icon-container" :class="`module-${module.id}`">
<image :src="module.icon" mode="aspectFit" class="module-icon" />
</view>
<view class="module-info">
<text class="module-title">{{ module.title }}</text>
<text class="module-desc">{{ module.desc }}</text>
</view>
</view>
</wd-grid-item>
</wd-grid>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
// -
$primary-color: #165dff; //
$bg-color: #f5f7fa; //
$card-bg: #ffffff; //
$text-primary: #1d2129; //
$text-secondary: #4e5969; //
$text-tertiary: #86909c; //
$border-radius: 12px; //
$box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); //
$transition: all 0.3s ease; //
//
.home-page {
min-height: 100%;
background-color: $bg-color;
}
//
.home-title {
background: linear-gradient(135deg, $primary-color, #4080ff);
padding: 24px 0;
position: relative;
overflow: hidden;
box-shadow: 0 3px 12px rgba($primary-color, 0.25);
//
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
transform: rotate(30deg);
}
.title-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.title-text {
font-size: 24px;
font-weight: 700;
color: $card-bg;
text-align: center;
letter-spacing: 0.5px;
position: relative;
}
.title-accent {
width: 40px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
opacity: 0.8;
}
}
//
.home-content {
padding-top: 15px;
padding-bottom: 15px;
}
// .info-section,
// .monitor-section {
// margin: 0 -15px;
// }
//
.info-section {
margin-bottom: 24px;
}
//
.info-item-wrapper {
height: 100%;
}
//
.basic-card {
border-radius: $border-radius;
box-shadow: $box-shadow;
overflow: hidden;
}
// -
.info-item {
display: flex;
flex-direction: column;
align-items: center;
// padding: 16px;
height: 100%;
min-height: 64px;
justify-content: center;
text-align: center;
.info-label {
font-size: 13px;
color: $text-tertiary;
margin-bottom: 6px;
}
.info-value {
font-size: 18px;
font-weight: 600;
color: $primary-color;
}
}
//
.monitor-section {
.section-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
display: block;
padding-left: 15px;
}
//
.module-item-wrapper {
height: 100%;
}
:deep(.module-item) {
.wd-grid-item__content {
padding: 0;
}
}
// -
.module-card {
padding: 15px;
background-color: $card-bg;
border-radius: $border-radius;
box-shadow: $box-shadow;
height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
transition: $transition;
overflow: hidden;
}
// -
.module-icon-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
width: 56px;
height: 56px;
border-radius: 8px;
&.module-pest {
background-color: rgba(245, 87, 108, 0.08);
}
&.module-soil {
background-color: rgba(82, 196, 26, 0.08);
}
&.module-weather {
background-color: rgba(66, 153, 225, 0.08);
}
&.module-drone {
background-color: rgba(255, 152, 0, 0.08);
}
}
//
.module-icon {
width: 28px;
height: 28px;
}
//
.module-info {
width: 100%;
}
.module-title {
font-size: 15px;
font-weight: 600;
color: $text-primary;
margin-bottom: 4px;
display: block;
}
.module-desc {
font-size: 12px;
color: $text-tertiary;
line-height: 1.4;
display: block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
</style>

@ -0,0 +1,214 @@
<script lang="ts" setup>
import type { IUploadSuccessInfo } from '@/api/types/login'
import { storeToRefs } from 'pinia'
import { LOGIN_PAGE } from '@/router/config'
import { useUserStore } from '@/store'
import { useTokenStore } from '@/store/token'
import { useUpload } from '@/utils/uploadFile'
definePage({
style: {
navigationBarTitleText: '我的',
},
})
const userStore = useUserStore()
const tokenStore = useTokenStore()
// 使storeToRefsuserInfo
const { userInfo } = storeToRefs(userStore)
// #ifndef MP-WEIXIN
//
const { run: uploadAvatar } = useUpload<IUploadSuccessInfo>(
'/upload',
{},
{
onSuccess: (res) => {
console.log('h5头像上传成功', res)
useUserStore().setUserAvatar(res.url)
},
},
)
// #endif
//
async function handleLogin() {
// #ifdef MP-WEIXIN
//
await tokenStore.wxLogin()
// #endif
// #ifndef MP-WEIXIN
uni.navigateTo({
url: `${LOGIN_PAGE}?redirect=${encodeURIComponent('/pages/me/me')}`,
})
// #endif
}
// #ifdef MP-WEIXIN
//
function onChooseAvatar(e: any) {
console.log('选择头像', e.detail)
const { avatarUrl } = e.detail
const { run } = useUpload<IUploadSuccessInfo>(
'/upload',
{},
{
onSuccess: (res) => {
console.log('wx头像上传成功', res)
useUserStore().setUserAvatar(res.url)
},
},
avatarUrl,
)
run()
}
// #endif
// #ifdef MP-WEIXIN
//
function getUserInfo(e: any) {
console.log(e.detail)
}
// #endif
// 退
function handleLogout() {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
//
useTokenStore().logout()
// 退
uni.showToast({
title: '退出登录成功',
icon: 'success',
})
// #ifdef MP-WEIXIN
//
// uni.reLaunch({ url: '/pages/index/index' })
// #endif
// #ifndef MP-WEIXIN
//
// uni.navigateTo({ url: LOGIN_PAGE })
// #endif
}
},
})
}
</script>
<template>
<view class="profile-container">
<!-- 用户信息区域 -->
<view class="user-info-section">
<!-- #ifdef MP-WEIXIN -->
<button class="avatar-button" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image :src="userInfo.avatar" mode="scaleToFill" class="h-full w-full" />
</button>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="avatar-wrapper" @click="uploadAvatar">
<image :src="userInfo.avatar" mode="scaleToFill" class="h-full w-full" />
</view>
<!-- #endif -->
<view class="user-details">
<!-- #ifdef MP-WEIXIN -->
<input
v-model="userInfo.username"
type="nickname"
class="weui-input"
placeholder="请输入昵称"
>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="username">
{{ userInfo.username }}
</view>
<!-- #endif -->
<view class="user-id">
ID: {{ userInfo.userId }}
</view>
</view>
</view>
<view class="mt-3 break-all px-3">
{{ JSON.stringify(userInfo, null, 2) }}
</view>
<view class="mt-20 px-3">
<view class="m-auto w-160px text-center">
<button v-if="tokenStore.hasLogin" type="warn" class="w-full" @click="handleLogout">
退出登录
</button>
<button v-else type="primary" class="w-full" @click="handleLogin">
登录
</button>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 基础样式 */
.profile-container {
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
// background-color: #f7f8fa;
}
/* 用户信息区域 */
.user-info-section {
display: flex;
align-items: center;
padding: 40rpx;
margin: 30rpx 30rpx 20rpx;
background-color: #fff;
border-radius: 24rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.avatar-wrapper {
width: 160rpx;
height: 160rpx;
margin-right: 40rpx;
overflow: hidden;
border: 4rpx solid #f5f5f5;
border-radius: 50%;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.avatar-button {
height: 160rpx;
width: 160rpx;
padding: 0;
margin-right: 40rpx;
overflow: hidden;
border: 4rpx solid #f5f5f5;
border-radius: 50%;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.user-details {
flex: 1;
}
.username {
margin-bottom: 12rpx;
font-size: 38rpx;
font-weight: 600;
color: #333;
letter-spacing: 0.5rpx;
}
.user-id {
font-size: 28rpx;
color: #666;
}
.user-created {
margin-top: 8rpx;
font-size: 24rpx;
color: #999;
}
</style>

@ -0,0 +1,169 @@
<!--
* @Author: chris
* @Date: 2025-10-21 11:42:40
* @LastEditors: chris
* @LastEditTime: 2025-10-22 10:26:02
-->
<script setup name="line-chart">
import LEchart from '@/uni_modules/lime-echart_1.0.5/components/l-echart/l-echart.vue'
const props = defineProps({
echarts: {
type: Object,
required: true,
},
chartData: {
type: Object,
default: () => ({
dates: ['10/1', '10/2', '10/3', '10/4', '10/5', '10/6', '10/7'],
values: [42, 49, 56, 32, 71, 33, 22],
}),
},
})
const lineChartRef = ref(null)
let lineChart = null
onMounted(async () => {
nextTick(async () => {
await initChart()
})
})
onUnmounted(() => {
if (lineChart)
lineChart.dispose()
})
async function initChart() {
if (!lineChartRef.value || !props.echarts)
return
lineChart = await lineChartRef.value.init(props.echarts)
refreshChart()
}
function refreshChart() {
if (!lineChart)
return
lineChart.showLoading()
lineChart.setOption(createOptions())
lineChart.hideLoading()
}
function createOptions() {
const gradient = new props.echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(77, 171, 247, 0.5)' },
{ offset: 1, color: 'rgba(77, 171, 247, 0.1)' },
])
const option = {
title: {
text: '7天虫情数量趋势',
top: '2%',
textStyle: {
color: '#333',
fontSize: 16,
fontWeight: 'bold',
},
left: 'center',
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderColor: '#333',
textStyle: {
color: '#fff',
},
formatter: (params) => {
const data = params[0]
return `虫情数量: ${Math.round(data.value)}`
},
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
grid: {
left: '3%',
right: '3%',
bottom: '3%',
top: '20%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: props.chartData.dates,
axisLine: {
lineStyle: {
color: '#464646',
},
},
axisLabel: {
color: '#ddd',
},
},
yAxis: [
{
type: 'value',
name: '数量',
nameTextStyle: {
color: '#ddd',
},
min: 0,
max: 'dataMax + 50',
axisLine: {
lineStyle: {
color: '#464646',
},
},
axisLabel: {
color: '#ddd',
},
splitLine: {
lineStyle: {
color: '#2a2a2a',
type: 'dashed',
},
},
},
],
series: [
{
name: '虫情数量',
type: 'line',
data: props.chartData.values,
smooth: true,
symbol: 'circle',
symbolSize: 6,
itemStyle: {
color: '#4dabf7',
},
areaStyle: {
color: gradient,
},
emphasis: {
focus: 'series',
},
},
],
}
return option
}
</script>
<template>
<view class="line-chart">
<l-echart id="lineChart" ref="lineChartRef" type="2d" />
</view>
</template>
<style lang="scss" scoped>
.line-chart {
width: 100%;
height: 300px;
}
</style>

@ -0,0 +1,157 @@
<!--
* @Author: chris
* @Date: 2025-10-21 11:42:40
* @LastEditors: chris
* @LastEditTime: 2025-10-22 10:26:22
-->
<script setup name="pie-chart">
import LEchart from '@/uni_modules/lime-echart_1.0.5/components/l-echart/l-echart.vue'
const props = defineProps({
echarts: {
type: Object,
required: true,
},
chartData: {
type: Object,
default: () => ({
datas: [
{
name: '红蜘蛛',
value: 100,
itemStyle: {
color: '#ffa502',
},
},
{
name: '蚜虫',
value: 170,
itemStyle: {
color: '#ff6b6b',
},
},
{
name: '果蝇',
value: 140,
itemStyle: {
color: '#5f27cd',
},
},
{
name: '其他',
value: 300,
itemStyle: {
color: '#54a0ff',
},
},
],
}),
},
})
const pieChartRef = ref(null)
let pieChart = null
onMounted(async () => {
await initChart()
})
async function initChart() {
if (!pieChartRef.value)
return
pieChart = await pieChartRef.value.init(props.echarts)
refreshChart()
}
onUnmounted(() => {
if (pieChart)
pieChart.dispose()
})
function refreshChart() {
if (!pieChart)
return
pieChart.showLoading()
pieChart.setOption(createOptions())
pieChart.hideLoading()
}
function createOptions() {
const options = {
title: {
text: '虫情分布状况',
top: '2%',
textStyle: {
color: '#333',
fontSize: 16,
fontWeight: 'bold',
},
left: 'center',
},
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderColor: '#333',
textStyle: {
color: '#fff',
},
formatter: '{b}: {c} ({d}%)',
},
legend: {
bottom: '3%',
left: 'center',
textStyle: {
color: '#333',
},
},
series: [
{
name: '虫情分布',
type: 'pie',
radius: ['40%', '70%'],
// center: ['35%', '50%'],
// top: '3%',
// left: '3%',
// right: '3%',
// bottom: '3%',
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: '16',
fontWeight: 'bold',
// color: 'red',
},
},
labelLine: {
show: false,
},
data: props.chartData.datas,
},
],
}
return options
}
</script>
<template>
<view class="pie-chart">
<l-echart id="pieChart" ref="pieChartRef" type="2d" />
</view>
</template>
<style lang="scss" scoped>
.pie-chart {
width: 100%;
height: 300px;
}
</style>

@ -0,0 +1,23 @@
/*
* @Author: chris
* @Date: 2025-10-22 09:16:37
* @LastEditors: chris
* @LastEditTime: 2025-10-22 09:16:48
*/
export const pestTypeDict = {
蚜虫: {
color: '#ff6b6b',
},
红蜘蛛: {
color: '#ffa502',
},
果蝇: {
color: '#5f27cd',
},
菜青虫: {
color: '#10ac84',
},
其他: {
color: '#54a0ff',
},
}

@ -0,0 +1,129 @@
<!--
* @Author: chris
* @Date: 2025-10-17 11:52:06
* @LastEditors: chris
* @LastEditTime: 2025-10-22 14:16:01
-->
<script setup>
const echarts = require('../../uni_modules/lime-echart_1.0.5/static/echarts.min2.js')
import LineChart from './components/lineChart.vue'
import PieChart from './components/pieChart.vue'
definePage({
style: {
navigationBarTitleText: '虫情监测',
},
})
const { proxy } = getCurrentInstance()
const query = uni.createSelectorQuery().in(proxy)
const chartSize = ref({
lineChart: {
width: 0,
height: 0,
},
pieChart: {
width: 0,
height: 0,
},
})
onMounted(async () => {
// await getChartCanvasSize('lineChart')
// await getChartCanvasSize('pieChart')
})
async function getCanvasSize(id) {
return new Promise((resolve, reject) => {
query.select(`#${id}`).boundingClientRect((info) => {
resolve(info)
}).exec()
})
}
async function getChartCanvasSize(id) {
const { width = 0, height = 0 } = await getCanvasSize(id)
chartSize.value[id].width = width
chartSize.value[id].height = height
}
</script>
<template>
<view class="pest-monitor-page">
<wd-card class="pest-info-card">
<view class="pest-total-title">
<view class="title-text">
虫害总数(近7天)
</view>
<view class="update-time">
更新时间: {{ '2023-10-17 11:52:06' }}
</view>
</view>
<view class="pest-total-count">
{{ 12345 }}
</view>
</wd-card>
<!-- 折线图区域 -->
<wd-card class="chart-card">
<!-- <view class="chart-title">
近7天虫害趋势
</view> -->
<!-- <l-echart id="lineChart" ref="lineChartRef" class="chart" /> -->
<line-chart id="lineChart" :echarts="echarts" />
</wd-card>
<!-- 饼图区域 -->
<wd-card class="chart-card">
<!-- <view class="chart-title">
虫害类型分布
</view> -->
<pie-chart id="pieChart" :echarts="echarts" />
</wd-card>
</view>
</template>
<style lang="scss" scoped>
$bg-color: #f5f7fa;
.pest-monitor-page {
@apply py-15px min-h-[100vh];
background-color: $bg-color;
> .wd-card {
@apply p-15px;
}
}
.pest-info-card {
.pest-total-title {
@apply flex flex-col items-center justify-between mb-20px;
.title-text {
@apply text-20px font-bold tracking-[4px] mb-2px;
color: $-color-theme;
}
.update-time {
@apply text-12px;
color: $-color-secondary;
}
}
.pest-total-count {
@apply text-center font-size-20px font-600;
color: $-color-warning;
}
}
.chart-card {
.chart-title {
@apply mb-20px font-bold;
}
}
.chart {
@apply w-[100%] h-[300px];
}
</style>

@ -0,0 +1,433 @@
<!--
* @Author: chris
* @Date: 2025-10-22 10:46:12
* @LastEditors: chris
* @LastEditTime: 2025-10-22 11:24:24
-->
<script setup lang="ts">
//
definePage({
navigationBarTitleText: '土壤检测',
})
//
interface SoilIndicator {
id: string
name: string
value: number
unit: string
color: string
description: string
}
//
interface TimeSeriesData {
time: string
value: number
}
//
const soilIndicators: SoilIndicator[] = [
{ id: 'temperature', name: '温度', value: 25.5, unit: '°C', color: '#FF6B6B', description: '土壤温度' },
{ id: 'humidity', name: '湿度', value: 65.2, unit: '%', color: '#4ECDC4', description: '土壤湿度' },
{ id: 'ph', name: 'pH值', value: 6.8, unit: '', color: '#45B7D1', description: '土壤酸碱度' },
{ id: 'ec', name: '电导率', value: 2.3, unit: 'mS/cm', color: '#FF8C42', description: '土壤电导率(EC)' },
{ id: 'nitrogen', name: '氮含量', value: 120, unit: 'mg/kg', color: '#96CEB4', description: '土壤氮含量' },
{ id: 'phosphorus', name: '磷含量', value: 45, unit: 'mg/kg', color: '#FFEAA7', description: '土壤磷含量' },
{ id: 'potassium', name: '钾含量', value: 180, unit: 'mg/kg', color: '#DDA0DD', description: '土壤钾含量' },
]
//
const selectedIndicator = ref<string>('temperature')
// 24
function generateTimeSeriesData(indicatorId: string): TimeSeriesData[] {
const data: TimeSeriesData[] = []
const now = new Date()
//
let baseValue = 0
let min = 0
let max = 0
switch (indicatorId) {
case 'temperature':
baseValue = 25
min = 20
max = 30
break
case 'humidity':
baseValue = 65
min = 50
max = 80
break
case 'ph':
baseValue = 6.8
min = 5.5
max = 8.0
break
case 'ec':
baseValue = 2.3
min = 0.5
max = 4.0
break
case 'nitrogen':
baseValue = 120
min = 80
max = 160
break
case 'phosphorus':
baseValue = 45
min = 20
max = 70
break
case 'potassium':
baseValue = 180
min = 140
max = 220
break
}
// 24
for (let i = 23; i >= 0; i--) {
const time = new Date(now.getTime() - i * 60 * 60 * 1000)
const hour = time.getHours()
//
const timeFactor = Math.sin((hour / 24) * Math.PI * 2) * 0.1
//
const randomFactor = (Math.random() - 0.5) * 0.2
let value = baseValue * (1 + timeFactor + randomFactor)
//
value = Math.max(min, Math.min(max, value))
//
if (indicatorId === 'ph' || indicatorId === 'ec') {
value = Number.parseFloat(value.toFixed(1))
}
else {
value = Math.round(value * 10) / 10
}
data.push({
time: `${hour.toString().padStart(2, '0')}:00`,
value,
})
}
return data
};
//
const timeSeriesData = ref<TimeSeriesData[]>([])
//
const chartOption = ref<any>({})
//
function handleIndicatorClick(indicatorId: string) {
selectedIndicator.value = indicatorId
updateChart()
};
//
function updateChart() {
const currentIndicator = soilIndicators.find(ind => ind.id === selectedIndicator.value)
if (!currentIndicator)
return
timeSeriesData.value = generateTimeSeriesData(selectedIndicator.value)
chartOption.value = {
title: {
text: `${currentIndicator.name} 24小时变化趋势`,
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal',
},
},
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const data = params[0]
return `${data.axisValue}<br/>${currentIndicator.name}: ${data.value} ${currentIndicator.unit}`
},
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '20%',
containLabel: true,
},
xAxis: {
type: 'category',
data: timeSeriesData.value.map(item => item.time),
axisLabel: {
rotate: 45,
fontSize: 10,
},
},
yAxis: {
type: 'value',
name: currentIndicator.unit,
nameLocation: 'middle',
nameGap: 30,
},
series: [
{
data: timeSeriesData.value.map(item => item.value),
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
itemStyle: {
color: currentIndicator.color,
},
lineStyle: {
width: 2,
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: `${currentIndicator.color}80`, // 80
},
{
offset: 1,
color: `${currentIndicator.color}20`, // 20
},
],
},
},
},
],
}
};
//
onMounted(() => {
updateChart()
})
</script>
<template>
<view class="soil-monitor-page">
<!-- 页面标题 -->
<view class="page-header">
<text class="header-title">土壤检测数据</text>
<text class="header-subtitle">实时监测土壤各项指标</text>
</view>
<!-- 土壤指标卡片网格 -->
<view class="indicators-grid">
<view
v-for="indicator in soilIndicators"
:key="indicator.id"
class="indicator-card"
:class="{ active: selectedIndicator === indicator.id }"
:style="{ borderColor: indicator.color }"
@click="handleIndicatorClick(indicator.id)"
>
<view class="indicator-name">
{{ indicator.name }}
</view>
<view class="indicator-value" :style="{ color: indicator.color }">
{{ indicator.value }}<span class="indicator-unit">{{ indicator.unit }}</span>
</view>
<view class="indicator-description">
{{ indicator.description }}
</view>
</view>
</view>
<!-- 曲线图区域 -->
<view class="chart-container">
<lime-echart
class="soil-chart"
:option="chartOption"
:canvas-id="`soil-chart-${selectedIndicator}`"
:lazy-load="true"
/>
</view>
</view>
</template>
<style lang="scss" scoped>
.soil-monitor-page {
padding: 20rpx;
background-color: #f8f8f8;
min-height: 100vh;
}
.page-header {
text-align: center;
margin-bottom: 30rpx;
}
.header-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.header-subtitle {
font-size: 24rpx;
color: #666;
display: block;
margin-top: 10rpx;
}
.indicators-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300rpx, 1fr));
gap: 20rpx;
margin-bottom: 30rpx;
justify-content: center;
}
.indicator-card {
background-color: #fff;
padding: 16rpx 20rpx;
border-radius: 16rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
border-left: 8rpx solid #e0e0e0;
transition: all 0.3s ease;
cursor: pointer;
min-width: 280rpx;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 120rpx;
height: auto;
}
.indicator-card.active {
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
transform: translateY(-2rpx);
}
.indicator-name {
font-size: 26rpx;
color: #666;
margin-bottom: 4rpx;
}
.indicator-value {
font-size: 34rpx;
font-weight: bold;
margin-bottom: 2rpx;
}
.indicator-unit {
font-size: 22rpx;
font-weight: normal;
margin-left: 4rpx;
}
.indicator-description {
font-size: 18rpx;
color: #999;
margin-top: 4rpx;
}
.chart-container {
background-color: #fff;
border-radius: 16rpx;
padding: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
height: 600rpx;
}
.soil-chart {
width: 100%;
height: 100%;
}
//
@media screen and (min-width: 768px) {
.indicators-grid {
grid-template-columns: repeat(3, 1fr);
max-width: 900px;
margin-left: auto;
margin-right: auto;
}
// 7 - 使auto-fit7
.indicators-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280rpx, 1fr));
gap: 24rpx;
justify-items: center;
}
// 7
.indicators-grid {
--auto-grid-min-size: 280rpx;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--auto-grid-min-size), 1fr));
grid-gap: 24rpx;
justify-content: center;
}
//
.indicator-card {
min-width: 280rpx;
width: 100%;
max-width: 320rpx;
}
}
@media screen and (min-width: 1024px) {
.indicators-grid {
max-width: 1200px;
--auto-grid-min-size: 260rpx;
}
// 7
.indicators-grid {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: auto auto;
}
// 5-7使
.indicators-grid > .indicator-card:nth-child(5) {
grid-column: 1;
grid-row: 2;
}
.indicators-grid > .indicator-card:nth-child(6) {
grid-column: 2;
grid-row: 2;
}
.indicators-grid > .indicator-card:nth-child(7) {
grid-column: 3;
grid-row: 2;
grid-column-end: 5; /* 让第7个卡片占据更宽的空间平衡布局 */
max-width: none;
}
// 7使
.indicators-grid > .indicator-card:nth-child(7) {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
text-align: center;
}
.indicators-grid > .indicator-card:nth-child(7) .indicator-info {
grid-column: 1 / -1;
}
.indicators-grid > .indicator-card:nth-child(7) .indicator-value-container {
grid-column: 1 / -1;
margin: 10rpx 0;
}
}
</style>

@ -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登录页` 的登录逻辑。

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

@ -0,0 +1,127 @@
import { isMp } from '@uni-helper/uni-env'
/**
* by on 2025-08-19
*
* config.ts EXCLUDE_LOGIN_PATH_LIST
*/
import { useTokenStore } from '@/store/token'
import { isPageTabbar, tabbarStore } from '@/tabbar/store'
import { getAllPages, getLastPage, HOME_PAGE, parseUrlToObj } from '@/utils/index'
import { EXCLUDE_LOGIN_PATH_LIST, isNeedLoginMode, LOGIN_PAGE, LOGIN_PAGE_ENABLE_IN_MP, NOT_FOUND_PAGE } from './config'
export const FG_LOG_ENABLE = false
export function judgeIsExcludePath(path: string) {
const isDev = import.meta.env.DEV
if (!isDev) {
return EXCLUDE_LOGIN_PATH_LIST.includes(path)
}
const allExcludeLoginPages = getAllPages('excludeLoginPath') // dev 环境下,需要每次都重新获取,否则新配置就不会生效
return EXCLUDE_LOGIN_PATH_LIST.includes(path) || (isDev && allExcludeLoginPages.some(page => page.path === path))
}
export const navigateToInterceptor = {
// 注意这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同
// 增加对相对路径的处理BY 网友 @ideal
invoke({ url, query }: { url: string, query?: Record<string, string> }) {
if (url === undefined) {
return
}
let { path, query: _query } = parseUrlToObj(url)
FG_LOG_ENABLE && console.log('\n\n路由拦截器:-------------------------------------')
FG_LOG_ENABLE && console.log('路由拦截器 1: url->', url, ', query ->', query)
const myQuery = { ..._query, ...query }
// /pages/route-interceptor/index?name=feige&age=30
FG_LOG_ENABLE && console.log('路由拦截器 2: path->', path, ', _query ->', _query)
FG_LOG_ENABLE && console.log('路由拦截器 3: myQuery ->', myQuery)
// 处理相对路径
if (!path.startsWith('/')) {
const currentPath = getLastPage()?.route || ''
const normalizedCurrentPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}`
const baseDir = normalizedCurrentPath.substring(0, normalizedCurrentPath.lastIndexOf('/'))
path = `${baseDir}/${path}`
}
// 处理路由不存在的情况
if (getAllPages().every(page => page.path !== path) && path !== '/') {
console.warn('路由不存在:', path)
uni.navigateTo({ url: NOT_FOUND_PAGE })
return false // 明确表示阻止原路由继续执行
}
// 处理直接进入路由非首页时tabbarIndex 不正确的问题
tabbarStore.setAutoCurIdx(path)
// 小程序里面使用平台自带的登录,则不走下面的逻辑
if (isMp && !LOGIN_PAGE_ENABLE_IN_MP) {
return true // 明确表示允许路由继续执行
}
const tokenStore = useTokenStore()
FG_LOG_ENABLE && console.log('tokenStore.hasLogin:', tokenStore.hasLogin)
// 不管黑白名单,登录了就直接去吧(但是当前不能是登录页)
if (tokenStore.hasLogin) {
if (path !== LOGIN_PAGE) {
return true // 明确表示允许路由继续执行
}
else {
console.log('已经登录,但是还在登录页', myQuery.redirect)
const url = myQuery.redirect || HOME_PAGE
if (isPageTabbar(url)) {
uni.switchTab({ url })
}
else {
uni.navigateTo({ url })
}
return false // 明确表示阻止原路由继续执行
}
}
let fullPath = path
if (Object.keys(myQuery).length) {
fullPath += `?${Object.keys(myQuery).map(key => `${key}=${myQuery[key]}`).join('&')}`
}
const redirectUrl = `${LOGIN_PAGE}?redirect=${encodeURIComponent(fullPath)}`
// #region 1/2 默认需要登录的情况(白名单策略) ---------------------------
if (isNeedLoginMode) {
// 需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示白名单,可以直接通过
if (judgeIsExcludePath(path)) {
return true // 明确表示允许路由继续执行
}
// 否则需要重定向到登录页
else {
if (path === LOGIN_PAGE) {
return true // 明确表示允许路由继续执行
}
FG_LOG_ENABLE && console.log('1 isNeedLogin(白名单策略) redirectUrl:', redirectUrl)
uni.navigateTo({ url: redirectUrl })
return false // 明确表示阻止原路由继续执行
}
}
// #endregion 1/2 默认需要登录的情况(白名单策略) ---------------------------
// #region 2/2 默认不需要登录的情况(黑名单策略) ---------------------------
else {
// 不需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示黑名单,需要重定向到登录页
if (judgeIsExcludePath(path)) {
FG_LOG_ENABLE && console.log('2 isNeedLogin(黑名单策略) redirectUrl:', redirectUrl)
uni.navigateTo({ url: redirectUrl })
return false // 修改为false阻止原路由继续执行
}
return true // 明确表示允许路由继续执行
}
// #endregion 2/2 默认不需要登录的情况(黑名单策略) ---------------------------
},
}
export const routeInterceptor = {
install() {
uni.addInterceptor('navigateTo', navigateToInterceptor)
uni.addInterceptor('reLaunch', navigateToInterceptor)
uni.addInterceptor('redirectTo', navigateToInterceptor)
uni.addInterceptor('switchTab', navigateToInterceptor)
},
}

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

@ -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/info */
export async function infoUsingGet({
options,
}: {
options?: CustomRequestOptions;
}) {
return request<API.UserItem>('/user/info', {
method: 'GET',
...(options || {}),
});
}

@ -0,0 +1,19 @@
/* eslint-disable */
// @ts-ignore
import request from '@/http/vue-query';
import { CustomRequestOptions } from '@/http/types';
import * as API from './types';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
/** 用户列表 GET /user/listAll */
export async function listAllUsingGet({
options,
}: {
options?: CustomRequestOptions;
}) {
return request<API.UserItem[]>('/user/listAll', {
method: 'GET',
...(options || {}),
});
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

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

Loading…
Cancel
Save