🎉 initial commit

This commit is contained in:
2025-09-14 16:49:40 +08:00
commit ee0fa309ca
176 changed files with 16837 additions and 0 deletions

3
.commitlintrc.cjs Normal file
View File

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

View File

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

View File

@@ -0,0 +1,41 @@
# 开发工作流程
## 项目启动
1. 安装依赖:`pnpm install`
2. 开发环境:
- H5: `pnpm dev` 或 `pnpm dev:h5`
- 微信小程序: `pnpm dev:mp`
- APP: `pnpm dev:app`
## 代码规范
- 使用 ESLint 进行代码检查:`pnpm lint`
- 自动修复代码格式:`pnpm lint:fix`
- 使用 eslint 格式化代码
- 遵循 TypeScript 严格模式
## 构建和部署
- H5 构建:`pnpm build:h5`
- 小程序构建:`pnpm build:mp`
- APP 构建:`pnpm build:app`
- 类型检查:`pnpm type-check`
## 开发工具
- 推荐使用 VSCode 编辑器
- 安装 Vue 和 TypeScript 相关插件
- 使用 uni-app 开发者工具调试小程序
- 使用 HBuilderX 调试 APP
## 调试技巧
- 使用 console.log 和 uni.showToast 调试
- 利用 Vue DevTools 调试组件状态
- 使用网络面板调试 API 请求
- 平台差异测试和兼容性检查
## 性能优化
- 使用懒加载和代码分割
- 优化图片和静态资源
- 减少不必要的重渲染
- 合理使用缓存策略
---
description: 开发工作流程和最佳实践指南
---

View File

@@ -0,0 +1,34 @@
---
alwaysApply: true
---
# 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/` - 底部导航栏
## 开发命令
- `pnpm dev` - 开发 H5 版本
- `pnpm dev:mp` - 开发微信小程序
- `pnpm dev:app` - 开发 APP 版本
- `pnpm build` - 构建生产版本

View File

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

View File

@@ -0,0 +1,62 @@
# uni-app 开发规范
## 页面开发
- 页面文件放在 [src/pages/](mdc:src/pages/) 目录下
- 使用约定式路由,文件名即路由路径
- 页面配置在仅需要在 `route-block` 中配置标题等内容即可,会自动生成到 `pages.json` 中
## 组件开发
- 组件文件放在 [src/components/](mdc:src/components/) 目录下
- 使用 uni-app 内置组件和第三方组件库
- 支持 wot-design-uni\uv-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
---

View File

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

13
.editorconfig Normal file
View File

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

31
.github/release.yml vendored Normal file
View File

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

188
.github/workflows/auto-merge.yml vendored Normal file
View File

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

119
.github/workflows/release-log.yml vendored Normal file
View File

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

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# 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
src/manifest.json
src/pages.json
# lock 文件还是不要了,我主要的版本写死就好了
pnpm-lock.yaml
package-lock.json
# TIPS如果某些文件已经加入了版本管理现在重新加入 .gitignore 是不生效的,需要执行下面的操作
# `git rm -r --cached .` 然后提交 commit 即可。
# git rm -r --cached file1 file2 ## 针对某些文件
# git rm -r --cached dir1 dir2 ## 针对某些文件夹
# git rm -r --cached . ## 针对所有文件
# 更新 uni-app 官方版本
# npx @dcloudio/uvm@latest

1
.husky.back/commit-msg Normal file
View File

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

1
.husky.back/pre-commit Normal file
View File

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

8
.npmrc Normal file
View File

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

View File

@@ -0,0 +1,118 @@
# 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/` - 底部导航栏
## 开发命令
- `pnpm dev` - 开发 H5 版本
- `pnpm dev:mp` - 开发微信小程序
- `pnpm dev:app` - 开发 APP 版本
- `pnpm build` - 构建生产版本
## Vue 组件规范
- 使用 Composition API 和 `<script setup>` 语法
- 组件文件使用 PascalCase 命名
- 页面文件放在 `src/pages/` 目录下
- 组件文件放在 `src/components/` 目录下
## TypeScript 规范
- 严格使用 TypeScript避免使用 `any` 类型
- 为 API 响应数据定义接口类型
- 使用 `interface` 定义对象类型,`type` 定义联合类型
- 导入类型时使用 `import type` 语法
## 状态管理
- 使用 Pinia 进行状态管理
- Store 文件放在 `src/store/` 目录下
- 使用 `defineStore` 定义 store
- 支持持久化存储
## UnoCSS 原子化 CSS
- 项目使用 UnoCSS 作为原子化 CSS 框架
- 配置在 [uno.config.ts](mdc:uno.config.ts)
- 支持预设和自定义规则
- 优先使用原子化类名,减少自定义 CSS
## Vue SFC 组件规范
- `<script setup>` 标签必须是第一个子元素
- `<template>` 标签必须是第二个子元素
- `<style scoped>` 标签必须是最后一个子元素(因为推荐使用原子化类名,所以很可能没有)
## 页面开发
- 页面文件放在 [src/pages/](mdc:src/pages/) 目录下
- 使用约定式路由,文件名即路由路径
- 页面配置在仅需要在 `route-block` 中配置标题等内容即可,会自动生成到 `pages.json`
## 组件开发
- 组件文件放在 [src/components/](mdc:src/components/) 目录下
- 使用 uni-app 内置组件和第三方组件库
- 支持 wot-design-uni\uv-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 规范
- 注意页面栈和导航管理

19
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,19 @@
{
"recommendations": [
"vue.volar",
"stylelint.vscode-stylelint",
"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",
"foxundermoon.shell-format",
"christian-kohler.path-intellisense"
]
}

95
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,95 @@
{
// 配置语言的文件关联
"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",
"pages.config.ts": "manifest.config.ts,openapi-ts-request.config.ts",
"package.json": "tsconfig.json,pnpm-lock.yaml,pnpm-workspace.yaml,LICENSE,.gitattributes,.gitignore,.gitpod.yml,CNAME,.npmrc,.browserslistrc",
"eslint.config.mjs": ".commitlintrc.*,.prettier*,.editorconfig,.commitlint.cjs,.eslint*"
},
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
],
"cSpell.words": [
"alova",
"Aplipay",
"attributify",
"chooseavatar",
"climblee",
"commitlint",
"dcloudio",
"iconfont",
"oxlint",
"qrcode",
"refresherrefresh",
"scrolltolower",
"tabbar",
"Toutiao",
"uniapp",
"unibest",
"unocss",
"uview",
"uvui",
"Wechat",
"WechatMiniprogram",
"Weixin"
]
}

77
.vscode/vue3.code-snippets vendored Normal file
View File

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

21
LICENSE Normal file
View File

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

93
README.md Normal file
View File

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

3
codes/README.md Normal file
View File

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

222
codes/router.txt Normal file
View File

@@ -0,0 +1,222 @@
import { getCurrentInstance, type App } from 'vue'
import { useUserLoginStore } from '@/store/login'
import { Pages } from './pages'
import { LoginPopupViewer } from './loginPopupServices'
import Loading from './Loading'
/** 实时判断用户是否已登录(避免 computed 缓存) */
function isUserLoggedIn(): boolean {
return useUserLoginStore().isLoggedIn
}
// 路由相关配置
// 这里可以根据实际情况调整
// 例如:需要登录验证的页面等
// 以及登录页面、会员中心页面等
// 需要登录验证的页面
const authPages = [
Pages.USER_INFO_EDIT,
Pages.VIP_CENTER,
//Pages.PRODUCT_LIST,
//Pages.PRODUCT_DETAILS,
Pages.USER_ACCOUNT_SECURITY,
Pages.USER_EDIT_NICKNAME,
Pages.USER_ORDER_LIST,
Pages.USER_ORDER_DETAILS,
Pages.USER_MOBILE,
Pages.DISTRIBUTION_CENTER,
Pages.DISTRIBUTION_CENTER_DETAILS,
Pages.USER_MOBILE_CHANGE,
Pages.USER_PERSONAL_INFO,
Pages.USER_REMARK,
Pages.PRODUCT_ORDER_CONFIRM,
Pages.PRODUCT_PAY_MODE,
Pages.COUPON_CENTER,
Pages.COUPON_LIST,
Pages.CUSTOMER_SERVICE,
Pages.SHIPPING_ADDRESS_ADDED_OR_EDIT,
Pages.SHIPPING_ADDRESS_LIST,
Pages.USER_PASSWORD_CONFIG,
Pages.WITHDRAWAL,
Pages.WITHDRAWAL_RECORD_LIST,
]
/** 判断是否需要登录 */
function getBasePath(url: string): string {
const index = url.indexOf('?')
return index !== -1 ? url.substring(0, index) : url
}
function isAuthRequired(url: string): boolean {
const cleanUrl = getBasePath(url)
console.log(`URL数据源:${authPages}`)
console.log(`URL原始值: ${url}`)
console.log(`URL过滤值: ${cleanUrl}`)
return authPages.some((item) => item === cleanUrl)
}
/** 缓存跳转路径 */
function cacheRedirect(url: string) {
uni.setStorageSync('pending_redirect', url)
}
/** 读取并清除缓存跳转路径 */
function consumeRedirect(): string | null {
const url = uni.getStorageSync('pending_redirect')
uni.removeStorageSync('pending_redirect')
return url || null
}
/** 路由核心跳转方法 */
async function internalNavigate(
type: 'navigateTo' | 'redirectTo' | 'switchTab' | 'reLaunch',
url: string,
options: Record<string, any> = {},
) {
const originUrl: string = url.startsWith('/') ? url : `/${url}`
const isAuthPage = isAuthRequired(originUrl)
console.log(`[Router][${type}] 跳转到:`, originUrl, '需要登录:', isAuthPage)
console.log(`[Router][${type}] 是否登录:`, isUserLoggedIn)
// 如果需要登录但未登录,则弹出登录框
if (isAuthPage && !isUserLoggedIn()) {
cacheRedirect(originUrl)
const loginResult = await LoginPopupViewer.open()
console.log(`[Router][${type}] 登录弹窗结果:`, loginResult)
// 如果登录失败(或用户取消),中断跳转
if (!loginResult) {
console.log(`[Router][${type}] 已终止跳转,原因:用户未登录或取消登录`)
Loading.showError({ msg: '已取消登录' })
return
}
}
// 登录状态已满足,可以安全跳转
try {
switch (type) {
case 'navigateTo':
return await uniNavigateTo(originUrl, options)
case 'redirectTo':
return await uniRedirectTo(originUrl, options)
case 'switchTab':
return await uniSwitchTab(originUrl)
case 'reLaunch':
return await uniReLaunch(originUrl)
}
} catch (error) {
console.error(`[Router][${type}] 跳转失败:`, error)
}
}
/** ✅ Promise 封装 uni API **/
function uniNavigateTo(url: string, options: any) {
return new Promise((resolve, reject) => {
uni.navigateTo({
url,
...options,
success: resolve,
fail: reject,
})
})
}
function uniRedirectTo(url: string, options: any) {
return new Promise((resolve, reject) => {
uni.redirectTo({
url,
...options,
success: resolve,
fail: reject,
})
})
}
function uniSwitchTab(url: string) {
return new Promise((resolve, reject) => {
uni.switchTab({
url,
success: resolve,
fail: reject,
})
})
}
function uniReLaunch(url: string) {
return new Promise((resolve, reject) => {
uni.reLaunch({
url,
success: resolve,
fail: reject,
})
})
}
// ✅ Router API 对象
// ✅ Router API 对象
export const Router = {
// 页面跳转,支持登录鉴权
async navigateTo(opt: { url: string; requiresAuth?: boolean } & Record<string, any>) {
return await internalNavigate('navigateTo', opt.url, opt)
},
// 页面重定向,支持登录鉴权
async redirectTo(opt: { url: string; requiresAuth?: boolean } & Record<string, any>) {
return await internalNavigate('redirectTo', opt.url, opt)
},
// tab 页面切换
async switchTab(opt: { url: string }) {
return await internalNavigate('switchTab', opt.url, opt)
},
// 重新启动应用跳转
async reLaunch(opt: { url: string }) {
return await internalNavigate('reLaunch', opt.url, opt)
},
// 重定向别名
async replace(opt: { url: string; requiresAuth?: boolean } & Record<string, any>) {
return await internalNavigate('redirectTo', opt.url, opt)
},
// 返回上一级
async back(delta = 1) {
return await new Promise((resolve, reject) => {
uni.navigateBack({
delta,
success: resolve,
fail: reject,
})
})
},
consumeRedirect,
}
let cachedRouter: typeof Router | null = null
/**
* ✅ 全局安全获取 $Router 实例(推荐使用)
*/
export function useRouter(): typeof Router {
if (cachedRouter) return cachedRouter
const instance = getCurrentInstance()
if (!instance) {
throw new Error('useRouter() 必须在 setup() 或生命周期中调用')
}
const router = instance.appContext.config.globalProperties.$Router
if (!router) {
throw new Error('$Router 尚未注入,请在 main.ts 中使用 app.use(RouterPlugin)')
}
cachedRouter = router
return router
}
/** ✅ 注册为全局插件 */
export default {
install(app: App) {
app.config.globalProperties.$Router = Router
},
}

28
env/.env vendored Normal file
View File

@@ -0,0 +1,28 @@
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
VITE_APP_PUBLIC_BASE=/
# 后台请求地址
VITE_SERVER_BASEURL = 'https://ukw0y1.laf.run'
# 后台上传地址
VITE_UPLOAD_BASEURL = 'https://ukw0y1.laf.run/upload'
# 注意,如果是微信小程序,还有一套请求地址的配置,在 `src/utils/index.ts` 中
# h5是否需要配置代理
VITE_APP_PROXY_ENABLE = true
VITE_APP_PROXY_PREFIX = '/api'
# 后端是否有统一前缀 /api决定本地代码的时候是否需要去掉 /api 前缀。这里面默认是没有的,即前端会把/api 转发去掉
VITE_SERVER_HAS_API_PREFIX = false
# 第二个请求地址 (目前alova中可以使用)
VITE_API_SECONDARY_URL = 'https://ukw0y1.laf.run'
# 认证模式,'single' | 'double' ==> 单token | 双token
VITE_AUTH_MODE = 'single'

9
env/.env.development vendored Normal file
View File

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

9
env/.env.production vendored Normal file
View File

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

9
env/.env.test vendored Normal file
View File

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

55
eslint.config.mjs Normal file
View File

@@ -0,0 +1,55 @@
import uniHelper from '@uni-helper/eslint-config'
export default uniHelper({
unocss: true,
vue: true,
markdown: false,
ignores: [
'src/uni_modules/',
'dist',
// unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用
'auto-import.d.ts',
// vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore
'uni-pages.d.ts',
// 插件生成的文件
'src/pages.json',
'src/manifest.json',
// 忽略自动生成文件
'src/service/**',
],
// https://eslint-config.antfu.me/rules
rules: {
'no-useless-return': 'off',
'no-console': 'off',
'no-unused-vars': 'off',
'vue/no-unused-refs': 'off',
'unused-imports/no-unused-vars': 'off',
'eslint-comments/no-unlimited-disable': 'off',
'jsdoc/check-param-names': 'off',
'jsdoc/require-returns-description': 'off',
'ts/no-empty-object-type': 'off',
'no-extend-native': 'off',
'vue/singleline-html-element-content-newline': [
'error',
{
externalIgnores: ['text'],
},
],
// vue SFC 调换顺序改这里
'vue/block-order': ['error', {
order: [['script', 'template'], 'style'],
}],
},
formatters: {
/**
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
* By default uses Prettier
*/
css: true,
/**
* Format HTML files
* By default uses Prettier
*/
html: true,
},
})

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

26
index.html Normal file
View File

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

149
manifest.config.ts Normal file
View File

@@ -0,0 +1,149 @@
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,
},
// styleIsolation: 'shared',
usingComponents: true,
// __usePrivacyCheck__: true,
},
'mp-alipay': {
usingComponents: true,
styleIsolation: 'shared',
},
'mp-baidu': {
usingComponents: true,
},
'mp-toutiao': {
usingComponents: true,
},
'uniStatistics': {
enable: false,
},
'vueVersion': '3',
})

View File

@@ -0,0 +1,13 @@
import type { GenerateServiceProps } from 'openapi-ts-request'
export default [
{
schemaPath: 'http://petstore.swagger.io/v2/swagger.json',
serversPath: './src/service',
requestLibPath: `import request from '@/http/vue-query';\n import { CustomRequestOptions } from '@/http/types';`,
requestOptionsType: 'CustomRequestOptions',
isGenReactQuery: true,
reactQueryMode: 'vue',
isGenJavaScript: false,
},
] as GenerateServiceProps[]

180
package.json Normal file
View File

@@ -0,0 +1,180 @@
{
"name": "zhuzi-uniapp",
"type": "module",
"version": "3.15.1",
"unibest-version": "3.15.1",
"update-time": "2025-09-11",
"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": ">=22",
"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": "node --experimental-loader ./scripts/window-path-loader.js node_modules/@dcloudio/vite-plugin-uni/bin/uni.js",
"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-ts-request": "openapi-ts",
"prepare": "git init && husky && node ./scripts/create-base-files.js",
"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",
"@tanstack/vue-query": "^5.62.16",
"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",
"wot-design-uni": "^1.11.1",
"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.0",
"@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.8",
"@uni-helper/vite-plugin-uni-platform": "^0.0.5",
"@uni-ku/bundle-optimizer": "^1.3.3",
"@uni-ku/root": "^1.3.4",
"@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",
"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"
},
"resolutions": {
"bin-wrapper": "npm:bin-wrapper-china"
},
"lint-staged": {
"*": "eslint --fix"
}
}

25
pages.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { isH5 } from '@uni-helper/uni-env'
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
import { tabBar } from './src/tabbar/config'
export default defineUniPages({
globalStyle: {
navigationStyle: 'default',
navigationBarTitleText: 'unibest',
navigationBarBackgroundColor: '#f8f8f8',
navigationBarTextStyle: 'black',
backgroundColor: '#FFFFFF',
},
easycom: {
autoscan: true,
custom: {
'^fg-(.*)': '@/components/fg-$1/fg-$1.vue',
'^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模式下h5 设置为 {} 为了防止浏览器报错导致白屏
tabBar: tabBar || (isH5 ? {} : undefined) as any,
})

6
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
ignoredBuiltDependencies:
- '@uni-helper/unocss-preset-uni'
- core-js
- es5-ext
- esbuild
- vue-demi

View File

@@ -0,0 +1,30 @@
// 生成 src/manifest.json 和 src/pages.json
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 = {
name: 'unibest',
description: 'unibest - 最好的 uniapp 开发模板',
versionName: '1.0.0',
versionCode: '100',
}
const pages = {
pages: [
{
path: 'pages/index/index',
style: {
navigationBarTitleText: 'uni-app',
},
},
],
}
// 使用修复后的 __dirname 来解析文件路径
fs.writeFileSync(path.resolve(__dirname, '../src/manifest.json'), JSON.stringify(manifest, null, 2))
fs.writeFileSync(path.resolve(__dirname, '../src/pages.json'), JSON.stringify(pages, null, 2))

101
scripts/postupgrade.js Normal file
View File

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

View File

@@ -0,0 +1,29 @@
// fix: https://github.com/unibest-tech/unibest/issues/219
// Windows path loader for Node.js ESM
// This loader converts Windows absolute paths to file:// URLs
import { pathToFileURL } from 'node:url'
/**
* Resolve hook for ESM loader
* Converts Windows absolute paths to file:// URLs
*/
export function resolve(specifier, context, defaultResolve) {
// Check if this is a Windows absolute path (starts with drive letter like C:)
if (specifier.match(/^[a-z]:\\/i) || specifier.match(/^[a-z]:\//i)) {
// Convert Windows path to file:// URL
const fileUrl = pathToFileURL(specifier).href
return defaultResolve(fileUrl, context, defaultResolve)
}
// For all other specifiers, use the default resolve
return defaultResolve(specifier, context, defaultResolve)
}
/**
* Load hook for ESM loader
*/
export function load(url, context, defaultLoad) {
return defaultLoad(url, context, defaultLoad)
}

39
src/App.ku.vue Normal file
View File

@@ -0,0 +1,39 @@
<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()
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>

38
src/App.vue Normal file
View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
import { navigateToInterceptor } from '@/router/interceptor'
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
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>

17
src/api/foo-alova.ts Normal file
View File

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

11
src/api/foo-vue-query.ts Normal file
View File

@@ -0,0 +1,11 @@
import { queryOptions } from '@tanstack/vue-query'
import { getFooAPI } from './foo'
export function getFooQueryOptions(name: string) {
return queryOptions({
queryFn: async ({ queryKey }) => {
return getFooAPI(queryKey[1])
},
queryKey: ['getFoo', name],
})
}

43
src/api/foo.ts Normal file
View File

@@ -0,0 +1,43 @@
import { http } from '@/http/http'
export interface IFoo {
id: number
name: string
}
export function foo() {
return http.Get<IFoo>('/foo', {
params: {
name: '菲鸽',
page: 1,
pageSize: 10,
},
})
}
export interface IFooItem {
id: string
name: string
}
/** GET 请求 */
export function getFooAPI(name: string) {
return 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 })
}
/** POST 请求;支持 传递 header 的范例 */
export function postFooAPI3(name: string) {
return http.post<IFooItem>('/foo', { name }, { name }, { 'Content-Type-100': '100' })
}

87
src/api/login.ts Normal file
View File

@@ -0,0 +1,87 @@
import type { IAuthLoginRes, ICaptcha, IDoubleTokenRes, IUpdateInfo, IUpdatePassword, IUserInfoRes } from './types/login'
import { http } from '@/http/http'
/**
* 登录表单
*/
export interface ILoginForm {
username: string
password: string
code?: string
uuid?: string
}
/**
* 获取验证码
* @returns ICaptcha 验证码
*/
export function getCode() {
return http.get<ICaptcha>('/user/getCode')
}
/**
* 用户登录
* @param loginForm 登录表单
*/
export function login(loginForm: ILoginForm) {
return http.post<IAuthLoginRes>('/auth/login', loginForm)
}
/**
* 刷新token
* @param refreshToken 刷新token
*/
export function refreshToken(refreshToken: string) {
return http.post<IDoubleTokenRes>('/auth/refreshToken', { refreshToken })
}
/**
* 获取用户信息
*/
export function getUserInfo() {
return http.get<IUserInfoRes>('/user/info')
}
/**
* 退出登录
*/
export function logout() {
return http.get<void>('/auth/logout')
}
/**
* 修改用户信息
*/
export function updateInfo(data: IUpdateInfo) {
return http.post('/user/updateInfo', data)
}
/**
* 修改用户密码
*/
export function updateUserPassword(data: IUpdatePassword) {
return http.post('/user/updatePassword', data)
}
/**
* 获取微信登录凭证
* @returns Promise 包含微信登录凭证(code)
*/
export function getWxCode() {
return new Promise<UniApp.LoginRes>((resolve, reject) => {
uni.login({
provider: 'weixin',
success: res => resolve(res),
fail: err => reject(new Error(err)),
})
})
}
/**
* 微信登录
* @param params 微信登录参数包含code
* @returns Promise 包含登录结果
*/
export function wxLogin(data: { code: string }) {
return http.post<IAuthLoginRes>('/auth/wxLogin', data)
}

97
src/api/types/login.ts Normal file
View File

@@ -0,0 +1,97 @@
// 认证模式类型
export type AuthMode = 'single' | 'double'
// 单Token响应类型
export interface ISingleTokenRes {
token: string
expiresIn: number // 有效期(秒)
}
// 双Token响应类型
export interface IDoubleTokenRes {
accessToken: string
refreshToken: string
accessExpiresIn: number // 访问令牌有效期(秒)
refreshExpiresIn: number // 刷新令牌有效期(秒)
}
/**
* 登录返回的信息,其实就是 token 信息
*/
export type IAuthLoginRes = ISingleTokenRes | IDoubleTokenRes
/**
* 用户信息
*/
export interface IUserInfoRes {
userId: number
username: string
nickname: string
avatar?: string
[key: string]: any // 允许其他扩展字段
}
// 认证存储数据结构
export interface AuthStorage {
mode: AuthMode
tokens: ISingleTokenRes | IDoubleTokenRes
userInfo?: IUserInfoRes
loginTime: number // 登录时间戳
}
/**
* 获取验证码
*/
export interface ICaptcha {
captchaEnabled: boolean
uuid: string
image: string
}
/**
* 上传成功的信息
*/
export interface IUploadSuccessInfo {
fileId: number
originalName: string
fileName: string
storagePath: string
fileHash: string
fileType: string
fileBusinessType: string
fileSize: number
}
/**
* 更新用户信息
*/
export interface IUpdateInfo {
id: number
name: string
sex: string
}
/**
* 更新用户信息
*/
export interface IUpdatePassword {
id: number
oldPassword: string
newPassword: string
confirmPassword: string
}
/**
* 判断是否为单Token响应
* @param tokenRes 登录响应数据
* @returns 是否为单Token响应
*/
export function isSingleTokenRes(tokenRes: IAuthLoginRes): tokenRes is ISingleTokenRes {
return 'token' in tokenRes && !('refreshToken' in tokenRes)
}
/**
* 判断是否为双Token响应
* @param tokenRes 登录响应数据
* @returns 是否为双Token响应
*/
export function isDoubleTokenRes(tokenRes: IAuthLoginRes): tokenRes is IDoubleTokenRes {
return 'accessToken' in tokenRes && 'refreshToken' in tokenRes
}

0
src/components/.gitkeep Normal file
View File

View File

@@ -0,0 +1,38 @@
<template>
<button
class="ancient-btn"
:class="{ disabled }"
:disabled="disabled"
@click="handleClick"
>
<slot>{{ text }}</slot>
</button>
</template>
<script setup lang="ts">
interface Props {
text?: string
disabled?: boolean
}
interface Emits {
click: []
}
const props = withDefaults(defineProps<Props>(), {
text: '',
disabled: false,
})
const emit = defineEmits<Emits>()
function handleClick() {
if (!props.disabled) {
emit('click')
}
}
</script>
<style lang="scss" scoped>
// 样式已在 zhuzi-theme.scss 中定义
</style>

View File

@@ -0,0 +1,26 @@
<template>
<view class="zhuzi-card" :class="customClass">
<view v-if="title" class="card-title">
{{ title }}
</view>
<view class="card-content">
<slot />
</view>
</view>
</template>
<script setup lang="ts">
interface Props {
title?: string
customClass?: string
}
withDefaults(defineProps<Props>(), {
title: '',
customClass: '',
})
</script>
<style lang="scss" scoped>
// 样式已在 zhuzi-theme.scss 中定义
</style>

38
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
/// <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
/** 后端是否有统一前缀 /api */
readonly VITE_SERVER_HAS_API_PREFIX: 'true' | 'false'
/** 认证模式,'single' | 'double' ==> 单token | 双token */
readonly VITE_AUTH_MODE: 'single' | 'double'
/** 上传图片地址 */
readonly VITE_UPLOAD_BASEURL: string
/** 是否清除console */
readonly VITE_DELETE_CONSOLE: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const __VITE_APP_PROXY__: 'true' | 'false'
declare const __UNI_PLATFORM__: 'app' | 'h5' | 'mp-alipay' | 'mp-baidu' | 'mp-kuaishou' | 'mp-lark' | 'mp-qq' | 'mp-tiktok' | 'mp-weixin' | 'mp-xiaochengxu'

51
src/hooks/useRequest.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { Ref } from 'vue'
interface IUseRequestOptions<T> {
/** 是否立即执行 */
immediate?: boolean
/** 初始化数据 */
initialData?: T
}
interface IUseRequestReturn<T> {
loading: Ref<boolean>
error: Ref<boolean | Error>
data: Ref<T | undefined>
run: () => 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>(
func: () => Promise<IResData<T>>,
options: IUseRequestOptions<T> = { immediate: false },
): IUseRequestReturn<T> {
const loading = ref(false)
const error = ref(false)
const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
const run = async () => {
loading.value = true
return func()
.then((res) => {
data.value = res.data
error.value = false
return data.value
})
.catch((err) => {
error.value = err
throw err
})
.finally(() => {
loading.value = false
})
}
options.immediate && run()
return { loading, error, data, run }
}

160
src/hooks/useUpload.ts Normal file
View File

@@ -0,0 +1,160 @@
import { ref } from 'vue'
import { getEnvBaseUploadUrl } from '@/utils'
const VITE_UPLOAD_BASEURL = `${getEnvBaseUploadUrl()}`
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) => {
const { data: _data } = JSON.parse(res)
data.value = _data
// console.log('上传成功', res)
success?.(_data)
},
onError: (err) => {
error.value = err
onError?.(err)
},
onComplete: () => {
loading.value = false
},
})
}
const run = () => {
// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
// 微信小程序在2023年10月17日之后使用本API需要配置隐私协议
const chooseFileOptions = {
count: 1,
success: (res: any) => {
console.log('File selected successfully:', res)
// 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]}
// h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]}
// h5的File有以下字段{name: "girl.jpeg", size: 48976, type: "image/jpeg"}
// App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]}
// App的File有以下字段{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976}
let tempFilePath = ''
let size = 0
// #ifdef MP-WEIXIN
tempFilePath = res.tempFiles[0].tempFilePath
size = res.tempFiles[0].size
// #endif
// #ifndef MP-WEIXIN
tempFilePath = res.tempFilePaths[0]
size = res.tempFiles[0].size
// #endif
handleFileChoose({ tempFilePath, size })
},
fail: (err: any) => {
console.error('File selection failed:', err)
error.value = err
onError?.(err)
},
}
if (fileType === 'image') {
// #ifdef MP-WEIXIN
uni.chooseMedia({
...chooseFileOptions,
mediaType: ['image'],
})
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage(chooseFileOptions)
// #endif
}
else {
uni.chooseFile({
...chooseFileOptions,
type: 'all',
})
}
}
return { loading, error, data, run }
}
async function uploadFile({
tempFilePath,
formData,
onSuccess,
onError,
onComplete,
}: {
tempFilePath: string
formData: Record<string, any>
onSuccess: (data: any) => void
onError: (err: any) => void
onComplete: () => void
}) {
uni.uploadFile({
url: VITE_UPLOAD_BASEURL,
filePath: tempFilePath,
name: 'file',
formData,
success: (uploadFileRes) => {
try {
const data = uploadFileRes.data
onSuccess(data)
}
catch (err) {
onError(err)
}
},
fail: (err) => {
console.error('Upload failed:', err)
onError(err)
},
complete: onComplete,
})
}

13
src/http/README.md Normal file
View File

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

117
src/http/alova.ts Normal file
View File

@@ -0,0 +1,117 @@
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_API_SECONDARY_URL,
}
/**
* 创建请求实例
*/
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<
typeof VueHook,
typeof uniappRequestAdapter
>({
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: import.meta.env.VITE_APP_PROXY_PREFIX,
...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
if (code !== ResultEnum.Success) {
if (config.meta?.toast !== false) {
uni.showToast({
title: message,
icon: 'none',
})
}
throw new Error(`请求错误[${code}]${message}`)
}
// 处理成功响应,返回业务数据
return data
}),
})
export const http = alovaInstance

182
src/http/http.ts Normal file
View File

@@ -0,0 +1,182 @@
import type { IDoubleTokenRes } from '@/api/types/login'
import type { CustomRequestOptions } from '@/http/types'
import { nextTick } from 'vue'
import { LOGIN_PAGE } from '@/router/config'
import { useTokenStore } from '@/store/token'
import { isDoubleTokenMode } from '@/utils'
// 刷新 token 状态管理
let refreshing = false // 防止重复刷新 token 标识
let taskQueue: (() => void)[] = [] // 刷新 token 请求队列
export function http<T>(options: CustomRequestOptions) {
// 1. 返回 Promise 对象
return new Promise<IResData<T>>((resolve, reject) => {
uni.request({
...options,
dataType: 'json',
// #ifndef MP-WEIXIN
responseType: 'json',
// #endif
// 响应成功
success: async (res) => {
// 状态码 2xx参考 axios 的设计
if (res.statusCode >= 200 && res.statusCode < 300) {
// 2.1 提取核心数据 res.data
return resolve(res.data as IResData<T>)
}
const resData: IResData<T> = res.data as IResData<T>
if ((res.statusCode === 401) || (resData.code === 401)) {
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 ((res.statusCode === 401 || resData.code === 401) && refreshToken) {
taskQueue.push(() => {
resolve(http<T>(options))
})
}
// 如果有 refreshToken 且未在刷新中,发起刷新 token 请求
if ((res.statusCode === 401 || resData.code === 401) && 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 = []
}
}
}
else {
// 其他错误 -> 根据后端错误信息轻提示
!options.hideErrorToast
&& uni.showToast({
icon: 'none',
title: (res.data as IResData<T>).msg || '请求错误',
})
reject(res)
}
},
// 响应失败
fail(err) {
uni.showToast({
icon: 'none',
title: '网络错误,换个网络试试',
})
reject(err)
},
})
})
}
/**
* GET 请求
* @param url 后台地址
* @param query 请求query参数
* @param header 请求头默认为json格式
* @returns
*/
export function httpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
method: 'GET',
header,
...options,
})
}
/**
* POST 请求
* @param url 后台地址
* @param data 请求body参数
* @param query 请求query参数post请求也支持query很多微信接口都需要
* @param header 请求头默认为json格式
* @returns
*/
export function httpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
data,
method: 'POST',
header,
...options,
})
}
/**
* PUT 请求
*/
export function httpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
data,
query,
method: 'PUT',
header,
...options,
})
}
/**
* DELETE 请求(无请求体,仅 query
*/
export function httpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
method: 'DELETE',
header,
...options,
})
}
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

66
src/http/interceptor.ts Normal file
View File

@@ -0,0 +1,66 @@
import type { CustomRequestOptions } from '@/http/types'
import { useTokenStore } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import { platform } from '@/utils/platform'
import { stringifyQuery } from './tools/queryString'
// 请求基准地址
const baseUrl = getEnvBaseUrl()
// 拦截器配置
const httpInterceptor = {
// 拦截前触发
invoke(options: CustomRequestOptions) {
// 接口请求支持通过 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
// console.log(__VITE_APP_PROXY__)
if (JSON.parse(__VITE_APP_PROXY__)) {
// 自动拼接代理前缀
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 = {
platform, // 可选,与 uniapp 定义的平台一致,告诉后台来源
...options.header,
}
// 3. 添加 token 请求头标识
const tokenStore = useTokenStore()
const token = tokenStore.validToken
if (token) {
options.header.Authorization = `Bearer ${token}`
}
},
}
export const requestInterceptor = {
install() {
// 拦截 request 请求
uni.addInterceptor('request', httpInterceptor)
// 拦截 uploadFile 文件上传
uni.addInterceptor('uploadFile', httpInterceptor)
},
}

66
src/http/tools/enum.ts Normal file
View File

@@ -0,0 +1,66 @@
export enum ResultEnum {
Success = 0, // 成功
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},请检查网络或联系管理员!`
}

View File

@@ -0,0 +1,29 @@
/**
* 将对象序列化为URL查询字符串用于替代第三方的 qs 库,节省宝贵的体积
* 支持基本类型值和数组,不支持嵌套对象
* @param obj 要序列化的对象
* @returns 序列化后的查询字符串
*/
export function stringifyQuery(obj: Record<string, any>): string {
if (!obj || typeof obj !== 'object' || Array.isArray(obj))
return ''
return Object.entries(obj)
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
// 对键进行编码
const encodedKey = encodeURIComponent(key)
// 处理数组类型
if (Array.isArray(value)) {
return value
.filter(item => item !== undefined && item !== null)
.map(item => `${encodedKey}=${encodeURIComponent(item)}`)
.join('&')
}
// 处理基本类型
return `${encodedKey}=${encodeURIComponent(value)}`
})
.join('&')
}

31
src/http/types.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* 在 uniapp 的 RequestOptions 和 IUniUploadFileOptions 基础上,添加自定义参数
*/
export type CustomRequestOptions = UniApp.RequestOptions & {
query?: Record<string, any>
/** 出错时是否隐藏错误提示 */
hideErrorToast?: boolean
} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
// 通用响应格式
export interface IResponse<T = any> {
code: number | string
data: T
message: string
status: string | number
}
// 分页请求参数
export interface PageParams {
page: number
pageSize: number
[key: string]: any
}
// 分页响应数据
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}

30
src/http/vue-query.ts Normal file
View File

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

10
src/layouts/default.vue Normal file
View File

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

21
src/main.ts Normal file
View File

@@ -0,0 +1,21 @@
import { VueQueryPlugin } from '@tanstack/vue-query'
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)
app.use(VueQueryPlugin)
return {
app,
}
}

86
src/mocks/article.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* 文章内容相关Mock数据
*/
export interface Article {
id: string
title: string
content: string
coverImage: string
publishTime: string
author: string
}
// 轮播图文章
export const mockCarouselArticles: Article[] = [
{
id: '1',
title: '朱子故里行:走进福建尤溪',
content: `
<div class="article-content">
<h2>朱子故里:福建尤溪</h2>
<p>福建尤溪这片山清水秀的土地孕育了一代大儒朱熹。公元1130年朱熹就诞生在这里的南溪书院附近。</p>
<h3>朱子文化的发源地</h3>
<p>尤溪县是朱熹的诞生地,这里保存着丰富的朱子文化遗迹。朱子社、朱子祠、朱子码头等历史遗迹见证了这位大儒的成长轨迹。</p>
<h3>朱子理学的启蒙</h3>
<p>朱熹在尤溪度过了人生的前14年这段时光为他日后的理学思想奠定了基础。山水之间的灵秀培养了他对自然和人文的深刻理解。</p>
<p>如今的尤溪,依然保持着那份古朴与宁静,成为后人缅怀朱子、学习朱子文化的圣地。</p>
</div>
`,
coverImage: '/static/images/article1.jpg',
publishTime: '2024-03-15',
author: '朱子文化研究院',
},
{
id: '2',
title: '四书章句集注:朱子的不朽之作',
content: `
<div class="article-content">
<h2>《四书章句集注》:理学经典</h2>
<p>朱熹的《四书章句集注》是中国古代教育史上最重要的著作之一影响了中国教育近700年。</p>
<h3>四书的组成</h3>
<p>四书包括《大学》、《中庸》、《论语》、《孟子》四部经典。朱熹将这四部著作编辑成册,并加以详细注解。</p>
<h3>教育的里程碑</h3>
<p>《四书章句集注》成为元、明、清三朝科举考试的标准教材,培养了无数读书人,传播了儒家思想。</p>
<h3>现代价值</h3>
<p>时至今日,《四书章句集注》仍然是学习儒家文化、理解中华传统文化的重要读本。</p>
</div>
`,
coverImage: '/static/images/article2.jpg',
publishTime: '2024-03-12',
author: '教育研究所',
},
{
id: '3',
title: '朱子教育思想对现代教育的启示',
content: `
<div class="article-content">
<h2>朱子教育思想的现代意义</h2>
<p>朱熹不仅是一位伟大的思想家,更是一位杰出的教育家。他的教育思想对现代教育仍有重要的指导意义。</p>
<h3>因材施教</h3>
<p>朱熹主张根据学生的不同特点进行教育,这与现代个性化教育理念不谋而合。</p>
<h3>学思并重</h3>
<p>"学而时习之"、"学而不思则罔,思而不学则殆",朱熹强调学习与思考的结合。</p>
<h3>品德教育</h3>
<p>朱熹认为教育的根本目的是培养品德高尚的人,这对现代素质教育具有重要启发。</p>
</div>
`,
coverImage: '/static/images/article3.jpg',
publishTime: '2024-03-10',
author: '现代教育研究中心',
},
]
// 朱子介绍内容
export const zhuziIntroduction = `朱熹1130-1200字元晦、仲晦号晦庵、晦翁别号紫阳谥号"文",世称朱文公。祖居宋徽州婺源县(治所在今江西省婺源县),出生于南剑州尤溪县(治所在今福建省尤溪县),终老于建宁府建阳县(治所在今福建省南平市建阳区)。我国古代伟大的思想家、哲学家、教育家、文学家。在中国文化史上是与孔子并峙的两座高峰,素有"北孔南朱"之称,后世尊称为"朱子"。
朱子集南宋前儒学思想之大成,构建了"致广大,尽精微,综罗百代"的理学思想体系,影响了中国社会近千年。其所著《四书章句集注》《周易本义》《诗集传》等,为元明清科举考试的必读书目。朱子亦是唯一非孔子亲传弟子而入孔庙大成殿配享的先哲。以朱子理学为标志,中华文明进入新的千年发展期并延续至今。可以说,读懂了朱熹,就读懂了中华优秀传统文化。`

61
src/mocks/auth.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* 认证相关Mock数据
*/
// 学校数据
export const mockSchools = [
{ id: 1, name: '第一小学' },
{ id: 2, name: '第二小学' },
{ id: 3, name: '第三小学' },
{ id: 4, name: '第四小学' },
{ id: 5, name: '第五小学' },
]
// 年级数据
export const mockGrades = [
{ id: 1, name: '一年级' },
{ id: 2, name: '二年级' },
{ id: 3, name: '三年级' },
{ id: 4, name: '四年级' },
{ id: 5, name: '五年级' },
{ id: 6, name: '六年级' },
]
// 班级数据
export const mockClasses = [
{ id: 1, name: '1班', gradeId: 1 },
{ id: 2, name: '2班', gradeId: 1 },
{ id: 3, name: '3班', gradeId: 1 },
{ id: 4, name: '1班', gradeId: 2 },
{ id: 5, name: '2班', gradeId: 2 },
]
// 用户注册响应
export const mockRegisterResponse = {
success: true,
message: '注册成功',
data: {
userId: '123456',
phone: '13800138000',
},
}
// 用户登录响应
export const mockLoginResponse = {
success: true,
message: '登录成功',
data: {
userId: '123456',
phone: '13800138000',
token: 'mock_token_123456',
hasChild: true,
childInfo: {
id: '789',
name: '小明',
schoolId: 1,
gradeId: 3,
classId: 5,
seatNumber: 15,
},
},
}

9
src/mocks/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Mock数据统一导出
*/
export * from './article'
export * from './auth'
export * from './quiz'
export * from './ranking'
export * from './user'

248
src/mocks/quiz.ts Normal file
View File

@@ -0,0 +1,248 @@
/**
* 答题相关Mock数据
*/
export interface QuizQuestion {
id: string
type: 'single' | 'multiple' // 单选、多选
title: string
options: Array<{
id: string
text: string
}>
correctAnswers: string[]
score: number
difficulty: 'easy' | 'medium' | 'hard'
}
// 家长题库5道题
export const mockParentQuestions: QuizQuestion[] = [
{
id: 'p1',
type: 'single',
title: '朱熹字什么?',
options: [
{ id: 'a', text: '元晦' },
{ id: 'b', text: '仲晦' },
{ id: 'c', text: '晦庵' },
{ id: 'd', text: '以上都是' },
],
correctAnswers: ['d'],
score: 10,
difficulty: 'easy',
},
{
id: 'p2',
type: 'single',
title: '朱熹被后世尊称为什么?',
options: [
{ id: 'a', text: '朱子' },
{ id: 'b', text: '朱文公' },
{ id: 'c', text: '紫阳先生' },
{ id: 'd', text: '以上都是' },
],
correctAnswers: ['d'],
score: 15,
difficulty: 'easy',
},
{
id: 'p3',
type: 'single',
title: '朱熹的生卒年份是?',
options: [
{ id: 'a', text: '1130-1200' },
{ id: 'b', text: '1120-1190' },
{ id: 'c', text: '1140-1210' },
{ id: 'd', text: '1135-1205' },
],
correctAnswers: ['a'],
score: 20,
difficulty: 'medium',
},
{
id: 'p4',
type: 'multiple',
title: '朱熹的主要著作有哪些?(多选)',
options: [
{ id: 'a', text: '四书章句集注' },
{ id: 'b', text: '周易本义' },
{ id: 'c', text: '诗集传' },
{ id: 'd', text: '论语' },
],
correctAnswers: ['a', 'b', 'c'],
score: 25,
difficulty: 'medium',
},
{
id: 'p5',
type: 'single',
title: '"北孔南朱"中的"朱"指的是谁?',
options: [
{ id: 'a', text: '朱元璋' },
{ id: 'b', text: '朱熹' },
{ id: 'c', text: '朱舜水' },
{ id: 'd', text: '朱自清' },
],
correctAnswers: ['b'],
score: 30,
difficulty: 'hard',
},
]
// 学生题库10道题
export const mockStudentQuestions: QuizQuestion[] = [
{
id: 's1',
type: 'single',
title: '朱熹是哪个朝代的人?',
options: [
{ id: 'a', text: '唐朝' },
{ id: 'b', text: '宋朝' },
{ id: 'c', text: '明朝' },
{ id: 'd', text: '清朝' },
],
correctAnswers: ['b'],
score: 10,
difficulty: 'easy',
},
{
id: 's2',
type: 'single',
title: '朱熹出生在今天的哪个省?',
options: [
{ id: 'a', text: '江西省' },
{ id: 'b', text: '福建省' },
{ id: 'c', text: '浙江省' },
{ id: 'd', text: '安徽省' },
],
correctAnswers: ['b'],
score: 10,
difficulty: 'easy',
},
{
id: 's3',
type: 'single',
title: '朱熹被称为什么?',
options: [
{ id: 'a', text: '诗仙' },
{ id: 'b', text: '诗圣' },
{ id: 'c', text: '朱子' },
{ id: 'd', text: '文豪' },
],
correctAnswers: ['c'],
score: 10,
difficulty: 'easy',
},
{
id: 's4',
type: 'single',
title: '朱熹是伟大的什么家?',
options: [
{ id: 'a', text: '思想家' },
{ id: 'b', text: '教育家' },
{ id: 'c', text: '哲学家' },
{ id: 'd', text: '以上都是' },
],
correctAnswers: ['d'],
score: 15,
difficulty: 'easy',
},
{
id: 's5',
type: 'single',
title: '"理学"的集大成者是谁?',
options: [
{ id: 'a', text: '孔子' },
{ id: 'b', text: '孟子' },
{ id: 'c', text: '朱熹' },
{ id: 'd', text: '老子' },
],
correctAnswers: ['c'],
score: 15,
difficulty: 'medium',
},
{
id: 's6',
type: 'single',
title: '朱熹的思想体系被称为什么?',
options: [
{ id: 'a', text: '理学' },
{ id: 'b', text: '心学' },
{ id: 'c', text: '实学' },
{ id: 'd', text: '玄学' },
],
correctAnswers: ['a'],
score: 15,
difficulty: 'medium',
},
{
id: 's7',
type: 'single',
title: '孔庙大成殿中,除了孔子的弟子外,还有一位入祀的先哲是?',
options: [
{ id: 'a', text: '孟子' },
{ id: 'b', text: '朱熹' },
{ id: 'c', text: '老子' },
{ id: 'd', text: '庄子' },
],
correctAnswers: ['b'],
score: 20,
difficulty: 'medium',
},
{
id: 's8',
type: 'multiple',
title: '朱熹的别号有哪些?(多选)',
options: [
{ id: 'a', text: '晦庵' },
{ id: 'b', text: '晦翁' },
{ id: 'c', text: '紫阳' },
{ id: 'd', text: '东坡' },
],
correctAnswers: ['a', 'b', 'c'],
score: 20,
difficulty: 'hard',
},
{
id: 's9',
type: 'single',
title: '朱熹的理学思想影响了中国社会多少年?',
options: [
{ id: 'a', text: '500年' },
{ id: 'b', text: '近千年' },
{ id: 'c', text: '200年' },
{ id: 'd', text: '100年' },
],
correctAnswers: ['b'],
score: 25,
difficulty: 'hard',
},
{
id: 's10',
type: 'single',
title: '"读懂了朱熹就读懂了____"',
options: [
{ id: 'a', text: '中国历史' },
{ id: 'b', text: '中华优秀传统文化' },
{ id: 'c', text: '宋朝文化' },
{ id: 'd', text: '理学思想' },
],
correctAnswers: ['b'],
score: 25,
difficulty: 'hard',
},
]
// 答题结果mock
export const mockQuizResult = {
totalScore: 85,
correctCount: 4,
totalCount: 5,
timeSpent: 120, // 秒
achievement: '优秀',
ranking: {
class: 5,
grade: 12,
school: 35,
},
}

91
src/mocks/ranking.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* 排行榜相关Mock数据
*/
export interface RankingItem {
rank: number
studentName: string
schoolName: string
gradeName: string
className: string
totalScore: number
monthScore: number
avatar?: string
}
// 班级排行榜前20名
export const mockClassRanking: RankingItem[] = Array.from({ length: 20 }, (_, index) => ({
rank: index + 1,
studentName: `同学${index + 1}`,
schoolName: '第一小学',
gradeName: '三年级',
className: '2班',
totalScore: 1000 - index * 20,
monthScore: 500 - index * 10,
avatar: `/static/images/avatar${(index % 5) + 1}.png`,
}))
// 年级排行榜
export const mockGradeRanking: RankingItem[] = Array.from({ length: 20 }, (_, index) => ({
rank: index + 1,
studentName: `年级同学${index + 1}`,
schoolName: '第一小学',
gradeName: '三年级',
className: `${Math.floor(Math.random() * 5) + 1}`,
totalScore: 1200 - index * 25,
monthScore: 600 - index * 12,
avatar: `/static/images/avatar${(index % 5) + 1}.png`,
}))
// 全校排行榜
export const mockSchoolRanking: RankingItem[] = Array.from({ length: 20 }, (_, index) => ({
rank: index + 1,
studentName: `全校同学${index + 1}`,
schoolName: '第一小学',
gradeName: `${Math.floor(Math.random() * 6) + 1}年级`,
className: `${Math.floor(Math.random() * 5) + 1}`,
totalScore: 1500 - index * 30,
monthScore: 750 - index * 15,
avatar: `/static/images/avatar${(index % 5) + 1}.png`,
}))
// 全区排行榜
export const mockDistrictRanking: RankingItem[] = Array.from({ length: 20 }, (_, index) => ({
rank: index + 1,
studentName: `全区同学${index + 1}`,
schoolName: `${Math.floor(Math.random() * 5) + 1}小学`,
gradeName: `${Math.floor(Math.random() * 6) + 1}年级`,
className: `${Math.floor(Math.random() * 5) + 1}`,
totalScore: 1800 - index * 35,
monthScore: 900 - index * 18,
avatar: `/static/images/avatar${(index % 5) + 1}.png`,
}))
// 全市排行榜
export const mockCityRanking: RankingItem[] = Array.from({ length: 20 }, (_, index) => ({
rank: index + 1,
studentName: `全市同学${index + 1}`,
schoolName: `${Math.floor(Math.random() * 10) + 1}小学`,
gradeName: `${Math.floor(Math.random() * 6) + 1}年级`,
className: `${Math.floor(Math.random() * 5) + 1}`,
totalScore: 2000 - index * 40,
monthScore: 1000 - index * 20,
avatar: `/static/images/avatar${(index % 5) + 1}.png`,
}))
// 当前用户排名信息
export const mockUserRankInfo = {
studentName: '小明',
schoolName: '第一小学',
gradeName: '三年级',
className: '2班',
totalScore: 850,
monthScore: 420,
rankings: {
class: { rank: 5, total: 35 },
grade: { rank: 12, total: 180 },
school: { rank: 35, total: 1200 },
district: { rank: 120, total: 8500 },
city: { rank: 560, total: 25000 },
},
}

82
src/mocks/user.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* 用户相关Mock数据
*/
export interface QuizRecord {
id: string
date: string
type: 'parent' | 'student' // 家长答题或学生答题
score: number
totalQuestions: number
correctAnswers: number
timeSpent: number // 秒
}
// 用户信息
export const mockUserInfo = {
id: '123456',
phone: '13800138000',
avatar: '/static/images/default-avatar.png',
child: {
id: '789',
name: '小明',
schoolId: 1,
schoolName: '第一小学',
gradeId: 3,
gradeName: '三年级',
classId: 5,
className: '2班',
seatNumber: 15,
totalScore: 850,
monthScore: 420,
},
}
// 答题记录
export const mockQuizRecords: QuizRecord[] = [
{
id: 'r1',
date: '2024-03-15 10:30:00',
type: 'student',
score: 85,
totalQuestions: 10,
correctAnswers: 8,
timeSpent: 280,
},
{
id: 'r2',
date: '2024-03-14 19:45:00',
type: 'parent',
score: 95,
totalQuestions: 5,
correctAnswers: 5,
timeSpent: 120,
},
{
id: 'r3',
date: '2024-03-13 20:15:00',
type: 'student',
score: 70,
totalQuestions: 10,
correctAnswers: 7,
timeSpent: 300,
},
{
id: 'r4',
date: '2024-03-12 18:20:00',
type: 'parent',
score: 80,
totalQuestions: 5,
correctAnswers: 4,
timeSpent: 135,
},
{
id: 'r5',
date: '2024-03-11 16:50:00',
type: 'student',
score: 90,
totalQuestions: 10,
correctAnswers: 9,
timeSpent: 260,
},
]

View File

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

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
// code here
import RequestComp from './components/request.vue'
definePage({
style: {
navigationBarTitleText: '分包页面',
},
})
</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>
<view>
<RequestComp />
</view>
</view>
</template>
<style lang="scss" scoped>
//
</style>

149
src/pages/about/about.vue Normal file
View File

@@ -0,0 +1,149 @@
<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 { tabbarStore } from '@/tabbar/store'
import RequestComp from './components/request.vue'
import VBindCss from './components/VBindCss.vue'
definePage({
style: {
navigationBarTitleText: '关于',
},
// 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 arc/router 文件夹
excludeLoginPath: false,
})
const tokenStore = useTokenStore()
// 浏览器打印 isH5为true, isWeb为false大家尽量用 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/about/about?a=1&b=2')}`,
})
}
function logout() {
// 清空用户信息
tokenStore.logout()
// 执行退出登录逻辑
uni.showToast({
title: '退出登录成功',
icon: 'success',
})
}
function gotoTabbar() {
uni.switchTab({
url: '/pages/index/index',
})
}
// #region setTabbarBadge
function setTabbarBadge() {
tabbarStore.setTabbarItemBadge(1, 100)
}
// #endregion
function gotoAlova() {
uni.navigateTo({
url: '/pages/about/alova',
})
}
function gotoVueQuery() {
uni.navigateTo({
url: '/pages/about/vue-query',
})
}
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获取不到但是可以通过 onReady获取到后面就可以通过onShow获取到了
onShow(() => {
console.log('onShow:', uniLayout.value) // onReady: Proxy(Object)
console.log('onShow:', uniLayout.value?.testUniLayoutExposedData) // onReady: testUniLayoutExposedData
})
const uniKuRoot = ref()
// 结论:(同上第一次通过onShow获取不到但是可以通过 onReady获取到后面就可以通过onShow获取到了
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>
<button class="mt-4 w-60 text-center" @click="setTabbarBadge">
设置tabbarBadge
</button>
<RequestComp />
<VBindCss />
<view class="mb-6 h-1px bg-#eee" />
<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="gotoTabbar">
切换tabbar
</button>
</view>
<view class="text-center">
<button type="primary" size="mini" class="w-160px" @click="gotoVueQuery">
vue-query 示例页面
</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>

53
src/pages/about/alova.vue Normal file
View File

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

View File

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

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import type { IFooItem } from '@/api/foo'
import { getFooAPI } from '@/api/foo'
// 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">
pages 里面的 vue 文件会扫描成页面将自动添加到 pages.json 里面
</view>
<view class="my-2 text-green-400">
但是 pages/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 class="text-xl">
请求数据如下
</view>
<view class="text-green leading-8">
{{ JSON.stringify(data) }}
</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>

View File

@@ -0,0 +1,50 @@
<script lang="ts" setup>
import { useQuery } from '@tanstack/vue-query'
import { foo } from '@/api/foo'
import { getFooQueryOptions } from '@/api/foo-vue-query'
definePage({
style: {
navigationBarTitleText: 'Vue Query 演示',
},
})
// 简单使用
onShow(async () => {
const res = await foo()
console.log('res: ', res)
})
// vue-query 版
const {
data,
error,
isLoading: loading,
refetch: send,
} = useQuery(getFooQueryOptions('菲鸽-vue-query'))
</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>
</view>
</template>
<style lang="scss" scoped>
//
</style>

View File

@@ -0,0 +1,257 @@
<route lang="json">
{
"style": {
"navigationBarTitleText": "文章详情",
"navigationBarBackgroundColor": "#2D5E3E",
"navigationBarTextStyle": "white"
}
}
</route>
<template>
<view class="article-detail-container">
<scroll-view class="article-content" scroll-y>
<!-- 文章头部 -->
<view class="article-header">
<text class="article-title">{{ article?.title }}</text>
<view class="article-meta">
<text class="publish-time">{{ formatDate(article?.publishTime) }}</text>
<text class="author">{{ article?.author }}</text>
</view>
</view>
<!-- 文章封面 -->
<view v-if="article?.coverImage" class="article-cover">
<image
:src="article.coverImage"
class="cover-image"
mode="aspectFill"
/>
</view>
<!-- 文章正文 -->
<ZhuziCard class="content-card">
<view class="article-body" v-html="article?.content" />
</ZhuziCard>
<!-- 相关推荐 -->
<view class="related-section">
<view class="section-title">
<uni-icons type="list" size="20" color="#2D5E3E" />
<text class="title-text">相关推荐</text>
</view>
<view class="related-list">
<view
v-for="relatedArticle in relatedArticles"
:key="relatedArticle.id"
class="related-item"
@tap="goToArticle(relatedArticle.id)"
>
<image
:src="relatedArticle.coverImage || '/static/logo.svg'"
class="related-image"
mode="aspectFill"
/>
<view class="related-content">
<text class="related-title">{{ relatedArticle.title }}</text>
<text class="related-author">{{ relatedArticle.author }}</text>
</view>
<uni-icons type="arrowright" size="16" color="#999999" />
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import type { Article } from '@/mocks/article'
import { ref } from 'vue'
import { mockCarouselArticles } from '@/mocks/article'
const article = ref<Article | null>(null)
const relatedArticles = ref<Article[]>([])
// 格式化日期
function formatDate(dateStr?: string) {
if (!dateStr)
return ''
const date = new Date(dateStr)
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
// 跳转到其他文章
function goToArticle(articleId: string) {
// 替换当前页面,避免页面栈过深
uni.redirectTo({
url: `/pages/article/detail?id=${articleId}`,
})
}
// 页面加载
onLoad((options: any) => {
const articleId = options.id
// 根据ID查找文章
const foundArticle = mockCarouselArticles.find(a => a.id === articleId)
if (foundArticle) {
article.value = foundArticle
// 加载相关文章(排除当前文章)
relatedArticles.value = mockCarouselArticles
.filter(a => a.id !== articleId)
.slice(0, 3)
}
else {
uni.showToast({
title: '文章不存在',
icon: 'none',
})
// 延迟返回上一页
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
})
</script>
<style lang="scss" scoped>
.article-detail-container {
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
.article-content {
height: 100vh;
padding: 32rpx;
.article-header {
margin-bottom: 32rpx;
.article-title {
display: block;
font-size: 40rpx;
font-weight: bold;
color: #2d5e3e;
line-height: 1.4;
margin-bottom: 16rpx;
}
.article-meta {
display: flex;
justify-content: space-between;
align-items: center;
.publish-time {
font-size: 24rpx;
color: #696969;
}
.author {
font-size: 24rpx;
color: #daa520;
font-weight: bold;
}
}
}
.article-cover {
margin-bottom: 32rpx;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(45, 94, 62, 0.15);
.cover-image {
width: 100%;
height: 400rpx;
}
}
.content-card {
margin-bottom: 48rpx;
.article-body {
font-size: 30rpx;
color: #2f4f4f;
line-height: 1.8;
// 深度选择器处理HTML内容样式
:deep(h2) {
font-size: 36rpx;
font-weight: bold;
color: #2d5e3e;
margin: 32rpx 0 16rpx;
}
:deep(h3) {
font-size: 32rpx;
font-weight: bold;
color: #2d5e3e;
margin: 24rpx 0 12rpx;
}
:deep(p) {
margin-bottom: 24rpx;
text-align: justify;
}
}
}
.related-section {
.section-title {
display: flex;
align-items: center;
margin-bottom: 24rpx;
.title-text {
font-size: 32rpx;
font-weight: bold;
color: #2d5e3e;
margin-left: 12rpx;
}
}
.related-list {
.related-item {
display: flex;
align-items: center;
padding: 24rpx;
margin-bottom: 16rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(45, 94, 62, 0.1);
.related-image {
width: 120rpx;
height: 80rpx;
border-radius: 8rpx;
margin-right: 24rpx;
}
.related-content {
flex: 1;
.related-title {
display: block;
font-size: 28rpx;
color: #2f4f4f;
font-weight: bold;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.related-author {
font-size: 24rpx;
color: #696969;
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,483 @@
<route lang="json">
{
"style": {
"navigationBarTitleText": "绑定孩子信息",
"navigationBarBackgroundColor": "#2D5E3E",
"navigationBarTextStyle": "white"
}
}
</route>
<template>
<view class="bind-child-container">
<!-- 进度指示器 -->
<view class="progress-bar">
<view class="progress-step active">
<view class="step-number">
1
</view>
<text class="step-text">选择学校</text>
</view>
<view class="progress-line" :class="{ active: currentStep >= 2 }" />
<view class="progress-step" :class="{ active: currentStep >= 2 }">
<view class="step-number">
2
</view>
<text class="step-text">选择年级班级</text>
</view>
<view class="progress-line" :class="{ active: currentStep >= 3 }" />
<view class="progress-step" :class="{ active: currentStep >= 3 }">
<view class="step-number">
3
</view>
<text class="step-text">填写学生信息</text>
</view>
</view>
<!-- 绑定表单 -->
<ZhuziCard class="form-card" title="绑定孩子信息">
<!-- 步骤1选择学校 -->
<view v-if="currentStep === 1" class="form-step">
<view class="step-title">
请选择孩子所在的学校
</view>
<picker
:value="selectedSchoolIndex"
:range="schoolNames"
@change="onSchoolChange"
>
<view class="picker-item">
<text>{{ formData.schoolName || '请选择学校' }}</text>
<uni-icons type="arrow-down" size="16" color="#999999" />
</view>
</picker>
</view>
<!-- 步骤2选择年级班级 -->
<view v-if="currentStep === 2" class="form-step">
<view class="step-title">
请选择年级和班级
</view>
<!-- 年级选择 -->
<picker
:value="selectedGradeIndex"
:range="gradeNames"
@change="onGradeChange"
>
<view class="picker-item">
<text>{{ formData.gradeName || '请选择年级' }}</text>
<uni-icons type="arrow-down" size="16" color="#999999" />
</view>
</picker>
<!-- 班级选择 -->
<picker
:value="selectedClassIndex"
:range="classNames"
:disabled="!formData.gradeId"
@change="onClassChange"
>
<view class="picker-item" :class="{ disabled: !formData.gradeId }">
<text>{{ formData.className || '请先选择年级' }}</text>
<uni-icons type="arrow-down" size="16" color="#999999" />
</view>
</picker>
</view>
<!-- 步骤3填写学生信息 -->
<view v-if="currentStep === 3" class="form-step">
<view class="step-title">
请填写学生基本信息
</view>
<view class="form-item">
<text class="label">学生姓名</text>
<input
v-model="formData.studentName"
class="form-input"
placeholder="请输入学生姓名"
maxlength="10"
>
</view>
<view class="form-item">
<text class="label">班内座号</text>
<input
v-model="seatNumber"
class="form-input"
type="text"
placeholder="请输入座号(防止重名)"
>
</view>
<!-- 信息确认 -->
<view class="info-confirm">
<view class="confirm-title">
请确认以下信息
</view>
<view class="info-item">
<text class="info-label">学校</text>
<text class="info-value">{{ formData.schoolName }}</text>
</view>
<view class="info-item">
<text class="info-label">年级</text>
<text class="info-value">{{ formData.gradeName }}</text>
</view>
<view class="info-item">
<text class="info-label">班级</text>
<text class="info-value">{{ formData.className }}</text>
</view>
<view class="info-item">
<text class="info-label">姓名</text>
<text class="info-value">{{ formData.studentName }}</text>
</view>
<view class="info-item">
<text class="info-label">座号</text>
<text class="info-value">{{ seatNumber }}</text>
</view>
</view>
</view>
</ZhuziCard>
<!-- 操作按钮 -->
<view class="button-group">
<AncientButton
v-if="currentStep > 1"
class="btn secondary"
@click="prevStep"
>
上一步
</AncientButton>
<AncientButton
class="btn primary"
:disabled="!canProceed || isSubmitting"
@click="nextStep"
>
{{ currentStep === 3 ? (isSubmitting ? '绑定中...' : '完成绑定') : '下一步' }}
</AncientButton>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { mockClasses, mockGrades, mockSchools } from '@/mocks/auth'
const currentStep = ref(1)
const isSubmitting = ref(false)
// 表单数据
const formData = ref({
schoolId: '',
schoolName: '',
gradeId: '',
gradeName: '',
classId: '',
className: '',
studentName: '',
seatNumber: 0,
})
const seatNumber = ref('')
// 选择索引
const selectedSchoolIndex = ref(0)
const selectedGradeIndex = ref(0)
const selectedClassIndex = ref(0)
// 数据源
const schoolNames = computed(() => mockSchools.map(school => school.name))
const gradeNames = computed(() => mockGrades.map(grade => grade.name))
const classNames = computed(() => {
if (!formData.value.gradeId)
return []
return mockClasses
.filter(cls => cls.gradeId === Number(formData.value.gradeId))
.map(cls => cls.name)
})
// 是否可以继续下一步
const canProceed = computed(() => {
switch (currentStep.value) {
case 1:
return !!formData.value.schoolId
case 2:
return !!formData.value.gradeId && !!formData.value.classId
case 3:
return !!formData.value.studentName && !!seatNumber.value
default:
return false
}
})
// 学校选择
function onSchoolChange(e: any) {
const index = e.detail.value
selectedSchoolIndex.value = index
const school = mockSchools[index]
formData.value.schoolId = school.id.toString()
formData.value.schoolName = school.name
}
// 年级选择
function onGradeChange(e: any) {
const index = e.detail.value
selectedGradeIndex.value = index
const grade = mockGrades[index]
formData.value.gradeId = grade.id.toString()
formData.value.gradeName = grade.name
// 重置班级选择
formData.value.classId = ''
formData.value.className = ''
selectedClassIndex.value = 0
}
// 班级选择
function onClassChange(e: any) {
const index = e.detail.value
selectedClassIndex.value = index
const availableClasses = mockClasses.filter(cls => cls.gradeId === Number(formData.value.gradeId))
const cls = availableClasses[index]
formData.value.classId = cls.id.toString()
formData.value.className = cls.name
}
// 下一步
async function nextStep() {
if (currentStep.value < 3) {
currentStep.value++
}
else {
// 第3步提交绑定
await handleSubmit()
}
}
// 上一步
function prevStep() {
if (currentStep.value > 1) {
currentStep.value--
}
}
// 提交绑定
async function handleSubmit() {
try {
isSubmitting.value = true
// 设置座号
formData.value.seatNumber = Number(seatNumber.value)
// 模拟绑定请求
await simulateBindChild()
uni.showToast({
title: '绑定成功!',
icon: 'success',
})
setTimeout(() => {
uni.switchTab({
url: '/pages/index/index',
})
}, 1500)
}
catch (error) {
console.error('绑定失败:', error)
uni.showToast({
title: '绑定失败,请重试',
icon: 'none',
})
}
finally {
isSubmitting.value = false
}
}
// 模拟绑定请求
function simulateBindChild(): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.1) {
// 保存孩子信息到本地存储
uni.setStorageSync('childInfo', formData.value)
resolve()
}
else {
reject(new Error('绑定失败'))
}
}, 2000)
})
}
</script>
<style lang="scss" scoped>
.bind-child-container {
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
padding: 32rpx;
.progress-bar {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
padding: 0 32rpx;
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
opacity: 0.5;
transition: opacity 0.3s ease;
&.active {
opacity: 1;
}
.step-number {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #cccccc;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-bottom: 8rpx;
}
&.active .step-number {
background: #2d5e3e;
}
.step-text {
font-size: 24rpx;
color: #696969;
}
}
.progress-line {
width: 80rpx;
height: 2rpx;
background: #cccccc;
margin: 0 16rpx -24rpx;
transition: background-color 0.3s ease;
&.active {
background: #2d5e3e;
}
}
}
.form-card {
margin-bottom: 48rpx;
.form-step {
.step-title {
font-size: 32rpx;
font-weight: bold;
color: #2d5e3e;
text-align: center;
margin-bottom: 48rpx;
}
.picker-item {
border: 2rpx solid #e6e6e6;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
background: white;
transition: border-color 0.3s ease;
&:hover {
border-color: #2d5e3e;
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
.form-item {
margin-bottom: 32rpx;
.label {
display: block;
font-size: 28rpx;
color: #2f4f4f;
margin-bottom: 12rpx;
font-weight: bold;
}
.form-input {
width: 100%;
border: 2rpx solid #e6e6e6;
border-radius: 16rpx;
padding: 24rpx;
font-size: 32rpx;
background: white;
transition: border-color 0.3s ease;
&:focus {
border-color: #2d5e3e;
}
}
}
.info-confirm {
background: rgba(45, 94, 62, 0.05);
border-radius: 16rpx;
padding: 32rpx;
margin-top: 32rpx;
.confirm-title {
font-size: 28rpx;
font-weight: bold;
color: #2d5e3e;
margin-bottom: 24rpx;
}
.info-item {
display: flex;
margin-bottom: 16rpx;
.info-label {
min-width: 120rpx;
color: #696969;
font-size: 28rpx;
}
.info-value {
color: #2f4f4f;
font-size: 28rpx;
font-weight: bold;
}
}
}
}
}
.button-group {
display: flex;
gap: 24rpx;
.btn {
flex: 1;
&.secondary {
background: rgba(255, 255, 255, 0.8);
color: #2d5e3e;
border-color: #2d5e3e;
}
}
}
}
</style>

265
src/pages/auth/login.vue Normal file
View File

@@ -0,0 +1,265 @@
<route lang="json">
{
"style": {
"navigationStyle": "custom"
}
}
</route>
<template>
<view class="login-container">
<!-- 背景装饰 -->
<view class="bg-decoration">
<!-- <image src="/static/images/bamboo-bg.png" class="bamboo-bg-img" mode="aspectFill" /> -->
</view>
<!-- 顶部标题区 -->
<view class="header-section">
<view class="logo-area">
<image src="/static/logo.svg" class="logo" mode="aspectFit" />
<text class="app-title">朱子文化学习</text>
<text class="app-subtitle">传承千年智慧弘扬文化精神</text>
</view>
</view>
<!-- 登录卡片 -->
<ZhuziCard class="login-card" title="微信快速登录">
<view class="login-content">
<view class="welcome-text">
<text class="main-text">欢迎回来</text>
<text class="sub-text">继续您的朱子文化学习之旅</text>
</view>
<!-- 微信登录按钮 -->
<AncientButton
class="wx-login-btn"
:disabled="isLoading"
@click="handleWxLogin"
>
<uni-icons type="weixin" size="24" color="#FFFFFF" class="wx-icon" />
<text>{{ isLoading ? '登录中...' : '微信授权登录' }}</text>
</AncientButton>
<!-- 登录说明 -->
<view class="login-tips">
<text class="tips-text">
首次登录需要绑定孩子信息
<text class="highlight">点击上方按钮开始</text>
</text>
</view>
</view>
</ZhuziCard>
<!-- 底部装饰 -->
<view class="footer-decoration">
<text class="ancient-text">学而时习之不亦说乎</text>
<text class="author-text"> 朱子语录</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { mockLoginResponse } from '@/mocks/auth'
const isLoading = ref(false)
// 微信登录处理
async function handleWxLogin() {
try {
isLoading.value = true
// 模拟微信登录
await simulateWxLogin()
// 登录成功后跳转
const hasChild = mockLoginResponse.data.hasChild
if (hasChild) {
// 已绑定孩子跳转首页首页是tabbar页面使用switchTab
uni.switchTab({
url: '/pages/index/index',
})
}
else {
// 未绑定孩子,跳转绑定页面
uni.redirectTo({
url: '/pages/auth/bind-child',
})
}
}
catch (error) {
console.error('登录失败:', error)
uni.showToast({
title: '登录失败,请重试',
icon: 'none',
})
}
finally {
isLoading.value = false
}
}
// 模拟微信登录
function simulateWxLogin(): Promise<void> {
return new Promise((resolve, reject) => {
// 模拟网络请求
setTimeout(() => {
if (Math.random() > 0.1) { // 90% 成功率
// 保存用户信息到本地存储
uni.setStorageSync('userToken', mockLoginResponse.data.token)
uni.setStorageSync('userInfo', mockLoginResponse.data)
resolve()
}
else {
reject(new Error('登录失败'))
}
}, 2000)
})
}
// 页面加载时检查登录状态
onLoad(() => {
const token = uni.getStorageSync('userToken')
if (token) {
// 已登录直接跳转首页首页是tabbar页面使用switchTab
uni.switchTab({
url: '/pages/index/index',
})
}
})
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #1e3a8a 0%, #6366f1 100%);
position: relative;
padding: 40rpx;
display: flex;
flex-direction: column;
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0.1;
z-index: 0;
.bamboo-bg-img {
width: 100%;
height: 100%;
}
}
.header-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
.logo-area {
text-align: center;
.logo {
width: 160rpx;
height: 160rpx;
margin-bottom: 32rpx;
}
.app-title {
display: block;
font-size: 48rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 16rpx;
text-shadow: 2rpx 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
.app-subtitle {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
text-shadow: 1rpx 1rpx 2rpx rgba(0, 0, 0, 0.3);
}
}
}
.login-card {
margin: 60rpx 0;
z-index: 1;
.login-content {
text-align: center;
.welcome-text {
margin-bottom: 48rpx;
.main-text {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #2d5e3e;
margin-bottom: 12rpx;
}
.sub-text {
display: block;
font-size: 28rpx;
color: #696969;
}
}
.wx-login-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin-bottom: 32rpx;
.wx-icon {
width: 48rpx;
height: 48rpx;
}
}
.login-tips {
.tips-text {
font-size: 24rpx;
color: #696969;
line-height: 1.5;
.highlight {
color: #daa520;
font-weight: bold;
}
}
}
}
}
.footer-decoration {
text-align: center;
z-index: 1;
margin-top: auto;
.ancient-text {
display: block;
font-size: 32rpx;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 8rpx;
text-shadow: 1rpx 1rpx 2rpx rgba(0, 0, 0, 0.3);
}
.author-text {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
text-shadow: 1rpx 1rpx 2rpx rgba(0, 0, 0, 0.3);
}
}
}
</style>

692
src/pages/index/index.vue Normal file
View File

@@ -0,0 +1,692 @@
<route lang="json">
{
"style": {
"navigationStyle": "custom"
}
}
</route>
<template>
<view class="homepage-container">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<view class="navbar-title">
<image src="/static/logo.svg" class="title-icon" mode="aspectFit" />
<text class="title-text">朱子文化</text>
</view>
<view class="navbar-subtitle">
传承千年智慧
</view>
</view>
</view>
<!-- 页面内容 -->
<scroll-view class="page-content" scroll-y>
<!-- 轮播图区域 -->
<view class="carousel-section">
<swiper
class="carousel-swiper"
:indicator-dots="true"
:autoplay="true"
:interval="3000"
:duration="500"
:circular="true"
indicator-color="rgba(255, 255, 255, 0.5)"
indicator-active-color="#DAA520"
@change="onSwiperChange"
>
<swiper-item
v-for="(article, index) in carouselArticles"
:key="article.id"
@tap="goToArticleDetail(article.id)"
>
<view class="carousel-item">
<image
:src="article.coverImage || '/static/logo.svg'"
class="carousel-image"
mode="aspectFill"
/>
<view class="carousel-overlay">
<text class="carousel-title">{{ article.title }}</text>
<text class="carousel-author">{{ article.author }}</text>
</view>
</view>
</swiper-item>
</swiper>
</view>
<!-- 答题入口区域 -->
<view class="quiz-section">
<view class="section-title">
<uni-icons type="compose" size="24" color="#2D5E3E" />
<text class="title-text">开始学习</text>
</view>
<view class="quiz-cards">
<!-- 家长答题卡片 -->
<ZhuziCard class="quiz-card parent-card ancient-card-enhanced ancient-float" @tap="goToParentQuiz">
<view class="card-header">
<view class="icon-container">
<image src="/static/logo.svg" class="quiz-icon" mode="aspectFit" />
<view class="icon-decoration" />
</view>
<view class="title-container">
<text class="card-title ancient-text calligraphy">家长答题</text>
<text class="card-subtitle">Parent Quiz</text>
</view>
</view>
<view class="card-content">
<view class="quiz-details">
<view class="detail-item">
<text class="detail-icon"></text>
<text class="quiz-desc">5道题目每题30秒</text>
</view>
<view class="detail-item">
<text class="detail-icon"></text>
<text class="quiz-reward">积分将累积到孩子身上</text>
</view>
</view>
<view class="ancient-pattern">
<text class="pattern-text"> 博学之审问之慎思之 </text>
</view>
</view>
<view class="card-footer">
<AncientButton class="start-btn ancient-btn-enhanced">
<text class="btn-text">开始答题</text>
</AncientButton>
</view>
</ZhuziCard>
<!-- 学生答题卡片 -->
<ZhuziCard class="quiz-card student-card ancient-card-enhanced ancient-float" @tap="goToStudentQuiz">
<view class="card-header">
<view class="icon-container">
<image src="/static/logo.svg" class="quiz-icon" mode="aspectFit" />
<view class="icon-decoration" />
</view>
<view class="title-container">
<text class="card-title ancient-text calligraphy">学生答题</text>
<text class="card-subtitle">Student Quiz</text>
</view>
</view>
<view class="card-content">
<view class="quiz-details">
<view class="detail-item">
<text class="detail-icon"></text>
<text class="quiz-desc">10道题目每题30秒</text>
</view>
<view class="detail-item">
<text class="detail-icon"></text>
<text class="quiz-reward">答对获得相应积分奖励</text>
</view>
</view>
<view class="ancient-pattern">
<text class="pattern-text"> 学而时习之不亦说乎 </text>
</view>
</view>
<view class="card-footer">
<AncientButton class="start-btn ancient-btn-enhanced secondary">
<text class="btn-text">开始答题</text>
</AncientButton>
</view>
</ZhuziCard>
</view>
</view>
<!-- 朱子介绍区域 -->
<view class="intro-section">
<view class="section-title">
<uni-icons type="person-filled" size="24" color="#2D5E3E" />
<text class="title-text">朱子简介</text>
</view>
<ZhuziCard class="intro-card">
<view class="intro-content">
<view class="intro-header" @tap="toggleIntroExpand">
<text class="intro-name">朱熹1130-1200</text>
<uni-icons
:type="introExpanded ? 'up' : 'down'"
size="16"
color="#696969"
class="expand-icon"
/>
</view>
<view class="intro-summary">
字元晦仲晦号晦庵晦翁别号紫阳世称朱文公我国古代伟大的思想家哲学家教育家文学家
</view>
<!-- 可展开的详细内容 -->
<view
v-if="introExpanded"
class="intro-detail"
:class="{ show: introExpanded }"
>
<text class="detail-text">{{ zhuziIntroduction }}</text>
</view>
<view class="intro-quote">
<text class="quote-text">学而时习之不亦说乎</text>
</view>
</view>
</ZhuziCard>
</view>
<!-- 底部装饰 -->
<view class="footer-decoration">
<view class="bamboo-decoration" />
<text class="footer-text">弘扬朱子文化传承千年智慧</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { mockCarouselArticles, zhuziIntroduction } from '@/mocks/article'
// 响应式数据
const carouselArticles = ref(mockCarouselArticles)
const currentSwiperIndex = ref(0)
const introExpanded = ref(false)
// 轮播图切换
function onSwiperChange(e: any) {
currentSwiperIndex.value = e.detail.current
}
// 跳转到文章详情
function goToArticleDetail(articleId: string) {
uni.navigateTo({
url: `/pages/article/detail?id=${articleId}`,
})
}
// 跳转到家长答题
function goToParentQuiz() {
uni.navigateTo({
url: '/pages/quiz/parent-quiz',
})
}
// 跳转到学生答题
function goToStudentQuiz() {
uni.navigateTo({
url: '/pages/quiz/student-quiz',
})
}
// 切换朱子介绍展开状态
function toggleIntroExpand() {
introExpanded.value = !introExpanded.value
}
// 页面加载时检查登录状态
onLoad(() => {
// 模拟检查用户登录状态
const token = uni.getStorageSync('userToken')
if (!token) {
// 未登录,跳转到登录页
uni.redirectTo({
url: '/pages/auth/login',
})
return
}
// 已登录,可以加载用户相关数据
console.log('用户已登录,加载首页数据')
})
</script>
<style lang="scss" scoped>
.homepage-container {
min-height: 100vh;
background: linear-gradient(135deg, #f8f5f0 0%, #d8d0c0 100%);
.custom-navbar {
background: linear-gradient(135deg, #8e3d3e 0%, #a76363 100%);
padding: calc(var(--status-bar-height, 0px) + 16rpx) 32rpx 16rpx;
box-shadow: 0 4rpx 16rpx rgba(45, 94, 62, 0.15);
.navbar-content {
display: flex;
flex-direction: column;
align-items: center;
.navbar-title {
display: flex;
align-items: center;
margin-bottom: 8rpx;
.title-icon {
width: 48rpx;
height: 48rpx;
margin-right: 16rpx;
}
.title-text {
font-size: 40rpx;
font-weight: bold;
color: #ffffff;
text-shadow: 2rpx 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
}
.navbar-subtitle {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
text-shadow: 1rpx 1rpx 2rpx rgba(0, 0, 0, 0.3);
}
}
}
.page-content {
height: calc(100vh - 120rpx);
padding: 32rpx;
.section-title {
display: flex;
align-items: center;
margin-bottom: 32rpx;
.title-text {
font-size: 36rpx;
font-weight: bold;
color: #8e3d3e;
margin-left: 16rpx;
// 古风标题装饰
position: relative;
&::after {
content: '';
position: absolute;
bottom: -2rpx;
left: 0;
width: 100%;
height: 2rpx;
background: linear-gradient(135deg, #d9c8a3 0%, #b8824f 100%);
border-radius: 1rpx;
}
}
}
}
// 轮播图区域
.carousel-section {
margin-bottom: 48rpx;
.carousel-swiper {
height: 400rpx;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(45, 94, 62, 0.2);
.carousel-item {
position: relative;
height: 100%;
.carousel-image {
width: 100%;
height: 100%;
}
.carousel-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
padding: 48rpx 32rpx 32rpx;
color: white;
.carousel-title {
display: block;
font-size: 32rpx;
font-weight: bold;
margin-bottom: 8rpx;
text-shadow: 1rpx 1rpx 2rpx rgba(0, 0, 0, 0.5);
}
.carousel-author {
font-size: 24rpx;
opacity: 0.9;
}
}
}
}
}
// 答题区域
.quiz-section {
margin-bottom: 48rpx;
.quiz-cards {
display: flex;
gap: 24rpx;
.quiz-card {
flex: 1;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: translateY(-8rpx);
box-shadow: 0 12rpx 32rpx rgba(45, 94, 62, 0.25);
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 32rpx;
position: relative;
.icon-container {
position: relative;
margin-right: 24rpx;
.quiz-icon {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
border: 2rpx solid rgba(142, 61, 62, 0.2);
padding: 8rpx;
background: rgba(217, 200, 163, 0.1);
}
.icon-decoration {
position: absolute;
top: -4rpx;
right: -4rpx;
width: 16rpx;
height: 16rpx;
background: #d9c8a3;
border-radius: 50%;
border: 2rpx solid #ffffff;
}
}
.title-container {
flex: 1;
.card-title {
font-size: 34rpx;
font-weight: bold;
color: #8e3d3e;
margin-bottom: 4rpx;
display: block;
}
.card-subtitle {
font-size: 20rpx;
color: #999999;
font-style: italic;
letter-spacing: 1rpx;
}
}
}
.card-content {
margin-bottom: 32rpx;
.quiz-details {
margin-bottom: 24rpx;
.detail-item {
display: flex;
align-items: center;
margin-bottom: 12rpx;
.detail-icon {
font-size: 12rpx;
color: #d9c8a3;
margin-right: 12rpx;
width: 20rpx;
text-align: center;
}
.quiz-desc {
font-size: 28rpx;
color: #333333;
font-weight: 500;
}
.quiz-reward {
font-size: 24rpx;
color: #666666;
font-style: italic;
}
}
}
.ancient-pattern {
text-align: center;
padding: 16rpx;
background: rgba(217, 200, 163, 0.08);
border-radius: 8rpx;
border: 1rpx dashed rgba(217, 200, 163, 0.3);
.pattern-text {
font-size: 20rpx;
color: #7a9e7e;
font-style: italic;
letter-spacing: 1rpx;
}
}
}
.card-footer {
.start-btn {
width: 100%;
font-size: 30rpx;
font-weight: 600;
padding: 20rpx 32rpx;
position: relative;
.btn-text {
position: relative;
z-index: 2;
}
}
}
}
// 古风卡片设计 - 移除所有边框
.parent-card {
background: linear-gradient(
135deg,
rgba(167, 99, 99, 0.06) 0%,
rgba(217, 200, 163, 0.08) 50%,
rgba(255, 255, 255, 0.95) 100%
);
box-shadow:
0 12rpx 32rpx rgba(167, 99, 99, 0.12),
0 4rpx 12rpx rgba(167, 99, 99, 0.08);
border: 1rpx solid rgba(167, 99, 99, 0.1);
.ancient-pattern {
border-color: rgba(167, 99, 99, 0.2);
background: rgba(167, 99, 99, 0.05);
}
}
.student-card {
background: linear-gradient(
135deg,
rgba(74, 115, 84, 0.06) 0%,
rgba(122, 158, 126, 0.08) 50%,
rgba(255, 255, 255, 0.95) 100%
);
box-shadow:
0 12rpx 32rpx rgba(74, 115, 84, 0.12),
0 4rpx 12rpx rgba(74, 115, 84, 0.08);
border: 1rpx solid rgba(74, 115, 84, 0.1);
.ancient-pattern {
border-color: rgba(74, 115, 84, 0.2);
background: rgba(74, 115, 84, 0.05);
}
}
}
}
// 朱子介绍区域
.intro-section {
margin-bottom: 48rpx;
.intro-card {
.intro-content {
.intro-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
cursor: pointer;
.intro-name {
font-size: 32rpx;
font-weight: bold;
color: #8e3d3e;
}
.expand-icon {
transition: transform 0.3s ease;
}
}
.intro-summary {
font-size: 28rpx;
color: #333333;
line-height: 1.7;
margin-bottom: 24rpx;
}
.intro-detail {
max-height: 0;
opacity: 0;
overflow: hidden;
transition: all 0.4s ease;
&.show {
max-height: 1000rpx;
opacity: 1;
margin-bottom: 24rpx;
}
.detail-text {
font-size: 26rpx;
color: #666666;
line-height: 1.7;
}
}
.intro-quote {
text-align: center;
padding: 24rpx;
background: rgba(217, 200, 163, 0.15);
border-radius: 16rpx;
border: 1rpx dashed #d9c8a3;
position: relative;
// 古风引用装饰
&::before {
content: '"';
position: absolute;
top: 8rpx;
left: 16rpx;
font-size: 36rpx;
color: #d9c8a3;
opacity: 0.6;
}
&::after {
content: '"';
position: absolute;
bottom: 8rpx;
right: 16rpx;
font-size: 36rpx;
color: #d9c8a3;
opacity: 0.6;
}
.quote-text {
font-size: 30rpx;
color: #8e3d3e;
font-style: italic;
font-weight: bold;
}
}
}
}
}
// 底部装饰
.footer-decoration {
text-align: center;
padding: 48rpx 0;
.bamboo-decoration {
width: 120rpx;
height: 8rpx;
background: linear-gradient(90deg, #8e3d3e 0%, #d9c8a3 30%, #7a9e7e 60%, #8e3d3e 100%);
margin: 0 auto 24rpx;
border-radius: 4rpx;
position: relative;
// 古风竹节装饰
&::before {
content: '';
position: absolute;
top: -2rpx;
left: 30%;
width: 2rpx;
height: 12rpx;
background: #7a9e7e;
border-radius: 1rpx;
}
&::after {
content: '';
position: absolute;
top: -2rpx;
right: 30%;
width: 2rpx;
height: 12rpx;
background: #7a9e7e;
border-radius: 1rpx;
}
}
.footer-text {
font-size: 24rpx;
color: #666666;
font-style: italic;
position: relative;
// 古风装饰符
&::before {
content: '※';
margin-right: 8rpx;
color: #d9c8a3;
}
&::after {
content: '※';
margin-left: 8rpx;
color: #d9c8a3;
}
}
}
}
// 适配不同屏幕尺寸
@media screen and (max-width: 750rpx) {
.quiz-cards {
flex-direction: column;
.quiz-card {
margin-bottom: 24rpx;
}
}
}
</style>

20
src/pages/login/README.md Normal file
View File

@@ -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` 里面,默认会在登录后自动重定向到来源/配置的页面。
如果与您的业务不符,您可以自行修改。

102
src/pages/login/login.vue Normal file
View File

@@ -0,0 +1,102 @@
<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 {
// 有的时候后端会用一个接口返回token和用户信息有的时候会分开2个接口各有利弊看业务场景和系统复杂度这里使用2个接口返回的来模拟
// 1/2 调用接口回来后设置token信息
// 这里用单token来模拟
tokenStore.setTokenInfo({
token: '123456',
expiresIn: 60 * 60 * 24 * 7,
})
// 2/2 调用接口回来后设置用户信息
// const res = await login({
// username: '菲鸽',
// password: '123456',
// })
// console.log('接口拿到的登录信息:', res)
userStore.setUserInfo({
userId: 123456,
username: 'abc123456',
nickname: '菲鸽',
avatar: 'https://oss.laf.run/ukw0y1-site/avatar.jpg',
})
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 {
console.log('redirectTo:', path)
uni.redirectTo({
url: path,
})
}
}
</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>

View File

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

515
src/pages/me/me.vue Normal file
View File

@@ -0,0 +1,515 @@
<route lang="json">
{
"style": {
"navigationBarTitleText": "我的",
"navigationBarBackgroundColor": "#2D5E3E",
"navigationBarTextStyle": "white"
}
}
</route>
<template>
<view class="me-container">
<!-- 用户信息卡片 -->
<view class="user-section">
<view class="user-card">
<image
:src="userInfo.avatar || '/static/images/default-avatar.png'"
class="user-avatar"
mode="aspectFill"
@tap="previewAvatar"
/>
<view class="user-info">
<text class="user-name">{{ userInfo.child?.name || '小朋友' }}</text>
<text class="user-phone">{{ formatPhone(userInfo.phone) }}</text>
<view class="school-badge">
<uni-icons type="home" size="12" color="#FFFFFF" />
<text class="school-text">{{ userInfo.child?.schoolName }}</text>
</view>
<view class="class-info">
<text class="class-text">{{ userInfo.child?.gradeName }} {{ userInfo.child?.className }} {{ userInfo.child?.seatNumber }}</text>
</view>
</view>
<view class="edit-btn" @tap="editProfile">
<uni-icons type="compose" size="20" color="#DAA520" />
</view>
</view>
<!-- 积分统计 -->
<view class="score-stats">
<view class="stat-item">
<text class="stat-value">{{ userInfo.child?.totalScore || 0 }}</text>
<text class="stat-label">总积分</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-value">{{ userInfo.child?.monthScore || 0 }}</text>
<text class="stat-label">本月积分</text>
</view>
<view class="stat-divider" />
<view class="stat-item" @tap="goToRanking">
<text class="stat-value">{{ currentRank }}</text>
<text class="stat-label">班级排名</text>
<uni-icons type="arrowright" size="12" color="#DAA520" />
</view>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<ZhuziCard class="menu-card" title="学习中心">
<view class="menu-list">
<view class="menu-item" @tap="goToQuizRecords">
<view class="menu-icon quiz-icon">
<uni-icons type="compose" size="20" color="#FFFFFF" />
</view>
<text class="menu-text">答题记录</text>
<view v-if="quizRecordCount > 0" class="menu-badge">
<text class="badge-text">{{ quizRecordCount }}</text>
</view>
<uni-icons type="arrowright" size="16" color="#CCCCCC" />
</view>
<view class="menu-item" @tap="goToRanking">
<view class="menu-icon ranking-icon">
<uni-icons type="star" size="20" color="#FFFFFF" />
</view>
<text class="menu-text">排行榜</text>
<uni-icons type="arrowright" size="16" color="#CCCCCC" />
</view>
<view class="menu-item" @tap="goToHome">
<view class="menu-icon home-icon">
<uni-icons type="home" size="20" color="#FFFFFF" />
</view>
<text class="menu-text">返回首页</text>
<uni-icons type="arrowright" size="16" color="#CCCCCC" />
</view>
</view>
</ZhuziCard>
<ZhuziCard class="menu-card" title="设置中心">
<view class="menu-list">
<view class="menu-item" @tap="shareApp">
<view class="menu-icon share-icon">
<uni-icons type="redo" size="20" color="#FFFFFF" />
</view>
<text class="menu-text">分享给朋友</text>
<uni-icons type="arrowright" size="16" color="#CCCCCC" />
</view>
<view class="menu-item" @tap="showAbout">
<view class="menu-icon about-icon">
<uni-icons type="info" size="20" color="#FFFFFF" />
</view>
<text class="menu-text">关于朱子文化</text>
<uni-icons type="arrowright" size="16" color="#CCCCCC" />
</view>
<view class="menu-item" @tap="contactUs">
<view class="menu-icon contact-icon">
<uni-icons type="phone" size="20" color="#FFFFFF" />
</view>
<text class="menu-text">联系我们</text>
<uni-icons type="arrowright" size="16" color="#CCCCCC" />
</view>
</view>
</ZhuziCard>
</view>
<!-- 朱子文化介绍 -->
<view class="culture-section">
<ZhuziCard class="culture-card">
<view class="culture-content">
<view class="culture-header">
<image src="/static/logo.svg" class="culture-icon" mode="aspectFit" />
<text class="culture-title">朱子文化学习</text>
</view>
<text class="culture-desc">传承千年智慧弘扬文化精神</text>
<view class="culture-quote">
<text class="quote-text">学而时习之不亦说乎</text>
<text class="quote-author"> 论语</text>
</view>
</view>
</ZhuziCard>
</view>
<!-- 版本信息 -->
<view class="version-section">
<text class="version-text">版本 v1.0.0</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { mockUserInfo } from '@/mocks/user'
const userInfo = ref(mockUserInfo)
const quizRecordCount = ref(0)
// 当前排名
const currentRank = computed(() => {
// 这里可以从真实的排行榜数据中获取
return '第5名'
})
// 格式化手机号
function formatPhone(phone?: string) {
if (!phone)
return '未绑定手机号'
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
// 预览头像
function previewAvatar() {
const avatarUrl = userInfo.value.avatar || '/static/images/default-avatar.png'
uni.previewImage({
urls: [avatarUrl],
current: avatarUrl,
})
}
// 编辑资料
function editProfile() {
uni.showModal({
title: '编辑资料',
content: '此功能暂未开放',
showCancel: false,
})
}
// 跳转到答题记录
function goToQuizRecords() {
uni.navigateTo({
url: '/pages/me/quiz-records',
})
}
// 跳转到排行榜
function goToRanking() {
uni.switchTab({
url: '/pages/ranking/ranking',
})
}
// 跳转到首页
function goToHome() {
uni.switchTab({
url: '/pages/index/index',
})
}
// 分享应用
function shareApp() {
uni.showShareMenu({
withShareTicket: true,
success: () => {
uni.showToast({
title: '请点击右上角分享',
icon: 'none',
})
},
})
}
// 关于朱子文化
function showAbout() {
uni.showModal({
title: '朱子文化',
content: '朱熹1130-1200我国古代伟大的思想家、哲学家、教育家、文学家。素有"北孔南朱"之称,后世尊称为"朱子"。',
showCancel: false,
})
}
// 联系我们
function contactUs() {
uni.showActionSheet({
itemList: ['客服电话', '官方邮箱', '意见反馈'],
success: (res) => {
const actions = [
() => uni.makePhoneCall({ phoneNumber: '400-123-4567' }),
() => uni.setClipboardData({ data: 'contact@zhuzi-culture.com' }),
() => uni.navigateTo({ url: '/pages/feedback/feedback' }),
]
if (actions[res.tapIndex]) {
actions[res.tapIndex]()
}
},
})
}
// 页面加载
onMounted(() => {
// 获取答题记录数量
const records = uni.getStorageSync('quizRecords') || []
quizRecordCount.value = records.length
})
// 页面分享配置
onShareAppMessage(() => {
return {
title: '朱子文化学习 - 传承千年智慧',
path: '/pages/index/index',
}
})
</script>
<style lang="scss" scoped>
.me-container {
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
.user-section {
padding: 32rpx 32rpx 16rpx;
.user-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 24rpx;
padding: 32rpx;
display: flex;
align-items: center;
box-shadow: 0 8rpx 24rpx rgba(45, 94, 62, 0.15);
margin-bottom: 24rpx;
position: relative;
.user-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
margin-right: 32rpx;
border: 4rpx solid #daa520;
cursor: pointer;
}
.user-info {
flex: 1;
.user-name {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #2d5e3e;
margin-bottom: 8rpx;
}
.user-phone {
display: block;
font-size: 24rpx;
color: #696969;
margin-bottom: 12rpx;
}
.school-badge {
display: inline-flex;
align-items: center;
gap: 8rpx;
background: #2d5e3e;
padding: 6rpx 12rpx;
border-radius: 16rpx;
margin-bottom: 8rpx;
.school-text {
font-size: 20rpx;
color: #ffffff;
}
}
.class-info {
.class-text {
font-size: 22rpx;
color: #daa520;
background: rgba(218, 165, 32, 0.1);
padding: 4rpx 8rpx;
border-radius: 8rpx;
}
}
}
.edit-btn {
position: absolute;
top: 24rpx;
right: 24rpx;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba(218, 165, 32, 0.1);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
}
.score-stats {
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
padding: 32rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 4rpx 16rpx rgba(45, 94, 62, 0.1);
.stat-item {
text-align: center;
position: relative;
cursor: pointer;
.stat-value {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #daa520;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #696969;
}
}
.stat-divider {
width: 1rpx;
height: 60rpx;
background: #e6e6e6;
}
}
}
.menu-section {
padding: 16rpx 32rpx;
.menu-card {
margin-bottom: 24rpx;
.menu-list {
.menu-item {
display: flex;
align-items: center;
padding: 32rpx 0;
border-bottom: 1rpx solid #f0f0f0;
cursor: pointer;
position: relative;
&:last-child {
border-bottom: none;
}
.menu-icon {
width: 48rpx;
height: 48rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
background: linear-gradient(135deg, #4ecdc4 0%, #7fdbda 100%);
}
&.ranking-icon {
background: linear-gradient(135deg, #ffd93d 0%, #ff6b6b 100%);
}
&.home-icon {
background: linear-gradient(135deg, #6c63ff 0%, #9c88ff 100%);
}
&.share-icon {
background: linear-gradient(135deg, #ff9500 0%, #ffad33 100%);
}
&.about-icon {
background: linear-gradient(135deg, #2d5e3e 0%, #4a7b5c 100%);
}
&.contact-icon {
background: linear-gradient(135deg, #ff6b9d 0%, #ff8cc8 100%);
}
.menu-text {
flex: 1;
font-size: 28rpx;
color: #2f4f4f;
}
.menu-badge {
background: #ff4444;
border-radius: 12rpx;
padding: 2rpx 8rpx;
margin-right: 16rpx;
.badge-text {
font-size: 20rpx;
color: #ffffff;
}
}
}
}
}
}
.culture-section {
padding: 0 32rpx 16rpx;
.culture-card {
.culture-content {
text-align: center;
.culture-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
.culture-icon {
width: 48rpx;
height: 48rpx;
margin-right: 16rpx;
}
.culture-title {
font-size: 28rpx;
font-weight: bold;
color: #2d5e3e;
}
}
.culture-desc {
display: block;
font-size: 24rpx;
color: #696969;
margin-bottom: 24rpx;
}
.culture-quote {
background: rgba(218, 165, 32, 0.1);
border-radius: 16rpx;
padding: 24rpx;
border: 1rpx dashed #daa520;
.quote-text {
display: block;
font-size: 26rpx;
color: #2d5e3e;
font-style: italic;
margin-bottom: 8rpx;
}
.quote-author {
font-size: 20rpx;
color: #daa520;
}
}
}
}
}
.version-section {
padding: 32rpx;
text-align: center;
.version-text {
font-size: 20rpx;
color: #cccccc;
}
}
}
</style>

View File

@@ -0,0 +1,576 @@
<route lang="json">
{
"style": {
"navigationBarTitleText": "答题记录",
"navigationBarBackgroundColor": "#2D5E3E",
"navigationBarTextStyle": "white"
}
}
</route>
<template>
<view class="quiz-records-container">
<!-- 统计信息 -->
<view class="stats-section">
<ZhuziCard class="stats-card">
<view class="stats-content">
<view class="stats-grid">
<view class="stat-item">
<text class="stat-number">{{ totalRecords }}</text>
<text class="stat-label">总答题次数</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ totalScore }}</text>
<text class="stat-label">累计得分</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ averageScore.toFixed(1) }}</text>
<text class="stat-label">平均得分</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ accuracyRate }}%</text>
<text class="stat-label">平均正确率</text>
</view>
</view>
</view>
</ZhuziCard>
</view>
<!-- 筛选选项 -->
<view class="filter-section">
<view class="filter-tabs">
<view
v-for="filter in filterOptions"
:key="filter.key"
class="filter-item"
:class="{ active: currentFilter === filter.key }"
@tap="switchFilter(filter.key)"
>
<text class="filter-text">{{ filter.name }}</text>
</view>
</view>
</view>
<!-- 答题记录列表 -->
<view class="records-section">
<scroll-view
class="records-list"
scroll-y
enable-flex
@scrolltolower="loadMoreRecords"
>
<view
v-for="record in filteredRecords"
:key="record.id"
class="record-item"
:class="{ 'parent-record': record.type === 'parent' }"
@tap="viewRecordDetail(record)"
>
<view class="record-header">
<view class="record-type-badge" :class="record.type">
<uni-icons
:type="record.type === 'parent' ? 'person' : 'person-filled'"
size="12"
color="#FFFFFF"
/>
<text class="type-text">{{ record.type === 'parent' ? '家长答题' : '学生答题' }}</text>
</view>
<text class="record-date">{{ formatDate(record.date) }}</text>
</view>
<view class="record-content">
<view class="score-display">
<text class="score-number">{{ record.score }}</text>
<text class="score-unit"></text>
<view class="score-badge" :class="getScoreLevel(record.score, record.totalQuestions)">
<text class="badge-text">{{ getScoreLevelText(record.score, record.totalQuestions) }}</text>
</view>
</view>
<view class="record-details">
<view class="detail-row">
<text class="detail-label">答题情况</text>
<text class="detail-value">{{ record.correctAnswers }}/{{ record.totalQuestions }} 题正确</text>
</view>
<view class="detail-row">
<text class="detail-label">用时</text>
<text class="detail-value">{{ formatTime(record.timeSpent) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">正确率</text>
<text class="detail-value" :class="{ 'high-rate': getAccuracy(record) >= 80 }">
{{ getAccuracy(record) }}%
</text>
</view>
</view>
<view class="record-actions">
<uni-icons type="arrow-right" size="16" color="#CCCCCC" />
</view>
</view>
<!-- 成绩进步标识 -->
<view v-if="isImprovement(record)" class="improvement-badge">
<uni-icons type="up" size="12" color="#32CD32" />
<text class="improvement-text">进步了</text>
</view>
</view>
<!-- 加载更多 -->
<view v-if="hasMoreRecords" class="load-more" @tap="loadMoreRecords">
<text class="load-more-text">加载更多</text>
<uni-icons type="arrow-down" size="16" color="#CCCCCC" />
</view>
<!-- 空状态 -->
<view v-if="filteredRecords.length === 0" class="empty-state">
<uni-icons type="list" size="80" color="#CCCCCC" />
<text class="empty-title">暂无答题记录</text>
<text class="empty-desc">快去首页开始答题吧</text>
<AncientButton class="go-quiz-btn" @click="goToHome">
开始答题
</AncientButton>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import type { QuizRecord } from '@/mocks/user'
import { computed, onMounted, ref } from 'vue'
import { mockQuizRecords } from '@/mocks/user'
// 响应式数据
const quizRecords = ref<QuizRecord[]>([])
const currentFilter = ref<'all' | 'parent' | 'student'>('all')
const hasMoreRecords = ref(false)
const pageSize = 10
const currentPage = ref(1)
// 筛选选项
const filterOptions = [
{ key: 'all' as const, name: '全部' },
{ key: 'parent' as const, name: '家长答题' },
{ key: 'student' as const, name: '学生答题' },
]
// 计算属性
const filteredRecords = computed(() => {
let records = quizRecords.value
if (currentFilter.value !== 'all') {
records = records.filter(record => record.type === currentFilter.value)
}
return records.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
})
const totalRecords = computed(() => quizRecords.value.length)
const totalScore = computed(() => {
return quizRecords.value.reduce((sum, record) => sum + record.score, 0)
})
const averageScore = computed(() => {
if (totalRecords.value === 0)
return 0
return totalScore.value / totalRecords.value
})
const accuracyRate = computed(() => {
if (totalRecords.value === 0)
return 0
const totalCorrect = quizRecords.value.reduce((sum, record) => sum + record.correctAnswers, 0)
const totalQuestions = quizRecords.value.reduce((sum, record) => sum + record.totalQuestions, 0)
return Math.round((totalCorrect / totalQuestions) * 100)
})
// 方法
function formatDate(dateStr: string) {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`
}
else if (days === 1) {
return `昨天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`
}
else if (days <= 7) {
return `${days}天前`
}
else {
return date.toLocaleDateString('zh-CN')
}
}
function formatTime(seconds: number) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}${remainingSeconds}`
}
function getAccuracy(record: QuizRecord) {
return Math.round((record.correctAnswers / record.totalQuestions) * 100)
}
function getScoreLevel(score: number, totalQuestions: number) {
const accuracy = (score / (totalQuestions * 20)) * 100 // 假设每题最高20分
if (accuracy >= 90)
return 'excellent'
if (accuracy >= 80)
return 'good'
if (accuracy >= 70)
return 'pass'
return 'need-improve'
}
function getScoreLevelText(score: number, totalQuestions: number) {
const accuracy = (score / (totalQuestions * 20)) * 100 // 假设每题最高20分
if (accuracy >= 90)
return '优秀'
if (accuracy >= 80)
return '良好'
if (accuracy >= 70)
return '合格'
return '需努力'
}
function isImprovement(record: QuizRecord) {
const currentIndex = filteredRecords.value.findIndex(r => r.id === record.id)
if (currentIndex >= filteredRecords.value.length - 1)
return false
const previousRecord = filteredRecords.value[currentIndex + 1]
if (!previousRecord || previousRecord.type !== record.type)
return false
return record.score > previousRecord.score
}
function switchFilter(filter: 'all' | 'parent' | 'student') {
currentFilter.value = filter
}
function loadMoreRecords() {
if (!hasMoreRecords.value)
return
// 模拟加载更多数据
currentPage.value++
// 这里可以调用API加载更多数据
console.log('加载更多记录...')
}
function viewRecordDetail(record: QuizRecord) {
uni.showModal({
title: '答题详情',
content: `类型:${record.type === 'parent' ? '家长答题' : '学生答题'}\n得分${record.score}\n正确${record.correctAnswers}/${record.totalQuestions}\n用时${formatTime(record.timeSpent)}`,
showCancel: false,
})
}
function goToHome() {
uni.switchTab({
url: '/pages/index/index',
})
}
// 生命周期
onMounted(() => {
// 从本地存储获取答题记录
const storedRecords = uni.getStorageSync('quizRecords') || []
quizRecords.value = storedRecords.length > 0 ? storedRecords : mockQuizRecords
// 判断是否有更多数据
hasMoreRecords.value = quizRecords.value.length > pageSize
})
</script>
<style lang="scss" scoped>
.quiz-records-container {
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
.stats-section {
padding: 32rpx;
.stats-card {
.stats-content {
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32rpx;
.stat-item {
text-align: center;
.stat-number {
display: block;
font-size: 40rpx;
font-weight: bold;
color: #daa520;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #696969;
}
}
}
}
}
}
.filter-section {
padding: 0 32rpx 16rpx;
.filter-tabs {
background: rgba(255, 255, 255, 0.8);
border-radius: 16rpx;
padding: 8rpx;
display: flex;
.filter-item {
flex: 1;
text-align: center;
padding: 16rpx 0;
border-radius: 12rpx;
transition: all 0.3s ease;
&.active {
background: #2d5e3e;
.filter-text {
color: #ffffff;
font-weight: bold;
}
}
.filter-text {
font-size: 28rpx;
color: #2d5e3e;
}
}
}
}
.records-section {
padding: 0 32rpx 32rpx;
.records-list {
max-height: calc(100vh - 400rpx);
.record-item {
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(45, 94, 62, 0.1);
position: relative;
transition: all 0.3s ease;
&.parent-record {
border-left: 4rpx solid #daa520;
}
&:not(.parent-record) {
border-left: 4rpx solid #32cd32;
}
&:active {
transform: scale(0.98);
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
.record-type-badge {
display: flex;
align-items: center;
gap: 8rpx;
padding: 6rpx 12rpx;
border-radius: 16rpx;
&.parent {
background: #daa520;
}
&.student {
background: #32cd32;
}
.type-text {
font-size: 20rpx;
color: #ffffff;
font-weight: bold;
}
}
.record-date {
font-size: 24rpx;
color: #696969;
}
}
.record-content {
display: flex;
align-items: center;
.score-display {
display: flex;
align-items: baseline;
margin-right: 24rpx;
position: relative;
.score-number {
font-size: 36rpx;
font-weight: bold;
color: #2d5e3e;
}
.score-unit {
font-size: 20rpx;
color: #696969;
margin-left: 4rpx;
}
.score-badge {
position: absolute;
top: -12rpx;
right: -32rpx;
padding: 2rpx 8rpx;
border-radius: 12rpx;
font-size: 18rpx;
&.excellent {
background: #32cd32;
color: white;
}
&.good {
background: #4ecdc4;
color: white;
}
&.pass {
background: #ffa726;
color: white;
}
&.need-improve {
background: #ff6b6b;
color: white;
}
.badge-text {
font-size: 18rpx;
font-weight: bold;
}
}
}
.record-details {
flex: 1;
.detail-row {
display: flex;
margin-bottom: 6rpx;
&:last-child {
margin-bottom: 0;
}
.detail-label {
font-size: 22rpx;
color: #696969;
min-width: 120rpx;
}
.detail-value {
font-size: 22rpx;
color: #2f4f4f;
&.high-rate {
color: #32cd32;
font-weight: bold;
}
}
}
}
.record-actions {
margin-left: 16rpx;
}
}
.improvement-badge {
position: absolute;
top: 16rpx;
right: 16rpx;
display: flex;
align-items: center;
gap: 4rpx;
background: rgba(50, 205, 50, 0.1);
border: 1rpx solid #32cd32;
border-radius: 12rpx;
padding: 4rpx 8rpx;
.improvement-text {
font-size: 18rpx;
color: #32cd32;
font-weight: bold;
}
}
}
.load-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 32rpx;
cursor: pointer;
.load-more-text {
font-size: 24rpx;
color: #cccccc;
}
}
.empty-state {
text-align: center;
padding: 120rpx 32rpx;
.empty-title {
display: block;
font-size: 32rpx;
color: #cccccc;
margin: 24rpx 0 12rpx;
}
.empty-desc {
display: block;
font-size: 24rpx;
color: #cccccc;
margin-bottom: 48rpx;
}
.go-quiz-btn {
width: 200rpx;
margin: 0 auto;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,614 @@
<route lang="json">
{
"style": {
"navigationBarTitleText": "家长答题",
"navigationBarBackgroundColor": "#2D5E3E",
"navigationBarTextStyle": "white"
}
}
</route>
<template>
<view class="quiz-container">
<!-- 答题进度和倒计时 -->
<view class="quiz-header">
<view class="progress-info">
<text class="question-number">{{ currentQuestionIndex + 1 }}/{{ totalQuestions }}</text>
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: `${progressWidth}%` }"
/>
</view>
</view>
<view class="timer-container" :class="{ warning: timeLeft <= 10 }">
<uni-icons type="clock" size="16" color="#FFFFFF" />
<text class="timer-text">{{ timeLeft }}s</text>
</view>
</view>
<!-- 题目卡片 -->
<ZhuziCard v-if="currentQuestion" class="question-card">
<view class="question-content">
<!-- 题目类型和分值 -->
<view class="question-meta">
<text class="question-type">{{ currentQuestion.type === 'single' ? '单选题' : '多选题' }}</text>
<text class="question-score">{{ currentQuestion.score }}</text>
</view>
<!-- 题目标题 -->
<view class="question-title">
<text>{{ currentQuestion.title }}</text>
</view>
<!-- 选项列表 -->
<view class="options-container">
<view
v-for="option in currentQuestion.options"
:key="option.id"
class="option-item"
:class="{
selected: selectedAnswers.includes(option.id),
correct: showAnswer && currentQuestion.correctAnswers.includes(option.id),
incorrect: showAnswer && selectedAnswers.includes(option.id) && !currentQuestion.correctAnswers.includes(option.id),
}"
@tap="selectOption(option.id)"
>
<view class="option-content">
<view class="option-icon">
<view
v-if="currentQuestion.type === 'single'"
class="radio-icon"
:class="{ checked: selectedAnswers.includes(option.id) }"
/>
<view
v-else
class="checkbox-icon"
:class="{ checked: selectedAnswers.includes(option.id) }"
>
<uni-icons v-if="selectedAnswers.includes(option.id)" type="checkmarkempty" size="14" color="#FFFFFF" />
</view>
</view>
<text class="option-text">{{ option.text }}</text>
</view>
</view>
</view>
<!-- 多选题确认按钮 -->
<AncientButton
v-if="currentQuestion.type === 'multiple' && !showAnswer"
class="confirm-btn"
:disabled="selectedAnswers.length === 0"
@click="confirmAnswer"
>
确认答案
</AncientButton>
</view>
</ZhuziCard>
<!-- 答题反馈 -->
<view v-if="showFeedback" class="feedback-overlay" @tap="nextQuestion">
<view class="feedback-content">
<uni-icons
:type="isCorrect ? 'checkmarkempty' : 'closeempty'"
:size="80"
:color="isCorrect ? '#32CD32' : '#FF4444'"
class="feedback-icon"
/>
<text class="feedback-text">{{ isCorrect ? '回答正确!' : '很遗憾,答错了' }}</text>
<text v-if="isCorrect" class="score-text">+{{ currentQuestion?.score }}</text>
<text class="next-hint">点击任意处继续</text>
</view>
</view>
<!-- 超时提示 -->
<view v-if="showTimeout" class="timeout-overlay" @tap="nextQuestion">
<view class="timeout-content">
<uni-icons type="clock" size="80" color="#FF6B6B" />
<text class="timeout-text">时间到了</text>
<text class="timeout-hint">点击任意处继续</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { QuizQuestion } from '@/mocks/quiz'
import { computed, onUnmounted, ref } from 'vue'
import { mockParentQuestions } from '@/mocks/quiz'
// 响应式数据
const questions = ref<QuizQuestion[]>([...mockParentQuestions])
const currentQuestionIndex = ref(0)
const selectedAnswers = ref<string[]>([])
const showAnswer = ref(false)
const showFeedback = ref(false)
const showTimeout = ref(false)
const timeLeft = ref(30)
const totalScore = ref(0)
const correctCount = ref(0)
const timer = ref<number | null>(null)
const startTime = ref<number>(Date.now())
// 计算属性
const totalQuestions = computed(() => questions.value.length)
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value])
const progressWidth = computed(() => ((currentQuestionIndex.value + 1) / totalQuestions.value) * 100)
const isCorrect = ref(false)
// 开始倒计时
function startTimer() {
if (timer.value)
clearInterval(timer.value)
timeLeft.value = 30
timer.value = setInterval(() => {
timeLeft.value--
if (timeLeft.value <= 0) {
// 时间到了
handleTimeout()
}
}, 1000) as unknown as number
}
// 处理超时
function handleTimeout() {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
showTimeout.value = true
// 2秒后自动进入下一题
setTimeout(() => {
nextQuestion()
}, 2000)
}
// 选择选项
function selectOption(optionId: string) {
if (showAnswer.value || showFeedback.value || showTimeout.value)
return
if (currentQuestion.value?.type === 'single') {
selectedAnswers.value = [optionId]
// 单选题立即确认答案
setTimeout(() => {
confirmAnswer()
}, 300)
}
else {
// 多选题切换选择状态
const index = selectedAnswers.value.indexOf(optionId)
if (index > -1) {
selectedAnswers.value.splice(index, 1)
}
else {
selectedAnswers.value.push(optionId)
}
}
}
// 确认答案
function confirmAnswer() {
if (showAnswer.value || !currentQuestion.value)
return
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
showAnswer.value = true
// 检查答案是否正确
const correctAnswers = currentQuestion.value.correctAnswers
const isAnswerCorrect = selectedAnswers.value.length === correctAnswers.length
&& selectedAnswers.value.every(answer => correctAnswers.includes(answer))
isCorrect.value = isAnswerCorrect
if (isAnswerCorrect) {
totalScore.value += currentQuestion.value.score
correctCount.value++
}
// 显示反馈
setTimeout(() => {
showAnswer.value = false
showFeedback.value = true
// 3秒后自动进入下一题
setTimeout(() => {
if (showFeedback.value) {
nextQuestion()
}
}, 3000)
}, 1500)
}
// 下一题
function nextQuestion() {
showFeedback.value = false
showTimeout.value = false
selectedAnswers.value = []
if (currentQuestionIndex.value < totalQuestions.value - 1) {
currentQuestionIndex.value++
startTimer()
}
else {
// 答题完成,跳转到结果页
finishQuiz()
}
}
// 完成答题
function finishQuiz() {
const endTime = Date.now()
const timeSpent = Math.floor((endTime - startTime.value) / 1000)
const result = {
type: 'parent' as const,
totalScore: totalScore.value,
correctCount: correctCount.value,
totalQuestions: totalQuestions.value,
timeSpent,
questions: questions.value,
}
// 保存结果到本地存储
uni.setStorageSync('quizResult', result)
// 跳转到结果页
uni.redirectTo({
url: '/pages/quiz/result',
})
}
// 监听页面返回
function handleBackPress() {
uni.showModal({
title: '确认退出',
content: '答题尚未完成,确定要退出吗?',
success: (res) => {
if (res.confirm) {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
uni.navigateBack()
}
},
})
return true // 阻止默认返回行为
}
// 页面加载时开始答题
onLoad(() => {
// 随机打乱题目顺序
questions.value = questions.value.sort(() => Math.random() - 0.5)
startTime.value = Date.now()
startTimer()
})
// 页面卸载时清理定时器
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
// 监听页面隐藏和显示
onHide(() => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
onShow(() => {
if (!showFeedback.value && !showTimeout.value && !showAnswer.value && timeLeft.value > 0) {
startTimer()
}
})
// 监听返回按钮
uni.onBackPress?.(handleBackPress)
</script>
<style lang="scss" scoped>
.quiz-container {
min-height: 100vh;
background: linear-gradient(to bottom, #e6f3e6 0%, #d4f0d4 100%);
padding: 32rpx;
.quiz-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
.progress-info {
flex: 1;
margin-right: 32rpx;
.question-number {
font-size: 28rpx;
font-weight: bold;
color: #2d5e3e;
margin-bottom: 16rpx;
display: block;
}
.progress-bar {
height: 8rpx;
background: rgba(45, 94, 62, 0.2);
border-radius: 4rpx;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #2d5e3e 0%, #daa520 100%);
transition: width 0.3s ease;
}
}
}
.timer-container {
display: flex;
align-items: center;
gap: 8rpx;
background: #2d5e3e;
padding: 12rpx 20rpx;
border-radius: 20rpx;
transition: all 0.3s ease;
&.warning {
background: #ff4444;
animation: pulse 1s infinite;
}
.timer-text {
font-size: 28rpx;
font-weight: bold;
color: #ffffff;
}
}
}
.question-card {
margin-bottom: 32rpx;
.question-content {
.question-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
.question-type {
background: rgba(45, 94, 62, 0.1);
color: #2d5e3e;
font-size: 24rpx;
padding: 8rpx 16rpx;
border-radius: 16rpx;
font-weight: bold;
}
.question-score {
background: linear-gradient(135deg, #daa520 0%, #ffd700 100%);
color: white;
font-size: 24rpx;
font-weight: bold;
padding: 8rpx 16rpx;
border-radius: 16rpx;
}
}
.question-title {
font-size: 32rpx;
font-weight: bold;
color: #2d5e3e;
line-height: 1.5;
margin-bottom: 48rpx;
}
.options-container {
.option-item {
margin-bottom: 24rpx;
.option-content {
display: flex;
align-items: center;
padding: 32rpx;
background: rgba(255, 255, 255, 0.8);
border: 2rpx solid transparent;
border-radius: 16rpx;
transition: all 0.3s ease;
cursor: pointer;
.option-icon {
margin-right: 24rpx;
.radio-icon {
width: 40rpx;
height: 40rpx;
border: 3rpx solid #cccccc;
border-radius: 50%;
position: relative;
transition: all 0.3s ease;
&.checked {
border-color: #daa520;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20rpx;
height: 20rpx;
background: #daa520;
border-radius: 50%;
transform: translate(-50%, -50%);
}
}
}
.checkbox-icon {
width: 40rpx;
height: 40rpx;
border: 3rpx solid #cccccc;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
&.checked {
background: #daa520;
border-color: #daa520;
}
}
}
.option-text {
flex: 1;
font-size: 28rpx;
color: #2f4f4f;
line-height: 1.5;
}
}
&.selected .option-content {
background: rgba(218, 165, 32, 0.1);
border-color: #daa520;
}
&.correct .option-content {
background: rgba(50, 205, 50, 0.1);
border-color: #32cd32;
}
&.incorrect .option-content {
background: rgba(255, 68, 68, 0.1);
border-color: #ff4444;
}
}
}
.confirm-btn {
width: 100%;
margin-top: 32rpx;
}
}
}
// 反馈弹窗
.feedback-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
.feedback-content {
background: white;
border-radius: 24rpx;
padding: 64rpx 48rpx;
text-align: center;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.2);
animation: slideInUp 0.4s ease-out;
.feedback-icon {
margin-bottom: 32rpx;
}
.feedback-text {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #2d5e3e;
margin-bottom: 16rpx;
}
.score-text {
display: block;
font-size: 28rpx;
color: #daa520;
font-weight: bold;
margin-bottom: 24rpx;
}
.next-hint {
font-size: 24rpx;
color: #696969;
}
}
}
// 超时弹窗
.timeout-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 68, 68, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
.timeout-content {
text-align: center;
animation: pulse 0.6s ease-in-out;
.timeout-text {
display: block;
font-size: 40rpx;
font-weight: bold;
color: white;
margin: 32rpx 0 16rpx;
text-shadow: 2rpx 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
.timeout-hint {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
}
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes slideInUp {
from {
transform: translateY(100rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

680
src/pages/quiz/result.vue Normal file
View File

@@ -0,0 +1,680 @@
<route lang="json">
{
"style": {
"navigationBarTitleText": "答题结果",
"navigationBarBackgroundColor": "#2D5E3E",
"navigationBarTextStyle": "white"
}
}
</route>
<template>
<view class="result-container">
<!-- 用户头像和信息 -->
<view class="user-section">
<image
:src="userInfo.avatar || '/static/images/default-avatar.png'"
class="user-avatar"
mode="aspectFill"
/>
<view class="user-info">
<text class="student-name">{{ userInfo.child?.name || '小朋友' }}</text>
<text class="parent-name">家长{{ userInfo.phone || '未知' }}</text>
<view class="school-info">
<text class="school-text">{{ userInfo.child?.schoolName }} {{ userInfo.child?.gradeName }} {{ userInfo.child?.className }}</text>
</view>
</view>
</view>
<!-- 答题结果卡片 -->
<ZhuziCard class="result-card" :class="{ 'student-result': quizResult?.type === 'student' }">
<view class="result-content">
<!-- 成绩标题 -->
<view class="result-header">
<text class="result-title">{{ quizResult?.type === 'parent' ? '家长答题' : '学生答题' }}成绩</text>
<text class="result-date">{{ formatDate() }}</text>
</view>
<!-- 核心成绩展示 -->
<view class="score-section">
<view class="main-score">
<text class="score-number">{{ quizResult?.totalScore || 0 }}</text>
<text class="score-unit"></text>
</view>
<view class="score-details">
<view class="detail-item">
<text class="detail-label">正确题数</text>
<text class="detail-value">{{ quizResult?.correctCount }}/{{ quizResult?.totalQuestions }}</text>
</view>
<view class="detail-item">
<text class="detail-label">答题时间</text>
<text class="detail-value">{{ formatTime(quizResult?.timeSpent) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">正确率</text>
<text class="detail-value">{{ accuracy }}%</text>
</view>
</view>
</view>
<!-- 成绩等级 -->
<view class="grade-section">
<view class="grade-badge" :class="gradeLevel.class">
<text class="grade-text">{{ gradeLevel.text }}</text>
</view>
<text class="grade-desc">{{ gradeLevel.desc }}</text>
</view>
<!-- 激励文字 -->
<view class="encouragement">
<text class="encourage-text">{{ encouragementText }}</text>
</view>
</view>
</ZhuziCard>
<!-- 分享区域 -->
<view class="share-section">
<view class="section-title">
<uni-icons type="gift" size="20" color="#DAA520" />
<text class="title-text">分享成绩</text>
</view>
<view id="shareCanvas" class="share-preview">
<view class="share-content">
<!-- 背景装饰 -->
<view class="share-bg">
<view class="bamboo-pattern" />
</view>
<!-- 分享卡片内容 -->
<view class="share-card">
<view class="share-header">
<image src="/static/logo.svg" class="share-logo" mode="aspectFit" />
<text class="share-title">朱子文化学习</text>
</view>
<view class="share-user">
<image
:src="userInfo.avatar || '/static/images/default-avatar.png'"
class="share-avatar"
mode="aspectFill"
/>
<text class="share-name">{{ userInfo.child?.name || '小朋友' }}</text>
</view>
<view class="share-score">
<text class="share-score-num">{{ quizResult?.totalScore || 0 }}</text>
<text class="share-score-unit"></text>
</view>
<view class="share-details">
<text class="share-type">{{ quizResult?.type === 'parent' ? '家长答题' : '学生答题' }}</text>
<text class="share-accuracy">正确率 {{ accuracy }}%</text>
</view>
<!-- 小程序码占位 -->
<view class="qr-placeholder">
<uni-icons type="scan" size="40" color="#CCCCCC" />
<text class="qr-text">小程序码</text>
</view>
</view>
</view>
</view>
<view class="share-actions">
<AncientButton class="share-btn" @click="shareToFriends">
<uni-icons type="redo" size="16" color="#FFFFFF" />
分享给朋友
</AncientButton>
<AncientButton class="generate-btn" @click="generateImage">
<uni-icons type="image" size="16" color="#FFFFFF" />
生成图片
</AncientButton>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-section">
<AncientButton class="action-btn secondary" @click="viewRecord">
查看答题记录
</AncientButton>
<AncientButton class="action-btn primary" @click="backToHome">
返回首页
</AncientButton>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { mockUserInfo } from '@/mocks/user'
interface QuizResult {
type: 'parent' | 'student'
totalScore: number
correctCount: number
totalQuestions: number
timeSpent: number
questions: any[]
}
const quizResult = ref<QuizResult | null>(null)
const userInfo = ref(mockUserInfo)
// 计算正确率
const accuracy = computed(() => {
if (!quizResult.value)
return 0
return Math.round((quizResult.value.correctCount / quizResult.value.totalQuestions) * 100)
})
// 成绩等级
const gradeLevel = computed(() => {
const score = accuracy.value
if (score >= 90) {
return { text: '优秀', class: 'excellent', desc: '表现出色,继续保持!' }
}
else if (score >= 80) {
return { text: '良好', class: 'good', desc: '很不错,再接再厉!' }
}
else if (score >= 70) {
return { text: '合格', class: 'pass', desc: '基础扎实,继续努力!' }
}
else {
return { text: '需努力', class: 'need-work', desc: '多多学习,相信你会进步!' }
}
})
// 激励文字
const encouragementText = computed(() => {
const texts = {
parent: [
'感谢您对朱子文化的学习和传承!',
'您的参与为孩子树立了良好的榜样!',
'家庭教育是最好的启蒙教育!',
],
student: [
'学而时习之,不亦说乎!',
'朱子的智慧伴您成长!',
'传承文化,从小做起!',
],
}
const typeTexts = texts[quizResult.value?.type || 'student']
return typeTexts[Math.floor(Math.random() * typeTexts.length)]
})
// 格式化时间
function formatTime(seconds?: number) {
if (!seconds)
return '0分0秒'
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}${remainingSeconds}`
}
// 格式化日期
function formatDate() {
const now = new Date()
return `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}`
}
// 分享给朋友
function shareToFriends() {
uni.showShareMenu({
withShareTicket: true,
success: () => {
uni.showToast({
title: '请点击右上角分享',
icon: 'none',
})
},
})
}
// 生成分享图片
function generateImage() {
uni.showLoading({
title: '生成中...',
})
// 模拟生成图片
setTimeout(() => {
uni.hideLoading()
uni.showToast({
title: '图片已保存到相册',
icon: 'success',
})
}, 2000)
}
// 查看答题记录
function viewRecord() {
uni.navigateTo({
url: '/pages/me/quiz-records',
})
}
// 返回首页
function backToHome() {
uni.switchTab({
url: '/pages/index/index',
})
}
// 页面加载时获取答题结果
onMounted(() => {
const result = uni.getStorageSync('quizResult')
if (result) {
quizResult.value = result
// 模拟保存答题记录到历史记录
const records = uni.getStorageSync('quizRecords') || []
records.unshift({
id: Date.now().toString(),
date: new Date().toISOString(),
type: result.type,
score: result.totalScore,
totalQuestions: result.totalQuestions,
correctAnswers: result.correctCount,
timeSpent: result.timeSpent,
})
uni.setStorageSync('quizRecords', records)
}
else {
// 没有答题结果,返回首页
uni.showToast({
title: '暂无答题结果',
icon: 'none',
})
setTimeout(() => {
backToHome()
}, 1500)
}
})
// 页面分享配置
onShareAppMessage(() => {
return {
title: `我在朱子文化学习中获得了${quizResult.value?.totalScore || 0}分!`,
path: '/pages/index/index',
}
})
onShareTimeline(() => {
return {
title: `朱子文化学习 - 获得${quizResult.value?.totalScore || 0}`,
}
})
</script>
<style lang="scss" scoped>
.result-container {
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
padding: 32rpx;
.user-section {
display: flex;
align-items: center;
margin-bottom: 32rpx;
background: rgba(255, 255, 255, 0.9);
padding: 32rpx;
border-radius: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(45, 94, 62, 0.1);
.user-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
margin-right: 32rpx;
border: 4rpx solid #daa520;
}
.user-info {
flex: 1;
.student-name {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #2d5e3e;
margin-bottom: 8rpx;
}
.parent-name {
display: block;
font-size: 24rpx;
color: #696969;
margin-bottom: 12rpx;
}
.school-info {
.school-text {
font-size: 24rpx;
color: #daa520;
background: rgba(218, 165, 32, 0.1);
padding: 6rpx 12rpx;
border-radius: 12rpx;
}
}
}
}
.result-card {
margin-bottom: 32rpx;
&.student-result {
border-left: 6rpx solid #32cd32;
}
.result-content {
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 48rpx;
.result-title {
font-size: 32rpx;
font-weight: bold;
color: #2d5e3e;
}
.result-date {
font-size: 24rpx;
color: #696969;
}
}
.score-section {
text-align: center;
margin-bottom: 48rpx;
.main-score {
margin-bottom: 32rpx;
.score-number {
font-size: 120rpx;
font-weight: bold;
background: linear-gradient(135deg, #daa520 0%, #ffd700 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 2rpx 2rpx 4rpx rgba(218, 165, 32, 0.3);
}
.score-unit {
font-size: 40rpx;
color: #2d5e3e;
margin-left: 16rpx;
}
}
.score-details {
display: flex;
justify-content: space-around;
.detail-item {
display: flex;
flex-direction: column;
align-items: center;
.detail-label {
font-size: 24rpx;
color: #696969;
margin-bottom: 8rpx;
}
.detail-value {
font-size: 28rpx;
font-weight: bold;
color: #2d5e3e;
}
}
}
}
.grade-section {
text-align: center;
margin-bottom: 32rpx;
.grade-badge {
display: inline-block;
padding: 16rpx 32rpx;
border-radius: 32rpx;
font-weight: bold;
margin-bottom: 16rpx;
&.excellent {
background: linear-gradient(135deg, #ff6b6b 0%, #ff8e8e 100%);
color: white;
}
&.good {
background: linear-gradient(135deg, #4ecdc4 0%, #7fdbda 100%);
color: white;
}
&.pass {
background: linear-gradient(135deg, #45b7d1 0%, #73c6e6 100%);
color: white;
}
&.need-work {
background: linear-gradient(135deg, #ffa726 0%, #ffcc80 100%);
color: white;
}
.grade-text {
font-size: 28rpx;
}
}
.grade-desc {
display: block;
font-size: 24rpx;
color: #696969;
}
}
.encouragement {
text-align: center;
padding: 32rpx;
background: rgba(218, 165, 32, 0.1);
border-radius: 16rpx;
border: 1rpx dashed #daa520;
.encourage-text {
font-size: 28rpx;
color: #2d5e3e;
font-style: italic;
font-weight: bold;
}
}
}
}
.share-section {
margin-bottom: 48rpx;
.section-title {
display: flex;
align-items: center;
margin-bottom: 24rpx;
.title-text {
font-size: 32rpx;
font-weight: bold;
color: #2d5e3e;
margin-left: 12rpx;
}
}
.share-preview {
background: white;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(45, 94, 62, 0.1);
.share-content {
position: relative;
background: linear-gradient(135deg, #f5f5dc 0%, #fffaf0 100%);
border-radius: 16rpx;
padding: 48rpx 32rpx;
text-align: center;
.share-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0.05;
.bamboo-pattern {
width: 100%;
height: 100%;
/* background: url('/static/images/bamboo-pattern.svg') repeat; */
/* 竹子图案暂时注释,后续添加实际图片资源 */
background:
linear-gradient(45deg, rgba(45, 94, 62, 0.02) 25%, transparent 25%),
linear-gradient(-45deg, rgba(45, 94, 62, 0.02) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(45, 94, 62, 0.02) 75%),
linear-gradient(-45deg, transparent 75%, rgba(45, 94, 62, 0.02) 75%);
background-size: 30rpx 30rpx;
}
}
.share-card {
position: relative;
z-index: 1;
.share-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
.share-logo {
width: 48rpx;
height: 48rpx;
margin-right: 16rpx;
}
.share-title {
font-size: 28rpx;
font-weight: bold;
color: #2d5e3e;
}
}
.share-user {
margin-bottom: 24rpx;
.share-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-bottom: 16rpx;
border: 2rpx solid #daa520;
}
.share-name {
display: block;
font-size: 24rpx;
color: #2d5e3e;
font-weight: bold;
}
}
.share-score {
margin-bottom: 24rpx;
.share-score-num {
font-size: 80rpx;
font-weight: bold;
color: #daa520;
}
.share-score-unit {
font-size: 24rpx;
color: #2d5e3e;
}
}
.share-details {
margin-bottom: 32rpx;
.share-type {
display: block;
font-size: 20rpx;
color: #696969;
margin-bottom: 8rpx;
}
.share-accuracy {
display: block;
font-size: 20rpx;
color: #daa520;
font-weight: bold;
}
}
.qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
border: 2rpx dashed #cccccc;
border-radius: 8rpx;
margin: 0 auto;
.qr-text {
font-size: 20rpx;
color: #cccccc;
margin-top: 8rpx;
}
}
}
}
}
.share-actions {
display: flex;
gap: 24rpx;
.share-btn,
.generate-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
}
}
}
.action-section {
display: flex;
gap: 24rpx;
.action-btn {
flex: 1;
&.secondary {
background: rgba(255, 255, 255, 0.8);
color: #2d5e3e;
border-color: #2d5e3e;
}
}
}
}
</style>

View File

@@ -0,0 +1,614 @@
<route lang="json">
{
"style": {
"navigationBarTitleText": "学生答题",
"navigationBarBackgroundColor": "#2D5E3E",
"navigationBarTextStyle": "white"
}
}
</route>
<template>
<view class="quiz-container">
<!-- 答题进度和倒计时 -->
<view class="quiz-header">
<view class="progress-info">
<text class="question-number">{{ currentQuestionIndex + 1 }}/{{ totalQuestions }}</text>
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: `${progressWidth}%` }"
/>
</view>
</view>
<view class="timer-container" :class="{ warning: timeLeft <= 10 }">
<uni-icons type="clock" size="16" color="#FFFFFF" />
<text class="timer-text">{{ timeLeft }}s</text>
</view>
</view>
<!-- 题目卡片 -->
<ZhuziCard v-if="currentQuestion" class="question-card student-theme">
<view class="question-content">
<!-- 题目类型和分值 -->
<view class="question-meta">
<text class="question-type student-type">{{ currentQuestion.type === 'single' ? '单选题' : '多选题' }}</text>
<text class="question-score student-score">{{ currentQuestion.score }}</text>
</view>
<!-- 题目标题 -->
<view class="question-title">
<text>{{ currentQuestion.title }}</text>
</view>
<!-- 选项列表 -->
<view class="options-container">
<view
v-for="option in currentQuestion.options"
:key="option.id"
class="option-item student-option"
:class="{
selected: selectedAnswers.includes(option.id),
correct: showAnswer && currentQuestion.correctAnswers.includes(option.id),
incorrect: showAnswer && selectedAnswers.includes(option.id) && !currentQuestion.correctAnswers.includes(option.id),
}"
@tap="selectOption(option.id)"
>
<view class="option-content">
<view class="option-icon">
<view
v-if="currentQuestion.type === 'single'"
class="radio-icon student-radio"
:class="{ checked: selectedAnswers.includes(option.id) }"
/>
<view
v-else
class="checkbox-icon student-checkbox"
:class="{ checked: selectedAnswers.includes(option.id) }"
>
<uni-icons v-if="selectedAnswers.includes(option.id)" type="checkmarkempty" size="14" color="#FFFFFF" />
</view>
</view>
<text class="option-text">{{ option.text }}</text>
</view>
</view>
</view>
<!-- 多选题确认按钮 -->
<AncientButton
v-if="currentQuestion.type === 'multiple' && !showAnswer"
class="confirm-btn student-btn"
:disabled="selectedAnswers.length === 0"
@click="confirmAnswer"
>
确认答案
</AncientButton>
</view>
</ZhuziCard>
<!-- 答题反馈 -->
<view v-if="showFeedback" class="feedback-overlay" @tap="nextQuestion">
<view class="feedback-content student-feedback">
<uni-icons
:type="isCorrect ? 'checkmarkempty' : 'closeempty'"
:size="80"
:color="isCorrect ? '#32CD32' : '#FF4444'"
class="feedback-icon"
/>
<text class="feedback-text">{{ isCorrect ? '太棒了!答对了' : '加油!再想想' }}</text>
<text v-if="isCorrect" class="score-text student-score-text">+{{ currentQuestion?.score }}</text>
<text class="next-hint">点击任意处继续</text>
</view>
</view>
<!-- 超时提示 -->
<view v-if="showTimeout" class="timeout-overlay" @tap="nextQuestion">
<view class="timeout-content">
<uni-icons type="clock" size="80" color="#FF6B6B" />
<text class="timeout-text">时间到了</text>
<text class="timeout-hint">点击任意处继续</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { QuizQuestion } from '@/mocks/quiz'
import { computed, onUnmounted, ref } from 'vue'
import { mockStudentQuestions } from '@/mocks/quiz'
// 响应式数据
const questions = ref<QuizQuestion[]>([...mockStudentQuestions])
const currentQuestionIndex = ref(0)
const selectedAnswers = ref<string[]>([])
const showAnswer = ref(false)
const showFeedback = ref(false)
const showTimeout = ref(false)
const timeLeft = ref(30)
const totalScore = ref(0)
const correctCount = ref(0)
const timer = ref<number | null>(null)
const startTime = ref<number>(Date.now())
// 计算属性
const totalQuestions = computed(() => questions.value.length)
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value])
const progressWidth = computed(() => ((currentQuestionIndex.value + 1) / totalQuestions.value) * 100)
const isCorrect = ref(false)
// 开始倒计时
function startTimer() {
if (timer.value)
clearInterval(timer.value)
timeLeft.value = 30
timer.value = setInterval(() => {
timeLeft.value--
if (timeLeft.value <= 0) {
handleTimeout()
}
}, 1000) as unknown as number
}
// 处理超时
function handleTimeout() {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
showTimeout.value = true
setTimeout(() => {
nextQuestion()
}, 2000)
}
// 选择选项
function selectOption(optionId: string) {
if (showAnswer.value || showFeedback.value || showTimeout.value)
return
if (currentQuestion.value?.type === 'single') {
selectedAnswers.value = [optionId]
setTimeout(() => {
confirmAnswer()
}, 300)
}
else {
const index = selectedAnswers.value.indexOf(optionId)
if (index > -1) {
selectedAnswers.value.splice(index, 1)
}
else {
selectedAnswers.value.push(optionId)
}
}
}
// 确认答案
function confirmAnswer() {
if (showAnswer.value || !currentQuestion.value)
return
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
showAnswer.value = true
// 检查答案是否正确
const correctAnswers = currentQuestion.value.correctAnswers
const isAnswerCorrect = selectedAnswers.value.length === correctAnswers.length
&& selectedAnswers.value.every(answer => correctAnswers.includes(answer))
isCorrect.value = isAnswerCorrect
if (isAnswerCorrect) {
totalScore.value += currentQuestion.value.score
correctCount.value++
}
// 显示反馈
setTimeout(() => {
showAnswer.value = false
showFeedback.value = true
setTimeout(() => {
if (showFeedback.value) {
nextQuestion()
}
}, 3000)
}, 1500)
}
// 下一题
function nextQuestion() {
showFeedback.value = false
showTimeout.value = false
selectedAnswers.value = []
if (currentQuestionIndex.value < totalQuestions.value - 1) {
currentQuestionIndex.value++
startTimer()
}
else {
finishQuiz()
}
}
// 完成答题
function finishQuiz() {
const endTime = Date.now()
const timeSpent = Math.floor((endTime - startTime.value) / 1000)
const result = {
type: 'student' as const,
totalScore: totalScore.value,
correctCount: correctCount.value,
totalQuestions: totalQuestions.value,
timeSpent,
questions: questions.value,
}
uni.setStorageSync('quizResult', result)
uni.redirectTo({
url: '/pages/quiz/result',
})
}
// 监听页面返回
function handleBackPress() {
uni.showModal({
title: '确认退出',
content: '答题尚未完成,确定要退出吗?',
success: (res) => {
if (res.confirm) {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
uni.navigateBack()
}
},
})
return true
}
// 页面加载时开始答题
onLoad(() => {
questions.value = questions.value.sort(() => Math.random() - 0.5)
startTime.value = Date.now()
startTimer()
})
// 页面卸载时清理定时器
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
onHide(() => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
onShow(() => {
if (!showFeedback.value && !showTimeout.value && !showAnswer.value && timeLeft.value > 0) {
startTimer()
}
})
uni.onBackPress?.(handleBackPress)
</script>
<style lang="scss" scoped>
.quiz-container {
min-height: 100vh;
background:
url('/static/images/ancient-paper-bg.jpg') no-repeat center center,
linear-gradient(135deg, #f8f5f0 0%, #d8d0c0 100%);
background-size: cover;
background-attachment: fixed;
padding: 32rpx;
.quiz-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
.progress-info {
flex: 1;
margin-right: 32rpx;
.question-number {
font-size: 28rpx;
font-weight: bold;
color: #2d5e3e;
margin-bottom: 16rpx;
display: block;
}
.progress-bar {
height: 8rpx;
background: rgba(50, 205, 50, 0.2);
border-radius: 4rpx;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #32cd32 0%, #98fb98 100%);
transition: width 0.3s ease;
}
}
}
.timer-container {
display: flex;
align-items: center;
gap: 8rpx;
background: #32cd32;
padding: 12rpx 20rpx;
border-radius: 20rpx;
transition: all 0.3s ease;
&.warning {
background: #ff4444;
animation: pulse 1s infinite;
}
.timer-text {
font-size: 28rpx;
font-weight: bold;
color: #ffffff;
}
}
}
.question-card {
margin-bottom: 32rpx;
&.student-theme {
border-left: 6rpx solid #32cd32;
}
.question-content {
.question-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
.question-type.student-type {
background: rgba(50, 205, 50, 0.1);
color: #228b22;
font-size: 24rpx;
padding: 8rpx 16rpx;
border-radius: 16rpx;
font-weight: bold;
}
.question-score.student-score {
background: linear-gradient(135deg, #32cd32 0%, #98fb98 100%);
color: white;
font-size: 24rpx;
font-weight: bold;
padding: 8rpx 16rpx;
border-radius: 16rpx;
}
}
.question-title {
font-size: 32rpx;
font-weight: bold;
color: #2d5e3e;
line-height: 1.5;
margin-bottom: 48rpx;
}
.options-container {
.option-item.student-option {
margin-bottom: 24rpx;
.option-content {
display: flex;
align-items: center;
padding: 32rpx;
background: rgba(255, 255, 255, 0.8);
border: 2rpx solid transparent;
border-radius: 16rpx;
transition: all 0.3s ease;
cursor: pointer;
.option-icon {
margin-right: 24rpx;
.radio-icon.student-radio {
width: 40rpx;
height: 40rpx;
border: 3rpx solid #cccccc;
border-radius: 50%;
position: relative;
transition: all 0.3s ease;
&.checked {
border-color: #32cd32;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20rpx;
height: 20rpx;
background: #32cd32;
border-radius: 50%;
transform: translate(-50%, -50%);
}
}
}
.checkbox-icon.student-checkbox {
width: 40rpx;
height: 40rpx;
border: 3rpx solid #cccccc;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
&.checked {
background: #32cd32;
border-color: #32cd32;
}
}
}
.option-text {
flex: 1;
font-size: 28rpx;
color: #2f4f4f;
line-height: 1.5;
}
}
&.selected .option-content {
background: rgba(50, 205, 50, 0.1);
border-color: #32cd32;
}
&.correct .option-content {
background: rgba(50, 205, 50, 0.15);
border-color: #32cd32;
}
&.incorrect .option-content {
background: rgba(255, 68, 68, 0.1);
border-color: #ff4444;
}
}
}
.confirm-btn.student-btn {
width: 100%;
margin-top: 32rpx;
background: linear-gradient(135deg, #32cd32 0%, #98fb98 100%);
border-color: #32cd32;
}
}
}
// 反馈弹窗
.feedback-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
.feedback-content.student-feedback {
background: white;
border-radius: 24rpx;
padding: 64rpx 48rpx;
text-align: center;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.2);
animation: slideInUp 0.4s ease-out;
border-left: 8rpx solid #32cd32;
.feedback-icon {
margin-bottom: 32rpx;
}
.feedback-text {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #228b22;
margin-bottom: 16rpx;
}
.score-text.student-score-text {
display: block;
font-size: 28rpx;
color: #32cd32;
font-weight: bold;
margin-bottom: 24rpx;
}
.next-hint {
font-size: 24rpx;
color: #696969;
}
}
}
// 超时弹窗
.timeout-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 68, 68, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
.timeout-content {
text-align: center;
animation: pulse 0.6s ease-in-out;
.timeout-text {
display: block;
font-size: 40rpx;
font-weight: bold;
color: white;
margin: 32rpx 0 16rpx;
text-shadow: 2rpx 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
.timeout-hint {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
}
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes slideInUp {
from {
transform: translateY(100rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,664 @@
<route lang="json">
{
"style": {
"navigationBarTitleText": "排行榜",
"navigationBarBackgroundColor": "#2D5E3E",
"navigationBarTextStyle": "white"
}
}
</route>
<template>
<view class="ranking-container">
<!-- 用户个人排名信息 -->
<view class="user-rank-section">
<view class="user-card">
<image
:src="userRankInfo.avatar || '/static/images/default-avatar.png'"
class="user-avatar"
mode="aspectFill"
/>
<view class="user-info">
<text class="user-name">{{ userRankInfo.studentName }}</text>
<text class="user-school">{{ userRankInfo.schoolName }} {{ userRankInfo.gradeName }} {{ userRankInfo.className }}</text>
<view class="score-info">
<view class="score-item">
<text class="score-label">总积分</text>
<text class="score-value">{{ userRankInfo.totalScore }}</text>
</view>
<view class="score-item">
<text class="score-label">本月积分</text>
<text class="score-value">{{ userRankInfo.monthScore }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 排行榜类型切换 -->
<view class="tab-section">
<view class="tab-container">
<view
v-for="tab in timeTabs"
:key="tab.key"
class="tab-item"
:class="{ active: currentTimeTab === tab.key }"
@tap="switchTimeTab(tab.key)"
>
<text class="tab-text">{{ tab.name }}</text>
</view>
</view>
</view>
<!-- 排行榜范围选择 -->
<view class="scope-section">
<scroll-view class="scope-tabs" scroll-x enable-flex>
<view
v-for="scope in scopeTabs"
:key="scope.key"
class="scope-item"
:class="{ active: currentScope === scope.key }"
@tap="switchScope(scope.key)"
>
<uni-icons :type="scope.icon" size="16" :color="currentScope === scope.key ? '#FFFFFF' : '#2D5E3E'" />
<text class="scope-text">{{ scope.name }}</text>
<view v-if="getUserRankByScope(scope.key)" class="user-rank-badge">
<text class="rank-text">{{ getUserRankByScope(scope.key) }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 前三名金字塔展示 -->
<view class="podium-section">
<view class="podium-header">
<text class="podium-title">🏆 {{ getCurrentScopeTitle() }} 前三甲</text>
</view>
<view class="podium-container">
<view
v-for="item in topThreeRanking"
:key="item.rank"
class="podium-item"
:class="{
first: item.rank === 1,
second: item.rank === 2,
third: item.rank === 3,
}"
>
<view class="user-info">
<image
:src="item.avatar || '/static/images/default-avatar.png'"
class="user-avatar"
mode="aspectFill"
/>
<text class="user-name">{{ item.studentName }}</text>
<text class="user-score">{{ getCurrentScore(item) }}</text>
</view>
<view class="podium-base">
<text class="rank-number">{{ item.rank }}</text>
</view>
<text v-if="item.rank === 1" class="rank-crown">👑</text>
</view>
</view>
</view>
<!-- 完整排行榜列表 -->
<view class="ranking-list">
<view class="list-header">
<text class="header-title">完整排行榜</text>
<text class="header-subtitle">前20名</text>
</view>
<scroll-view class="list-content" scroll-y enable-flex>
<view
v-for="(item, index) in currentRankingList"
:key="item.studentName + index"
class="ranking-item"
:class="{
'top-three': item.rank <= 3,
'current-user': item.studentName === userRankInfo.studentName,
}"
>
<!-- 排名数字 -->
<view class="rank-number">
<text
class="rank-text"
:class="{
first: item.rank === 1,
second: item.rank === 2,
third: item.rank === 3,
}"
>
{{ item.rank }}
</text>
</view>
<!-- 用户头像 -->
<image
:src="item.avatar || '/static/images/default-avatar.png'"
class="item-avatar"
mode="aspectFill"
/>
<!-- 用户信息 -->
<view class="item-info">
<text class="item-name">{{ item.studentName }}</text>
<view class="item-details">
<text class="item-school">{{ item.schoolName }}</text>
<text class="item-class">{{ item.gradeName }} {{ item.className }}</text>
</view>
</view>
<!-- 积分信息 -->
<view class="item-score">
<text class="score-number">{{ getCurrentScore(item) }}</text>
<text class="score-unit"></text>
</view>
<!-- 前三名奖杯图标 -->
<view v-if="item.rank <= 3" class="trophy-icon">
<uni-icons
type="medal-filled"
size="24"
:color="getTrophyColor(item.rank)"
/>
</view>
</view>
<!-- 空状态 -->
<view v-if="currentRankingList.length === 0" class="empty-state">
<uni-icons type="list" size="60" color="#CCCCCC" />
<text class="empty-text">暂无排行数据</text>
</view>
</scroll-view>
</view>
<!-- 激励文字 -->
<view class="encouragement-section">
<ZhuziCard class="encouragement-card">
<view class="encouragement-content">
<uni-icons type="star" size="20" color="#DAA520" />
<text class="encouragement-text">{{ encouragementText }}</text>
</view>
</ZhuziCard>
</view>
</view>
</template>
<script setup lang="ts">
import type { RankingItem } from '@/mocks/ranking'
import { computed, ref } from 'vue'
import {
mockCityRanking,
mockClassRanking,
mockDistrictRanking,
mockGradeRanking,
mockSchoolRanking,
mockUserRankInfo,
} from '@/mocks/ranking'
// 响应式数据
const userRankInfo = ref(mockUserRankInfo)
const currentTimeTab = ref<'total' | 'month'>('total')
const currentScope = ref<'class' | 'grade' | 'school' | 'district' | 'city'>('class')
// 时间维度选项卡
const timeTabs = [
{ key: 'total' as const, name: '总榜' },
{ key: 'month' as const, name: '月榜' },
]
// 范围维度选项卡
const scopeTabs = [
{ key: 'class' as const, name: '班级榜', icon: 'person' },
{ key: 'grade' as const, name: '年级榜', icon: 'persons' },
{ key: 'school' as const, name: '全校榜', icon: 'home' },
{ key: 'district' as const, name: '全区榜', icon: 'location' },
{ key: 'city' as const, name: '全市榜', icon: 'map' },
]
// 当前排行榜数据
const currentRankingList = computed(() => {
const rankingMap = {
class: mockClassRanking,
grade: mockGradeRanking,
school: mockSchoolRanking,
district: mockDistrictRanking,
city: mockCityRanking,
}
return rankingMap[currentScope.value] || []
})
// 前三名数据
const topThreeRanking = computed(() => {
return currentRankingList.value.filter(item => item.rank <= 3).sort((a, b) => a.rank - b.rank)
})
// 获取当前显示的积分
function getCurrentScore(item: RankingItem) {
return currentTimeTab.value === 'total' ? item.totalScore : item.monthScore
}
// 获取当前范围标题
function getCurrentScopeTitle() {
const titleMap = {
class: '班级排行榜',
grade: '年级排行榜',
school: '全校排行榜',
district: '全区排行榜',
city: '全市排行榜',
}
return titleMap[currentScope.value]
}
// 获取用户在指定范围的排名
function getUserRankByScope(scope: string) {
const rankings = userRankInfo.value.rankings as any
return rankings[scope]?.rank
}
// 获取奖杯颜色
function getTrophyColor(rank: number) {
const colors = {
1: '#FFD700', // 金色
2: '#C0C0C0', // 银色
3: '#CD7F32', // 铜色
}
return colors[rank as keyof typeof colors] || '#CCCCCC'
}
// 激励文字
const encouragementText = computed(() => {
const userRank = getUserRankByScope(currentScope.value)
if (userRank <= 3) {
return '太棒了!您已进入前三名,继续保持!'
}
else if (userRank <= 10) {
return '表现优秀!向前三名发起冲击吧!'
}
else if (userRank <= 20) {
return '成绩不错!再接再厉进入前十名!'
}
else {
return '学而时习之,不亦说乎!坚持学习必有收获!'
}
})
// 切换时间维度
function switchTimeTab(tab: 'total' | 'month') {
currentTimeTab.value = tab
}
// 切换排行范围
function switchScope(scope: 'class' | 'grade' | 'school' | 'district' | 'city') {
currentScope.value = scope
}
// 页面加载时的数据初始化
onLoad(() => {
// 可以在这里加载用户的排名信息
console.log('排行榜页面加载完成')
})
// 下拉刷新
function onRefresh() {
uni.showLoading({
title: '刷新中...',
})
// 模拟刷新数据
setTimeout(() => {
uni.hideLoading()
uni.showToast({
title: '刷新成功',
icon: 'success',
})
}, 1500)
}
// 页面分享配置
onShareAppMessage(() => {
const userRank = getUserRankByScope(currentScope.value)
return {
title: `我在朱子文化学习排行榜中排名第${userRank}名!`,
path: '/pages/ranking/ranking',
}
})
</script>
<style lang="scss" scoped>
.ranking-container {
min-height: 100vh;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
.user-rank-section {
padding: 32rpx;
.user-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 24rpx;
padding: 32rpx;
display: flex;
align-items: center;
box-shadow: 0 8rpx 24rpx rgba(30, 58, 138, 0.1);
border-left: 6rpx solid #1e3a8a;
.user-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
margin-right: 32rpx;
border: 3rpx solid #f59e0b;
}
.user-info {
flex: 1;
.user-name {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #1e3a8a;
margin-bottom: 8rpx;
}
.user-school {
display: block;
font-size: 24rpx;
color: #64748b;
margin-bottom: 16rpx;
}
.score-info {
display: flex;
gap: 32rpx;
.score-item {
display: flex;
flex-direction: column;
align-items: center;
.score-label {
font-size: 20rpx;
color: #94a3b8;
margin-bottom: 4rpx;
}
.score-value {
font-size: 28rpx;
font-weight: bold;
color: #f59e0b;
}
}
}
}
}
}
.tab-section {
padding: 0 32rpx 16rpx;
.tab-container {
background: rgba(255, 255, 255, 0.8);
border-radius: 16rpx;
padding: 8rpx;
display: flex;
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
border-radius: 12rpx;
transition: all 0.3s ease;
&.active {
background: #1e3a8a;
box-shadow: 0 4rpx 12rpx rgba(30, 58, 138, 0.3);
.tab-text {
color: #ffffff;
font-weight: bold;
}
}
.tab-text {
font-size: 28rpx;
color: #1e3a8a;
transition: color 0.3s ease;
}
}
}
}
.scope-section {
padding: 0 32rpx 16rpx;
.scope-tabs {
white-space: nowrap;
.scope-item {
display: inline-flex;
align-items: center;
background: rgba(255, 255, 255, 0.8);
border: 2rpx solid #e6e6e6;
border-radius: 32rpx;
padding: 16rpx 24rpx;
margin-right: 16rpx;
transition: all 0.3s ease;
position: relative;
&.active {
background: #1e3a8a;
border-color: #1e3a8a;
.scope-text {
color: #ffffff;
font-weight: bold;
}
}
.scope-text {
font-size: 24rpx;
color: #1e3a8a;
margin-left: 8rpx;
transition: color 0.3s ease;
}
.user-rank-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
background: #ff6b6b;
border-radius: 12rpx;
padding: 2rpx 8rpx;
.rank-text {
font-size: 18rpx;
color: #ffffff;
font-weight: bold;
}
}
}
}
}
.ranking-list {
padding: 16rpx 32rpx 32rpx;
flex: 1;
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16rpx 24rpx;
.header-title {
font-size: 32rpx;
font-weight: bold;
color: #1e3a8a;
}
.header-subtitle {
font-size: 24rpx;
color: #64748b;
}
}
.list-content {
max-height: 800rpx;
.ranking-item {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(45, 94, 62, 0.1);
transition: all 0.3s ease;
position: relative;
&.top-three {
background: linear-gradient(135deg, rgba(30, 58, 138, 0.1) 0%, rgba(99, 102, 241, 0.1) 100%);
border: 2rpx solid #6366f1;
box-shadow: 0 4rpx 16rpx rgba(99, 102, 241, 0.2);
}
&.current-user {
border: 3rpx solid #10b981;
background: rgba(16, 185, 129, 0.1);
}
.rank-number {
width: 60rpx;
text-align: center;
margin-right: 24rpx;
.rank-text {
font-size: 32rpx;
font-weight: bold;
color: #1e3a8a;
color: #fbbf24;
}
&.second {
color: #9ca3af;
}
&.third {
color: #d97706;
}
}
}
.item-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 24rpx;
border: 2rpx solid #e6e6e6;
}
.item-info {
flex: 1;
.item-name {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #1e3a8a;
margin-bottom: 8rpx;
}
.item-details {
display: flex;
gap: 16rpx;
.item-school,
.item-class {
font-size: 22rpx;
color: #64748b;
}
}
}
.item-score {
text-align: right;
margin-right: 16rpx;
.score-number {
font-size: 32rpx;
font-weight: bold;
color: #f59e0b;
}
.score-unit {
font-size: 20rpx;
color: #64748b;
}
}
.trophy-icon {
position: absolute;
top: 16rpx;
right: 16rpx;
}
}
.empty-state {
text-align: center;
padding: 120rpx 0;
.empty-text {
display: block;
font-size: 28rpx;
color: #cccccc;
margin-top: 24rpx;
}
}
}
}
// 金字塔展示区域
.podium-section {
padding: 32rpx;
.podium-header {
text-align: center;
margin-bottom: 32rpx;
.podium-title {
font-size: 36rpx;
font-weight: bold;
color: #1e3a8a;
text-shadow: 0 2rpx 4rpx rgba(30, 58, 138, 0.1);
}
}
}
.encouragement-section {
padding: 0 32rpx 32rpx;
.encouragement-card {
.encouragement-content {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
.encouragement-text {
font-size: 26rpx;
color: #2d5e3e;
font-style: italic;
font-weight: bold;
text-align: center;
}
}
}
}
}
</style>

55
src/router/README.md Normal file
View File

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

29
src/router/config.ts Normal file
View File

@@ -0,0 +1,29 @@
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/login/login'
export const REGISTER_PAGE = '/pages/login/register'
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',
...excludeLoginPathList, // 都是以 / 开头的 path
]
// 在小程序里面是否使用H5的登录页默认为 false
// 如果为 true 则复用 h5 的登录逻辑
// TODO: 3/3 确定自己的登录页是否需要在小程序里面使用
export const LOGIN_PAGE_ENABLE_IN_MP = false

120
src/router/interceptor.ts Normal file
View File

@@ -0,0 +1,120 @@
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 } 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}`
}
// 处理直接进入路由非首页时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阻止原路由继续执行
}
}
// #endregion 2/2 默认不需要登录的情况(黑名单策略) ---------------------------
return true // 明确表示允许路由继续执行
},
}
export const routeInterceptor = {
install() {
uni.addInterceptor('navigateTo', navigateToInterceptor)
uni.addInterceptor('reLaunch', navigateToInterceptor)
uni.addInterceptor('redirectTo', navigateToInterceptor)
uni.addInterceptor('switchTab', navigateToInterceptor)
},
}

View File

@@ -0,0 +1,13 @@
/* eslint-disable */
// @ts-ignore
import * as API from './types';
export function displayStatusEnum(field: API.StatusEnum) {
return { available: 'available', pending: 'pending', sold: 'sold' }[field];
}
export function displayStatusEnum2(field: API.StatusEnum2) {
return { placed: 'placed', approved: 'approved', delivered: 'delivered' }[
field
];
}

11
src/service/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/* eslint-disable */
// @ts-ignore
export * from './types';
export * from './displayEnumLabel';
export * from './pet';
export * from './pet.vuequery';
export * from './store';
export * from './store.vuequery';
export * from './user';
export * from './user.vuequery';

185
src/service/pet.ts Normal file
View File

@@ -0,0 +1,185 @@
/* eslint-disable */
// @ts-ignore
import request from '@/http/vue-query';
import type { CustomRequestOptions } from '@/http/types';
import * as API from './types';
/** Update an existing pet PUT /pet */
export async function petUsingPut({
body,
options,
}: {
body: API.Pet;
options?: CustomRequestOptions;
}) {
return request<unknown>('/pet', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** Add a new pet to the store POST /pet */
export async function petUsingPost({
body,
options,
}: {
body: API.Pet;
options?: CustomRequestOptions;
}) {
return request<unknown>('/pet', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** Find pet by ID Returns a single pet GET /pet/${param0} */
export async function petPetIdUsingGet({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.petPetIdUsingGetParams;
options?: CustomRequestOptions;
}) {
const { petId: param0, ...queryParams } = params;
return request<API.Pet>(`/pet/${param0}`, {
method: 'GET',
params: { ...queryParams },
...(options || {}),
});
}
/** Updates a pet in the store with form data POST /pet/${param0} */
export async function petPetIdUsingPost({
params,
body,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.petPetIdUsingPostParams;
body: API.PetPetIdUsingPostBody;
options?: CustomRequestOptions;
}) {
const { petId: param0, ...queryParams } = params;
return request<unknown>(`/pet/${param0}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
params: { ...queryParams },
data: body,
...(options || {}),
});
}
/** Deletes a pet DELETE /pet/${param0} */
export async function petPetIdUsingDelete({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.petPetIdUsingDeleteParams;
options?: CustomRequestOptions;
}) {
const { petId: param0, ...queryParams } = params;
return request<unknown>(`/pet/${param0}`, {
method: 'DELETE',
params: { ...queryParams },
...(options || {}),
});
}
/** uploads an image POST /pet/${param0}/uploadImage */
export async function petPetIdUploadImageUsingPost({
params,
body,
file,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.petPetIdUploadImageUsingPostParams;
body: API.PetPetIdUploadImageUsingPostBody;
file?: File;
options?: CustomRequestOptions;
}) {
const { petId: param0, ...queryParams } = params;
const formData = new FormData();
if (file) {
formData.append('file', file);
}
Object.keys(body).forEach((ele) => {
const item = (body as { [key: string]: any })[ele];
if (item !== undefined && item !== null) {
if (typeof item === 'object' && !(item instanceof File)) {
if (item instanceof Array) {
item.forEach((f) => formData.append(ele, f || ''));
} else {
formData.append(ele, JSON.stringify(item));
}
} else {
formData.append(ele, item);
}
}
});
return request<API.ApiResponse>(`/pet/${param0}/uploadImage`, {
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data',
},
params: { ...queryParams },
data: formData,
...(options || {}),
});
}
/** Finds Pets by status Multiple status values can be provided with comma separated strings GET /pet/findByStatus */
export async function petFindByStatusUsingGet({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.petFindByStatusUsingGetParams;
options?: CustomRequestOptions;
}) {
return request<API.Pet[]>('/pet/findByStatus', {
method: 'GET',
params: {
...params,
},
...(options || {}),
});
}
/** Finds Pets by tags Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. GET /pet/findByTags */
export async function petFindByTagsUsingGet({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.petFindByTagsUsingGetParams;
options?: CustomRequestOptions;
}) {
return request<API.Pet[]>('/pet/findByTags', {
method: 'GET',
params: {
...params,
},
...(options || {}),
});
}

151
src/service/pet.vuequery.ts Normal file
View File

@@ -0,0 +1,151 @@
/* eslint-disable */
// @ts-ignore
import { queryOptions, useMutation } from '@tanstack/vue-query';
import type { DefaultError } from '@tanstack/vue-query';
import request from '@/http/vue-query';
import type { CustomRequestOptions } from '@/http/types';
import * as apis from './pet';
import * as API from './types';
/** Update an existing pet PUT /pet */
export function usePetUsingPutMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.petUsingPut,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Add a new pet to the store POST /pet */
export function usePetUsingPostMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.petUsingPost,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Find pet by ID Returns a single pet GET /pet/${param0} */
export function petPetIdUsingGetQueryOptions(options: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.petPetIdUsingGetParams;
options?: CustomRequestOptions;
}) {
return queryOptions({
queryFn: async ({ queryKey }) => {
return apis.petPetIdUsingGet(queryKey[1] as typeof options);
},
queryKey: ['petPetIdUsingGet', options],
});
}
/** Updates a pet in the store with form data POST /pet/${param0} */
export function usePetPetIdUsingPostMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.petPetIdUsingPost,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Deletes a pet DELETE /pet/${param0} */
export function usePetPetIdUsingDeleteMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.petPetIdUsingDelete,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** uploads an image POST /pet/${param0}/uploadImage */
export function usePetPetIdUploadImageUsingPostMutation(options?: {
onSuccess?: (value?: API.ApiResponse) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.petPetIdUploadImageUsingPost,
onSuccess(data: API.ApiResponse) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Finds Pets by status Multiple status values can be provided with comma separated strings GET /pet/findByStatus */
export function petFindByStatusUsingGetQueryOptions(options: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.petFindByStatusUsingGetParams;
options?: CustomRequestOptions;
}) {
return queryOptions({
queryFn: async ({ queryKey }) => {
return apis.petFindByStatusUsingGet(queryKey[1] as typeof options);
},
queryKey: ['petFindByStatusUsingGet', options],
});
}
/** Finds Pets by tags Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. GET /pet/findByTags */
export function petFindByTagsUsingGetQueryOptions(options: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.petFindByTagsUsingGetParams;
options?: CustomRequestOptions;
}) {
return queryOptions({
queryFn: async ({ queryKey }) => {
return apis.petFindByTagsUsingGet(queryKey[1] as typeof options);
},
queryKey: ['petFindByTagsUsingGet', options],
});
}

72
src/service/store.ts Normal file
View File

@@ -0,0 +1,72 @@
/* eslint-disable */
// @ts-ignore
import request from '@/http/vue-query';
import type { CustomRequestOptions } from '@/http/types';
import * as API from './types';
/** Returns pet inventories by status Returns a map of status codes to quantities GET /store/inventory */
export async function storeInventoryUsingGet({
options,
}: {
options?: CustomRequestOptions;
}) {
return request<Record<string, number>>('/store/inventory', {
method: 'GET',
...(options || {}),
});
}
/** Place an order for a pet POST /store/order */
export async function storeOrderUsingPost({
body,
options,
}: {
body: API.Order;
options?: CustomRequestOptions;
}) {
return request<API.Order>('/store/order', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** Find purchase order by ID For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions GET /store/order/${param0} */
export async function storeOrderOrderIdUsingGet({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.storeOrderOrderIdUsingGetParams;
options?: CustomRequestOptions;
}) {
const { orderId: param0, ...queryParams } = params;
return request<API.Order>(`/store/order/${param0}`, {
method: 'GET',
params: { ...queryParams },
...(options || {}),
});
}
/** Delete purchase order by ID For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors DELETE /store/order/${param0} */
export async function storeOrderOrderIdUsingDelete({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.storeOrderOrderIdUsingDeleteParams;
options?: CustomRequestOptions;
}) {
const { orderId: param0, ...queryParams } = params;
return request<unknown>(`/store/order/${param0}`, {
method: 'DELETE',
params: { ...queryParams },
...(options || {}),
});
}

View File

@@ -0,0 +1,75 @@
/* eslint-disable */
// @ts-ignore
import { queryOptions, useMutation } from '@tanstack/vue-query';
import type { DefaultError } from '@tanstack/vue-query';
import request from '@/http/vue-query';
import type { CustomRequestOptions } from '@/http/types';
import * as apis from './store';
import * as API from './types';
/** Returns pet inventories by status Returns a map of status codes to quantities GET /store/inventory */
export function storeInventoryUsingGetQueryOptions(options: {
options?: CustomRequestOptions;
}) {
return queryOptions({
queryFn: async ({ queryKey }) => {
return apis.storeInventoryUsingGet(queryKey[1] as typeof options);
},
queryKey: ['storeInventoryUsingGet', options],
});
}
/** Place an order for a pet POST /store/order */
export function useStoreOrderUsingPostMutation(options?: {
onSuccess?: (value?: API.Order) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.storeOrderUsingPost,
onSuccess(data: API.Order) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Find purchase order by ID For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions GET /store/order/${param0} */
export function storeOrderOrderIdUsingGetQueryOptions(options: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.storeOrderOrderIdUsingGetParams;
options?: CustomRequestOptions;
}) {
return queryOptions({
queryFn: async ({ queryKey }) => {
return apis.storeOrderOrderIdUsingGet(queryKey[1] as typeof options);
},
queryKey: ['storeOrderOrderIdUsingGet', options],
});
}
/** Delete purchase order by ID For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors DELETE /store/order/${param0} */
export function useStoreOrderOrderIdUsingDeleteMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.storeOrderOrderIdUsingDelete,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}

146
src/service/types.ts Normal file
View File

@@ -0,0 +1,146 @@
/* eslint-disable */
// @ts-ignore
export type ApiResponse = {
code?: number;
type?: string;
message?: string;
};
export type Category = {
id?: number;
name?: string;
};
export type Order = {
id?: number;
petId?: number;
quantity?: number;
shipDate?: string;
/** Order Status */
status?: 'placed' | 'approved' | 'delivered';
complete?: boolean;
};
export type Pet = {
id?: number;
category?: Category;
name: string;
photoUrls: string[];
tags?: Tag[];
/** pet status in the store */
status?: 'available' | 'pending' | 'sold';
};
export type petFindByStatusUsingGetParams = {
/** Status values that need to be considered for filter */
status: ('available' | 'pending' | 'sold')[];
};
export type petFindByTagsUsingGetParams = {
/** Tags to filter by */
tags: string[];
};
export type PetPetIdUploadImageUsingPostBody = {
/** Additional data to pass to server */
additionalMetadata?: string;
/** file to upload */
file?: string;
};
export type petPetIdUploadImageUsingPostParams = {
/** ID of pet to update */
petId: number;
};
export type petPetIdUsingDeleteParams = {
/** Pet id to delete */
petId: number;
};
export type petPetIdUsingGetParams = {
/** ID of pet to return */
petId: number;
};
export type PetPetIdUsingPostBody = {
/** Updated name of the pet */
name?: string;
/** Updated status of the pet */
status?: string;
};
export type petPetIdUsingPostParams = {
/** ID of pet that needs to be updated */
petId: number;
};
export enum StatusEnum {
'available' = 'available',
'pending' = 'pending',
'sold' = 'sold',
}
export type IStatusEnum = keyof typeof StatusEnum;
export enum StatusEnum2 {
'placed' = 'placed',
'approved' = 'approved',
'delivered' = 'delivered',
}
export type IStatusEnum2 = keyof typeof StatusEnum2;
export type storeOrderOrderIdUsingDeleteParams = {
/** ID of the order that needs to be deleted */
orderId: number;
};
export type storeOrderOrderIdUsingGetParams = {
/** ID of pet that needs to be fetched */
orderId: number;
};
export type Tag = {
id?: number;
name?: string;
};
export type User = {
id?: number;
username?: string;
firstName?: string;
lastName?: string;
email?: string;
password?: string;
phone?: string;
/** User Status */
userStatus?: number;
};
export type UserCreateWithArrayUsingPostBody = User[];
export type UserCreateWithListUsingPostBody = User[];
export type userLoginUsingGetParams = {
/** The user name for login */
username: string;
/** The password for login in clear text */
password: string;
};
export type userUsernameUsingDeleteParams = {
/** The name that needs to be deleted */
username: string;
};
export type userUsernameUsingGetParams = {
/** The name that needs to be fetched. Use user1 for testing. */
username: string;
};
export type userUsernameUsingPutParams = {
/** name that need to be updated */
username: string;
};

150
src/service/user.ts Normal file
View File

@@ -0,0 +1,150 @@
/* eslint-disable */
// @ts-ignore
import request from '@/http/vue-query';
import type { CustomRequestOptions } from '@/http/types';
import * as API from './types';
/** Create user This can only be done by the logged in user. 返回值: successful operation POST /user */
export async function userUsingPost({
body,
options,
}: {
body: API.User;
options?: CustomRequestOptions;
}) {
return request<unknown>('/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** Get user by user name GET /user/${param0} */
export async function userUsernameUsingGet({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.userUsernameUsingGetParams;
options?: CustomRequestOptions;
}) {
const { username: param0, ...queryParams } = params;
return request<API.User>(`/user/${param0}`, {
method: 'GET',
params: { ...queryParams },
...(options || {}),
});
}
/** Updated user This can only be done by the logged in user. PUT /user/${param0} */
export async function userUsernameUsingPut({
params,
body,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.userUsernameUsingPutParams;
body: API.User;
options?: CustomRequestOptions;
}) {
const { username: param0, ...queryParams } = params;
return request<unknown>(`/user/${param0}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
params: { ...queryParams },
data: body,
...(options || {}),
});
}
/** Delete user This can only be done by the logged in user. DELETE /user/${param0} */
export async function userUsernameUsingDelete({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.userUsernameUsingDeleteParams;
options?: CustomRequestOptions;
}) {
const { username: param0, ...queryParams } = params;
return request<unknown>(`/user/${param0}`, {
method: 'DELETE',
params: { ...queryParams },
...(options || {}),
});
}
/** Creates list of users with given input array 返回值: successful operation POST /user/createWithArray */
export async function userCreateWithArrayUsingPost({
body,
options,
}: {
body: API.UserCreateWithArrayUsingPostBody;
options?: CustomRequestOptions;
}) {
return request<unknown>('/user/createWithArray', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** Creates list of users with given input array 返回值: successful operation POST /user/createWithList */
export async function userCreateWithListUsingPost({
body,
options,
}: {
body: API.UserCreateWithListUsingPostBody;
options?: CustomRequestOptions;
}) {
return request<unknown>('/user/createWithList', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** Logs user into the system GET /auth/login */
export async function userLoginUsingGet({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.userLoginUsingGetParams;
options?: CustomRequestOptions;
}) {
return request<string>('/auth/login', {
method: 'GET',
params: {
...params,
},
...(options || {}),
});
}
/** Logs out current logged in user session 返回值: successful operation GET /user/logout */
export async function userLogoutUsingGet({
options,
}: {
options?: CustomRequestOptions;
}) {
return request<unknown>('/user/logout', {
method: 'GET',
...(options || {}),
});
}

View File

@@ -0,0 +1,149 @@
/* eslint-disable */
// @ts-ignore
import { queryOptions, useMutation } from '@tanstack/vue-query';
import type { DefaultError } from '@tanstack/vue-query';
import request from '@/http/vue-query';
import type { CustomRequestOptions } from '@/http/types';
import * as apis from './user';
import * as API from './types';
/** Create user This can only be done by the logged in user. 返回值: successful operation POST /user */
export function useUserUsingPostMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.userUsingPost,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Get user by user name GET /user/${param0} */
export function userUsernameUsingGetQueryOptions(options: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.userUsernameUsingGetParams;
options?: CustomRequestOptions;
}) {
return queryOptions({
queryFn: async ({ queryKey }) => {
return apis.userUsernameUsingGet(queryKey[1] as typeof options);
},
queryKey: ['userUsernameUsingGet', options],
});
}
/** Updated user This can only be done by the logged in user. PUT /user/${param0} */
export function useUserUsernameUsingPutMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.userUsernameUsingPut,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Delete user This can only be done by the logged in user. DELETE /user/${param0} */
export function useUserUsernameUsingDeleteMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.userUsernameUsingDelete,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Creates list of users with given input array 返回值: successful operation POST /user/createWithArray */
export function useUserCreateWithArrayUsingPostMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.userCreateWithArrayUsingPost,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Creates list of users with given input array 返回值: successful operation POST /user/createWithList */
export function useUserCreateWithListUsingPostMutation(options?: {
onSuccess?: (value?: unknown) => void;
onError?: (error?: DefaultError) => void;
}) {
const { onSuccess, onError } = options || {};
const response = useMutation({
mutationFn: apis.userCreateWithListUsingPost,
onSuccess(data: unknown) {
onSuccess?.(data);
},
onError(error) {
onError?.(error);
},
});
return response;
}
/** Logs user into the system GET /auth/login */
export function userLoginUsingGetQueryOptions(options: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.userLoginUsingGetParams;
options?: CustomRequestOptions;
}) {
return queryOptions({
queryFn: async ({ queryKey }) => {
return apis.userLoginUsingGet(queryKey[1] as typeof options);
},
queryKey: ['userLoginUsingGet', options],
});
}
/** Logs out current logged in user session 返回值: successful operation GET /user/logout */
export function userLogoutUsingGetQueryOptions(options: {
options?: CustomRequestOptions;
}) {
return queryOptions({
queryFn: async ({ queryKey }) => {
return apis.userLogoutUsingGet(queryKey[1] as typeof options);
},
queryKey: ['userLogoutUsingGet', options],
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

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