This commit is contained in:
阿梦 2026-05-26 17:12:12 +08:00
commit c1aa714e11
91 changed files with 55664 additions and 0 deletions

9
.env Normal file
View File

@ -0,0 +1,9 @@
#VITE_HOST = http://hjcy.niuniuhotpot.fr/prod-api
VITE_BASE_URL = https://ai.xuexiaole.com
# VITE_WS_URL = ws://8.138.151.141:8888/
VITE_WS_URL = ws://ec2-13-36-229-106.eu-west-3.compute.amazonaws.com:8888/
# VITE_HOST = http://192.168.2.118:8888
#VITE_WS_URL = ws://192.168.2.28:8888/

21
.gitignore vendored Normal file
View File

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

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 周艳凯
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.

130
README.md Normal file
View File

@ -0,0 +1,130 @@
# uni-app Vue3 Vite4 pinia2 TypeScript 基础框架
## 简介
- **uni-app Vue3 Vite4 pinia2 TypeScript Tailwindcss 基础框架**
- cli 创建的 Vue3/Vite 项目 与 使用 HBuilderX 导入插件 的包有差异,最好使用vscode开发,请直接访问 [开源地址](https://gitee.com/zhou-yankai/uniapp-vite-vue3-pinia-ts)
### 说明
- 框架完全基于 Vue3 `<script setup>` 写法,不支持 Vue2;
- 可用于学习与交流;
- 目前测试 H5、微信小程序,APP(Android)通过;
- 其他平台暂未测试,后续会增加;
- 如发现问题或建议可在评论区留言, 看到会及时处理;
- 如有需求亦可在评论区留言,或在此项目基础上增加;
- 代码规范 husky、prettier、eslint、lint-staged、stylelint 的作用和使用
## 特性
- **最新技术栈**:使用 Vue3/Vite4/pinia ,TypeScript 等前端前沿技术开发;
- **vue3组件库**:
[Vin UI](https://vingogo.cn/docs/guide/quickstart.html) 组件库基于Taro版NutUi 4.x版本修改而来适配了uni-app, 使用 Vue 技术栈开发小程序应用,开箱即用,拥有丰富的业务组件。
- **Tailwind CSS**:
[Tailwind CSS](https://www.tailwindcss.cn/docs/installation) 是一个功能类优先的 CSS 框架,它集成了诸如 flex, pt-4, text-center 和 rotate-90 这样的的类,它们能直接在脚本标记语言中组合起来,构建出任何设计。
- **Eslint/Prettier/stylelint**: 规范代码样式格式,统一编码;
- **husky**: 提交代码钩子
- **路由拦截**:
[uni-mini-router](https://gitee.com/fant-mini/uni-mini-router) 类似Vue Router的API和功能,在uni-app中进行路由跳转、传参、拦截等常用操作;
- **请求拦截**:
[luch-request](https://www.quanzhan.co/luch-request/handbook) 是一个基于Promise 开发的uni-app跨平台、项目级别的请求库它有更小的体积易用的api方便简单的自定义能力。
## 目录结构
```shell
├─ src
│ ├─api # 接口文件目录
│ ├─static # 静态公共文件
│ │ ├─ images # 图片
│ │ │ ├─.png
│ │ │ └─...
│ │ │
│ │ └─ ...
│ │
│ ├─components # 组件目录
│ ├─enum # 枚举
│ ├─pages # 页面
│ │ ├─ index
│ │ │ └─index.vue
│ │ └─...
│ │
│ ├─style # 样式
│ │
│ ├─state # 状态管理模式(pinia)
│ │ ├─ modules # 数据模块
│ │ │ ├─auth.ts
│ │ │ └─...
│ │ │
│ │ └─ index.ts
│ │
│ └─utils # 工具类
│ ├─ cache # 缓存相关目录
│ └─ request.ts #api请求拦截
│ └─ layout #公共的方法
├─ .env
├─ .env.development
├─ .env.production
├─ .eslintignore
├─ .eslintrc.js
├─ .gitignore
├─ .prettierignore
├─ .prettierrc.js
├─ .commitlint.config.js
├─ .stylelint.config
├─ index.html
├─ package.json
├─ README.md
├─ tailwind.config.js
├─ tsconfig.json
└─ vite.config.ts
```
## 安装使用
- 安装依赖
```bash
npm install
```
- 运行
```bash
# 其他端请查看 package.json script
npm dev:h5
```
- 打包
```bash
# 其他端请查看 package.json script
npm build:h5
```
- npm run cz提交命令 先git add. 后在使用这个命令
```bash
npm run cz
```
### 提交类型
| 提交类型 | 标题 | 描述 |
| ---------- | ------------------ | ------------------------------------------------------------------------------------- |
| `feat` | 特征 | 新功能、新特性 |
| `fix` | Bug 修复 | bug 修复 |
| `docs` | 文档 | 仅文档更改 |
| `style` | 风格 | 不影响代码含义的更改(空格、格式、缺少分号等) |
| `refactor` | 代码重构 | 重构,在不影响代码内部行为,功能下的代码修改 |
| `perf` | 性能改进 | 更改代码,以提高性能 |
| `test` | 测试 | 添加缺失的测试或纠正现有的测试 |
| `build` | 构建 | 影响构建系统或外部依赖项的更改示例范围gulp、broccoli、npm |
| `ci` | 持续集成 | 对我们的 CI 配置文件和脚本的更改示例范围Travis、Circle、BrowserStack、SauceLabs |
| `chore` | 其他文件修改 | 不修改 src 或测试文件的其他更改 |
| `revert` | 还原 | 恢复之前的提交 |
| `release` | 发布新版本 | \- |
| `workflow` | 工作流相关文件修改 | \- |
![Image text](https://gitee.com/zhou-yankai/uniapp-vite-vue3-pinia-ts/raw/master/src/static/image/ab464291d2f39f4e38178ac21ca4f58.png)

47
commitlint.config.js Normal file
View File

@ -0,0 +1,47 @@
// type 类别:用于表明本次提交做了那种类型的改动。
// - feat新增功能
// - fix缺陷修复
// - perf性能优化
// - refactor重构代码(既没有新增功能,也没有修复 bug)
// - style不影响程序逻辑的代码修改(代码风格样式等,没有改变代码逻辑)
// - docs文档更新
// - build项目构建系统(例如 glupwebpackrollup 的配置等)的提交
// - revert回滚某个更早之前的提交
// - chore不属于以上类型的其他类型
// optional scope一个可选的修改范围。用于标识此次提交主要涉及到代码中哪个模块。
// description一句话描述此次提交的主要内容做到言简意赅。
module.exports = {
ignores: [(commit) => commit.includes('init')],
extends: ['@commitlint/config-conventional'],
rules: {
'body-leading-blank': [2, 'always'],
'footer-leading-blank': [1, 'always'],
'header-max-length': [2, 'always', 108],
'subject-empty': [2, 'never'],
'type-empty': [2, 'never'],
'type-enum': [
2,
'always',
[
'feat',
'fix',
'perf',
'style',
'docs',
'test',
'refactor',
'build',
'ci',
'chore',
'revert',
'wip',
'workflow',
'types',
'release',
],
],
},
};

20
index.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<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></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

16638
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

111
package.json Normal file
View File

@ -0,0 +1,111 @@
{
"name": "uni-preset-vue",
"version": "0.0.0",
"scripts": {
"dev:app": "uni -p app",
"dev:app-android": "uni -p app-android",
"dev:app-ios": "uni -p app-ios",
"dev:custom": "uni -p",
"dev:h5": "uni --minify",
"dev:h5:ssr": "uni --ssr",
"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 --minify",
"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-android": "uni build -p app-android",
"build:app-ios": "uni build -p app-ios",
"build:custom": "uni build -p",
"build:h5": "uni build --minify",
"build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay",
"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 --minify",
"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",
"prepare": "husky install",
"lint:lint-staged": "lint-staged -c ./.husky/lintstagedrc.js",
"cz": "cz"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-3080720230703001",
"@dcloudio/uni-app-plus": "3.0.0-3080720230703001",
"@dcloudio/uni-components": "3.0.0-3080720230703001",
"@dcloudio/uni-h5": "3.0.0-3080720230703001",
"@dcloudio/uni-mp-alipay": "3.0.0-3080720230703001",
"@dcloudio/uni-mp-baidu": "3.0.0-3080720230703001",
"@dcloudio/uni-mp-jd": "3.0.0-3080720230703001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-3080720230703001",
"@dcloudio/uni-mp-lark": "3.0.0-3080720230703001",
"@dcloudio/uni-mp-qq": "3.0.0-3080720230703001",
"@dcloudio/uni-mp-toutiao": "3.0.0-3080720230703001",
"@dcloudio/uni-mp-weixin": "3.0.0-3080720230703001",
"@dcloudio/uni-quickapp-webview": "3.0.0-3080720230703001",
"@vingogo/uni-ui": "^0.7.1",
"luch-request": "^3.1.0",
"pinia": "2.0.36",
"terser": "^5.16.1",
"uni-mini-router": "^0.1.3",
"uni-read-pages-vite": "^0.0.6",
"uniapp-nutui": "^0.1.8",
"vue": "^3.2.45",
"vue-i18n": "^9.1.9"
},
"devDependencies": {
"@commitlint/cli": "^17.6.7",
"@commitlint/config-conventional": "^17.6.7",
"@dcloudio/types": "^3.3.2",
"@dcloudio/uni-automator": "3.0.0-3080720230703001",
"@dcloudio/uni-cli-shared": "3.0.0-3080720230703001",
"@dcloudio/uni-stacktracey": "3.0.0-3080720230703001",
"@dcloudio/vite-plugin-uni": "3.0.0-3080720230703001",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.59.7",
"@vue/tsconfig": "^0.1.3",
"autoprefixer": "^10.4.14",
"commitizen": "^4.3.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^8.45.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.15.1",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"postcss": "^8.4.27",
"postcss-rem-to-responsive-pixel": "^5.1.3",
"prettier": "^3.0.0",
"sass": "^1.62.1",
"stylelint": "^14.13.0",
"stylelint-config-prettier": "^9.0.5",
"stylelint-config-recommended-scss": "^7.0.0",
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-config-standard": "^29.0.0",
"stylelint-config-standard-scss": "^4.0.0",
"stylelint-order": "^6.0.3",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.5",
"vite": "4.0.4",
"vite-plugin-eslint": "^1.8.1",
"vue-tsc": "^1.0.24",
"weapp-tailwindcss": "^2.6.2"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

11671
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

14
postcss.config.js Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
'postcss-rem-to-responsive-pixel': {
// 32 意味着 1rem = 32rpx
rootValue: 32,
// 默认所有属性都转化
propList: ['*'],
// 转化的单位,可以变成 px / rpx
transformUnit: 'rpx',
},
}
}

19
src/App.vue Normal file
View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app';
onLaunch(() => {
console.log('App Launch');
});
onShow(() => {
console.log('App Show');
});
onHide(() => {
console.log('App Hide');
});
</script>
<style lang="scss">
@import './style/global.scss';
</style>

91
src/api/homework.ts Normal file
View File

@ -0,0 +1,91 @@
import { request } from '@/utils/request';
export interface PaperRecord {
id: string;
paperId: string;
paperName: string;
subjectId: string;
subjectName: string;
publisherName: string;
createTime: string;
endTime: string;
remark: string;
costTime: number;
}
/** 获取未完成作业列表 */
export const getUnfinishedHomework = () =>
request<PaperRecord[]>({
url: '/school/paper/userRecord/listExamRecord',
method: 'GET',
});
/** 开始答题 */
export const startExam = (recordId: string) =>
request<any>({
url: '/school/paper/exam',
method: 'POST',
data: { recordId },
});
/** 保存单道题目答案 */
export const saveRecordAnswer = (data: {
answer: string;
answerPic: string;
costTime: number;
itemId: string;
recordId: string;
}) =>
request({
url: '/school/paper/saveRecordAnswer',
method: 'POST',
data,
silent: true,
});
/** 提交作业 */
export const submitExam = (data: {
answerDtoList: Array<{ itemId: string; answer: string; answerPic: string }>;
costTime: number;
recordId: string;
}) =>
request({
url: '/school/paper/submit',
method: 'POST',
data,
});
/** 已完成作业列表 */
export const getListCompleteRecord = (params?: {
gradingStatus?: string;
subjectId?: string;
}) =>
request<any[]>({
url: '/school/paper/userRecord/listCompleteRecord',
method: 'GET',
params,
});
/** 作业详情(含题目) */
export const getPaperRecordDetail = (data: { recordId: string; reportFlag: number }) =>
request<any>({
url: '/school/paper/getPaperRecordDetail',
method: 'POST',
data,
});
/** 作业整体分析 */
export const getJobAnalyse = (params: { recordId: string }) =>
request<any>({
url: '/sc/job/getJobAnalyse',
method: 'GET',
params,
});
/** 知识点分析 */
export const getKnowledgeAnalyse = (params: { recordId: string }) =>
request<any[]>({
url: '/sc/job/statisticalKnowledge',
method: 'GET',
params,
});

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

@ -0,0 +1,45 @@
import { request } from '@/utils/request';
export interface AccountLoginParams {
account: string;
password: string;
/** 老师登录传 'MANAGE',学生登录默认 'PC' */
clientType?: string;
companyId?: string;
}
/** 账号密码登录 */
export const psdLogin = (data: AccountLoginParams) =>
request<string>({
url: '/login',
method: 'POST',
headers: { Authorization: '' },
data: {
clientType: 'PC',
simSerialNumber: 'web',
model: 'web',
...data,
},
});
/** 退出登录 */
export const logout = () =>
request({
url: '/logout',
method: 'GET',
silent: true,
});
/** 获取学生用户首页信息 */
export const getUserInfo = () =>
request<any>({
url: '/personal/getHomePage',
method: 'GET',
});
/** 老师登录用户 */
export const getLoginUser = () =>
request<any>({
url: '/getLoginUser',
method: 'GET',
});

181
src/api/teacher.ts Normal file
View File

@ -0,0 +1,181 @@
import { request } from '@/utils/request';
export interface TeacherUserInfo {
id?: string;
account?: string;
nickName?: string;
name?: string;
avatar?: string;
avatarUrl?: string;
schoolId?: string;
schoolName?: string;
subjectId?: string | number;
[key: string]: any;
}
export interface TeacherClassItem {
id: string;
name: string;
classId?: string;
className?: string;
[key: string]: any;
}
export interface TeacherSubjectItem {
subjectId?: string | number;
id?: string | number;
value?: string | number;
subjectName?: string;
name?: string;
label?: string;
[key: string]: any;
}
export interface TopErrorTitle {
titleId?: number | string;
titleContent?: string;
subjectId?: number;
subjectName?: string;
errorCount?: number;
errorRate?: number;
[key: string]: any;
}
export interface AiCorrectionOverview {
classId: number | string;
className: string;
aiPaperCount: number;
aiTitleCount: number;
teacherSaveTimeMinutes: number;
excellentCount: number;
excellentRate: number;
goodCount: number;
goodRate: number;
passCount: number;
passRate: number;
unsubmittedCount: number;
unsubmittedRate: number;
topErrorTitles?: TopErrorTitle[];
}
export interface ClassHomeworkSubjectStat {
subjectId: number;
subjectName: string;
homeworkCount: number;
completedCount: number;
uncompletedCount: number;
}
export interface ClassHomeworkStudentItem {
userId: number | string;
userName: string;
totalCount: number;
completedCount: number;
uncompletedCount: number;
subjectStats: ClassHomeworkSubjectStat[];
}
export interface ClassHomeworkReportData {
classId: number | string;
className: string;
subjects: { subjectId: number; subjectName: string }[];
studentList: ClassHomeworkStudentItem[];
}
export interface WeakKnowledgeItem {
knowledgeId: number | string;
knowledgeName: string;
subjectId: number | string;
subjectName: string;
errorCount: number;
titleCount: number;
[key: string]: any;
}
export interface WeakKnowledgeTop10Data {
classId: number | string;
className: string;
topWeakKnowledgeList: WeakKnowledgeItem[];
}
const formEncode = (value: string) => encodeURIComponent(value).replace(/%20/g, '+');
const appendDateRange = (query: string[], startTime?: string, endTime?: string) => {
if (startTime?.trim()) {
const value = startTime.trim();
query.push('startTime=' + formEncode(value.includes(' ') ? value : `${value} 00:00:00`));
}
if (endTime?.trim()) {
const value = endTime.trim();
query.push('endTime=' + formEncode(value.includes(' ') ? value : `${value} 23:59:59`));
}
};
const buildReportQuery = (params: {
classId: string | number;
subjectIds?: number[];
startTime?: string;
endTime?: string;
schoolId?: string;
}) => {
const query = ['classId=' + formEncode(String(params.classId))];
if (params.schoolId) query.push('schoolId=' + formEncode(String(params.schoolId)));
params.subjectIds?.forEach((id) => query.push('subjectIds=' + formEncode(String(id))));
appendDateRange(query, params.startTime, params.endTime);
return query.join('&');
};
export const getLoginUser = () =>
request<TeacherUserInfo>({
url: '/getLoginUser',
method: 'GET',
});
export const getClassListByTeacher = (params?: { gradeId?: string; gradeName?: string }) =>
request<TeacherClassItem[]>({
url: '/org/class/listByTeacher',
method: 'GET',
params: params || {},
});
export const getSubjectListByClassId = (classId: string | number) =>
request<TeacherSubjectItem[]>({
url: '/org/class/listSubjectInfoByClassId',
method: 'GET',
params: { classId },
});
export const getAiCorrectionOverview = (params: {
classId: string | number;
subjectIds?: number[];
startTime?: string;
endTime?: string;
schoolId?: string;
}) =>
request<AiCorrectionOverview>({
url: '/school/report/aiCorrectionEfficiency/overview?' + buildReportQuery(params),
method: 'GET',
});
export const getClassHomeworkReport = (params: {
classId: string | number;
subjectIds?: number[];
startTime?: string;
endTime?: string;
}) =>
request<ClassHomeworkReportData>({
url: '/school/report/classHomework/list?' + buildReportQuery(params),
method: 'GET',
});
export const getWeakKnowledgeTop10 = (params: {
classId: string | number;
subjectIds?: number[];
startTime?: string;
endTime?: string;
schoolId?: string;
}) =>
request<WeakKnowledgeTop10Data>({
url: '/school/report/weakKnowledge/top10?' + buildReportQuery(params),
method: 'GET',
});

34
src/api/upload.ts Normal file
View File

@ -0,0 +1,34 @@
import { getCache } from '@/utils/cache';
import { HOST, TENANT_ID } from '@/utils/request';
/**
* { url, ... }
*/
export const uploadFile = (filePath: string): Promise<{ url: string; [key: string]: any }> => {
const token = getCache('token');
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${HOST}/api/main/sysFileInfo/tenUploadAll`,
filePath,
name: 'file',
header: {
Authorization: token ? `Bearer ${token}` : '',
'x-tenant-id': TENANT_ID,
},
success: (res) => {
try {
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
if (data.code === 200) {
resolve(data.data);
} else {
reject(new Error(data.message || '上传失败'));
}
} catch (e) {
reject(new Error('上传返回格式异常'));
}
},
fail: (err) => reject(err),
});
});
};

View File

@ -0,0 +1,105 @@
<template>
<view class="empty">
<view class="empty-illu" :class="`empty-illu-${type}`">
<!-- Loading -->
<view v-if="type === 'loading'" class="empty-loader">
<view class="dot dot-1"></view>
<view class="dot dot-2"></view>
<view class="dot dot-3"></view>
</view>
<!-- Network -->
<svg v-else-if="type === 'network'" width="80" height="80" viewBox="0 0 24 24" fill="none">
<path d="M5 13a10 10 0 0 1 14 0M8.5 16.5a5 5 0 0 1 7 0M12 20h0M2 8.82a15 15 0 0 1 20 0"
stroke="#A78BFA" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<line x1="3" y1="3" x2="21" y2="21" stroke="#EF4444" stroke-width="1.6" stroke-linecap="round" />
</svg>
<!-- Task / 默认空盒子 -->
<svg v-else-if="type === 'task'" width="80" height="80" viewBox="0 0 24 24" fill="none">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
stroke="#A78BFA" stroke-width="1.6" stroke-linejoin="round" />
<path d="M14 2v6h6" stroke="#A78BFA" stroke-width="1.6" stroke-linejoin="round" />
<path d="M9 13h6M9 17h4" stroke="#A78BFA" stroke-width="1.6" stroke-linecap="round" />
</svg>
<!-- Learn -->
<svg v-else width="80" height="80" viewBox="0 0 24 24" fill="none">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"
stroke="#A78BFA" stroke-width="1.6" stroke-linejoin="round" />
</svg>
</view>
<view class="empty-text">{{ content }}</view>
</view>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
type?: 'loading' | 'network' | 'task' | 'learn';
content?: string;
}>(),
{
type: 'task',
content: '暂无数据',
},
);
</script>
<style lang="scss" scoped>
.empty {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40rpx;
gap: 24rpx;
}
.empty-illu {
width: 200rpx;
height: 200rpx;
border-radius: 60rpx;
background: linear-gradient(180deg, #ffffff 0%, #eef2ff 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 16rpx 40rpx rgba(99, 102, 241, 0.18),
inset 0 -6rpx 0 rgba(199, 210, 254, 0.6),
inset 0 4rpx 0 rgba(255, 255, 255, 0.9);
svg {
width: 90rpx;
height: 90rpx;
}
}
.empty-text {
font-size: 28rpx;
color: #6366f1;
font-weight: 500;
}
/* loading dots */
.empty-loader {
display: flex;
gap: 16rpx;
.dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background: #818cf8;
animation: bounce 1.4s infinite ease-in-out both;
&.dot-1 { animation-delay: -0.32s; background: #4f46e5; }
&.dot-2 { animation-delay: -0.16s; background: #818cf8; }
&.dot-3 { background: #c7d2fe; }
}
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.6; }
40% { transform: scale(1); opacity: 1; }
}
</style>

View File

@ -0,0 +1,600 @@
<template>
<view :class="['cropper-wrapper', { show: visible }]">
<!-- 显示层 image 展示图片裁剪框覆盖其上避免真机上 canvas 把裁剪框盖住 -->
<view
class="display-layer"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<image
v-if="imageSrc"
:src="imageSrc"
class="display-image"
mode="aspectFit"
></image>
</view>
<!-- 工作 canvas放到屏幕外只用于真正的绘制和导出 -->
<canvas
id="imageCropperCanvas"
canvas-id="imageCropperCanvas"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
class="work-canvas"
></canvas>
<!-- 用于导出裁剪结果的离屏 canvas必须有真实节点 -->
<canvas
id="tempCropCanvas"
canvas-id="tempCropCanvas"
:style="{ width: exportSize.width + 'px', height: exportSize.height + 'px' }"
class="temp-canvas"
></canvas>
<view
class="crop-frame"
:style="{
left: cropFrame.x + 'px',
top: cropFrame.y + 'px',
width: cropFrame.width + 'px',
height: cropFrame.height + 'px',
}"
@touchstart.stop.prevent="onFrameTouchStart"
@touchmove.stop.prevent="onFrameTouchMove"
@touchend.stop="onFrameTouchEnd"
>
<view class="resize-handle top-left" data-direction="tl"></view>
<view class="resize-handle top-right" data-direction="tr"></view>
<view class="resize-handle bottom-left" data-direction="bl"></view>
<view class="resize-handle bottom-right" data-direction="br"></view>
</view>
<view class="crop-controls" :style="{ bottom: 8 + safeAreaBottom + 'px' }">
<button @tap="cancelCrop">取消</button>
<button :disabled="isProcessing" @tap="confirmCrop">
{{ isProcessing ? '处理中...' : '确定' }}
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, nextTick, getCurrentInstance } from 'vue';
const props = withDefaults(
defineProps<{
visible: boolean;
imageSrc: string;
outputWidth?: number; //
outputHeight?: number; //
}>(),
{
outputWidth: 750, //
outputHeight: 750, //
}
);
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'confirm', filePath: string): void;
(e: 'cancel'): void;
}>();
const ctx = ref<UniApp.CanvasContext | null>(null);
const imgInfo = ref<UniApp.GetImageInfoSuccessData | null>(null);
const canvasWidth = ref(0);
const canvasHeight = ref(0);
const imageDisplay = ref({ x: 0, y: 0, width: 0, height: 0 }); // canvas
const controlsReservePx = 64; //
const cropFrame = ref({ x: 0, y: 0, width: 0, height: 0 }); //
const isDraggingImage = ref(false);
const isResizingFrame = ref(false);
const isMovingFrame = ref(false);
const startX = ref(0);
const startY = ref(0);
const lastFrame = ref({ x: 0, y: 0, width: 0, height: 0 }); //
const resizeDirection = ref('');
const isProcessing = ref(false); //
const safeAreaTop = ref(0);
const safeAreaBottom = ref(0);
const componentProxy = getCurrentInstance()?.proxy as any;
const exportSize = ref({ width: 0, height: 0 });
const rpxToPx = (rpx: number) => {
// rpx 750rpx = windowWidth(px)
if (!canvasWidth.value) return Math.max(1, Math.round(rpx));
return Math.max(1, Math.round((canvasWidth.value / 750) * rpx));
};
const getTouchPoint = (e: any) => {
const t = e?.touches?.[0] || e?.changedTouches?.[0] || {};
const x = t.x ?? t.clientX ?? t.pageX ?? 0;
const y = t.y ?? t.clientY ?? t.pageY ?? 0;
return { x, y };
};
const tryInitAndLoad = () => {
if (!props.visible) return;
if (!props.imageSrc) return;
if (!ctx.value) return;
if (!canvasWidth.value || !canvasHeight.value) return;
nextTick(() => {
exportSize.value = {
width: rpxToPx(props.outputWidth),
height: rpxToPx(props.outputHeight),
};
loadImage();
});
};
onMounted(() => {
// canvas
uni.getSystemInfo({
success: (res) => {
canvasWidth.value = res.windowWidth;
safeAreaTop.value = res.safeArea?.top || 0;
safeAreaBottom.value =
(res.screenHeight && res.safeArea ? res.screenHeight - res.safeArea.bottom : 0) || 0;
// +
const bottomReserve = controlsReservePx + safeAreaBottom.value;
canvasHeight.value = Math.max(1, res.windowHeight - bottomReserve);
// canvas /
ctx.value = uni.createCanvasContext('imageCropperCanvas', componentProxy);
tryInitAndLoad();
},
});
});
watch(
[() => props.visible, () => props.imageSrc, canvasWidth, canvasHeight, ctx],
() => {
if (!props.visible) {
imgInfo.value = null;
return;
}
tryInitAndLoad();
},
{ immediate: true }
);
const loadImage = () => {
uni.showLoading({ title: '加载中...' });
uni.getImageInfo({
src: props.imageSrc,
success: (res) => {
imgInfo.value = res;
drawImage();
uni.hideLoading();
},
fail: (err) => {
uni.hideLoading();
uni.showToast({ title: '图片加载失败', icon: 'none' });
console.error('图片加载失败', err);
cancelCrop();
},
});
};
const drawImage = () => {
if (!ctx.value || !imgInfo.value) return;
const { path, width: originalWidth, height: originalHeight } = imgInfo.value;
// Canvas使Canvas
const canvasRatio = canvasWidth.value / canvasHeight.value;
const imageRatio = originalWidth / originalHeight;
let displayWidth = 0;
let displayHeight = 0;
if (imageRatio > canvasRatio) {
displayWidth = canvasWidth.value;
displayHeight = canvasWidth.value / imageRatio;
} else {
displayHeight = canvasHeight.value;
displayWidth = canvasHeight.value * imageRatio;
}
imageDisplay.value = {
x: (canvasWidth.value - displayWidth) / 2,
y: (canvasHeight.value - displayHeight) / 2,
width: displayWidth,
height: displayHeight,
};
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
ctx.value.drawImage(
path,
imageDisplay.value.x,
imageDisplay.value.y,
imageDisplay.value.width,
imageDisplay.value.height
);
//
cropFrame.value = {
x: imageDisplay.value.x,
y: imageDisplay.value.y,
width: imageDisplay.value.width,
height: imageDisplay.value.height,
};
ctx.value.draw();
};
const onTouchStart = (e: any) => {
if (!imgInfo.value) return;
const { x, y } = getTouchPoint(e);
startX.value = x;
startY.value = y;
//
if (
x > imageDisplay.value.x &&
x < imageDisplay.value.x + imageDisplay.value.width &&
y > imageDisplay.value.y &&
y < imageDisplay.value.y + imageDisplay.value.height
) {
isDraggingImage.value = true;
//
lastFrame.value = { ...imageDisplay.value };
}
};
const onTouchMove = (e: any) => {
if (!isDraggingImage.value || !imgInfo.value || !ctx.value) return;
const { x, y } = getTouchPoint(e);
const deltaX = x - startX.value;
const deltaY = y - startY.value;
let newX = lastFrame.value.x + deltaX;
let newY = lastFrame.value.y + deltaY;
// canvas
if (newX > canvasWidth.value - imageDisplay.value.x) {
newX = canvasWidth.value - imageDisplay.value.x;
}
if (newY > canvasHeight.value - imageDisplay.value.y) {
newY = canvasHeight.value - imageDisplay.value.y;
}
if (newX < -imageDisplay.value.width + imageDisplay.value.x) {
newX = -imageDisplay.value.width + imageDisplay.value.x;
}
if (newY < -imageDisplay.value.height + imageDisplay.value.y) {
newY = -imageDisplay.value.height + imageDisplay.value.y;
}
imageDisplay.value.x = newX;
imageDisplay.value.y = newY;
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
ctx.value.drawImage(
imgInfo.value.path,
imageDisplay.value.x,
imageDisplay.value.y,
imageDisplay.value.width,
imageDisplay.value.height
);
ctx.value.draw(true); // true
};
const onTouchEnd = () => {
isDraggingImage.value = false;
};
// eslint-disable-next-line complexity
const onFrameTouchMove = (e: any) => {
if (!imgInfo.value) return;
const { x, y } = getTouchPoint(e);
const deltaX = x - startX.value;
const deltaY = y - startY.value;
if (isMovingFrame.value) {
//
let newX = lastFrame.value.x + deltaX;
let newY = lastFrame.value.y + deltaY;
//
if (newX < 0) newX = 0;
if (newY < 0) newY = 0;
if (newX + cropFrame.value.width > canvasWidth.value)
newX = canvasWidth.value - cropFrame.value.width;
if (newY + cropFrame.value.height > canvasHeight.value)
newY = canvasHeight.value - cropFrame.value.height;
cropFrame.value.x = newX;
cropFrame.value.y = newY;
} else if (isResizingFrame.value) {
//
let newX = lastFrame.value.x;
let newY = lastFrame.value.y;
let newWidth = lastFrame.value.width;
let newHeight = lastFrame.value.height;
const minSize = 50; //
switch (resizeDirection.value) {
case 'tl': // Top-left
newX = Math.min(x, lastFrame.value.x + lastFrame.value.width - minSize);
newY = Math.min(y, lastFrame.value.y + lastFrame.value.height - minSize);
newWidth = lastFrame.value.x + lastFrame.value.width - newX;
newHeight = lastFrame.value.y + lastFrame.value.height - newY;
break;
case 'tr': // Top-right
newX = lastFrame.value.x;
newY = Math.min(startY.value, lastFrame.value.y + lastFrame.value.height - minSize, y);
newWidth = Math.max(minSize, lastFrame.value.width + deltaX);
newHeight = lastFrame.value.y + lastFrame.value.height - newY;
break;
case 'bl': // Bottom-left
newX = Math.min(startX.value, lastFrame.value.x + lastFrame.value.width - minSize, x);
newY = lastFrame.value.y;
newWidth = lastFrame.value.x + lastFrame.value.width - newX;
newHeight = Math.max(minSize, lastFrame.value.height + deltaY);
break;
case 'br': // Bottom-right
newX = lastFrame.value.x;
newY = lastFrame.value.y;
newWidth = Math.max(minSize, lastFrame.value.width + deltaX);
newHeight = Math.max(minSize, lastFrame.value.height + deltaY);
break;
}
// canvas
if (newX < 0) newX = 0;
if (newY < 0) newY = 0;
if (newX + newWidth > canvasWidth.value) newWidth = canvasWidth.value - newX;
if (newY + newHeight > canvasHeight.value) newHeight = canvasHeight.value - newY;
cropFrame.value = { x: newX, y: newY, width: newWidth, height: newHeight };
}
};
const onFrameTouchEnd = () => {
isMovingFrame.value = false;
isResizingFrame.value = false;
resizeDirection.value = '';
};
// canvastouchmove
//
const onFrameTouchStart = (e: any) => {
const { x, y } = getTouchPoint(e);
startX.value = x;
startY.value = y;
lastFrame.value = { ...cropFrame.value }; //
const targetDataset = e.target.dataset;
if (targetDataset && targetDataset.direction) {
isResizingFrame.value = true;
resizeDirection.value = targetDataset.direction;
} else {
isMovingFrame.value = true;
}
};
const confirmCrop = () => {
if (!ctx.value || !imgInfo.value || isProcessing.value) return;
isProcessing.value = true;
uni.showLoading({ title: '裁剪中...', mask: true });
const { path: imagePath, width: originalWidth, height: originalHeight } = imgInfo.value;
//
//
const scaleX = originalWidth / imageDisplay.value.width;
const scaleY = originalHeight / imageDisplay.value.height;
const sourceX = (cropFrame.value.x - imageDisplay.value.x) * scaleX;
const sourceY = (cropFrame.value.y - imageDisplay.value.y) * scaleY;
const sourceWidth = cropFrame.value.width * scaleX;
const sourceHeight = cropFrame.value.height * scaleY;
// canvas
const tempCanvasId = 'tempCropCanvas';
const tempCtx = uni.createCanvasContext(tempCanvasId, componentProxy);
//
const outputWidthPx = rpxToPx(props.outputWidth);
const outputHeightPx = rpxToPx(props.outputHeight);
exportSize.value = { width: outputWidthPx, height: outputHeightPx };
// canvas
tempCtx.clearRect(0, 0, outputWidthPx, outputHeightPx);
tempCtx.drawImage(
imagePath,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
outputWidthPx,
outputHeightPx
);
//
tempCtx.draw(false, () => {
let done = false;
const finish = (ok: boolean, payload?: any) => {
if (done) return;
done = true;
isProcessing.value = false;
uni.hideLoading();
if (!ok) return;
emit('confirm', payload);
emit('update:visible', false);
};
// canvasToTempFilePath
const timer = setTimeout(() => {
uni.showToast({ title: '裁剪超时,请重试', icon: 'none' });
finish(false);
}, 4000);
// canvas
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: tempCanvasId,
x: 0,
y: 0,
width: outputWidthPx,
height: outputHeightPx,
destWidth: outputWidthPx,
destHeight: outputHeightPx,
success: (res) => {
clearTimeout(timer);
finish(true, res.tempFilePath);
},
fail: (err) => {
clearTimeout(timer);
uni.showToast({ title: '裁剪失败', icon: 'none' });
console.error('canvasToTempFilePath failed', err);
finish(false);
},
}, componentProxy);
}, 100); //
});
};
const cancelCrop = () => {
emit('cancel');
emit('update:visible', false);
};
</script>
<style lang="scss" scoped>
.cropper-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background-color: #000;
display: none;
pointer-events: none;
overflow: hidden; //
&.show {
display: block;
pointer-events: auto;
}
}
.display-layer {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 1;
overflow: hidden;
}
.display-image {
width: 100%;
height: 100%;
}
.work-canvas {
position: fixed;
left: -99999px;
top: -99999px;
width: 1px;
height: 1px;
opacity: 0;
}
.temp-canvas {
position: fixed;
left: -99999px;
top: -99999px;
width: 1px;
height: 1px;
opacity: 0;
}
.crop-frame {
position: absolute;
border: 2px solid #00c0ff;
box-sizing: border-box;
cursor: grab;
touch-action: none; //
z-index: 2;
}
.resize-handle {
position: absolute;
width: 32px;
height: 32px;
background-color: #00c0ff;
border: 2px solid #fff;
box-sizing: border-box;
opacity: 0.9;
border-radius: 4px;
}
.top-left {
top: -16px;
left: -16px;
cursor: nwse-resize;
}
.top-right {
top: -16px;
right: -16px;
cursor: nesw-resize;
}
.bottom-left {
bottom: -16px;
left: -16px;
cursor: nesw-resize;
}
.bottom-right {
bottom: -16px;
right: -16px;
cursor: nwse-resize;
}
.crop-controls {
position: absolute;
// left: 12px;
right: 12px;
z-index: 3;
display: flex;
justify-content: space-between;
gap: 15px;
padding: 6px 10px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.25);
border-radius: 12px;
backdrop-filter: blur(4px);
}
.crop-controls button {
min-width: 30px;
padding: 5px 10px;
border: none;
border-radius: 10px;
background: #007aff;
color: #fff;
font-size: 14px;
}
.crop-controls button:first-child {
background: #3c3c3c;
}
.crop-controls button[disabled] {
opacity: 0.6;
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<view class="loading">
<view class="loading-clay">
<view class="loading-orbit">
<view class="loading-dot loading-dot-1"></view>
<view class="loading-dot loading-dot-2"></view>
<view class="loading-dot loading-dot-3"></view>
</view>
</view>
<text class="loading-text">{{ text }}</text>
</view>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{ text?: string }>(),
{ text: '加载中…' },
);
</script>
<style lang="scss" scoped>
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24rpx;
}
.loading-clay {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background: linear-gradient(180deg, #ffffff 0%, #eef2ff 100%);
box-shadow:
0 12rpx 32rpx rgba(99, 102, 241, 0.18),
inset 0 -4rpx 0 rgba(199, 210, 254, 0.6),
inset 0 4rpx 0 rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
}
.loading-orbit {
width: 80rpx;
height: 80rpx;
position: relative;
animation: orbit-spin 1.6s linear infinite;
}
.loading-dot {
position: absolute;
width: 18rpx;
height: 18rpx;
border-radius: 50%;
&.loading-dot-1 {
background: #4f46e5;
top: 0;
left: 50%;
margin-left: -9rpx;
}
&.loading-dot-2 {
background: #818cf8;
bottom: 0;
left: 14rpx;
}
&.loading-dot-3 {
background: #22c55e;
bottom: 0;
right: 14rpx;
}
}
.loading-text {
font-size: 26rpx;
color: #4f46e5;
font-weight: 500;
letter-spacing: 1rpx;
}
@keyframes orbit-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,162 @@
<template>
<view class="navbar" :style="navbarStyle">
<view class="navbar-status" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="navbar-bar" :style="{ height: navHeight + 'px' }">
<view v-if="showBack" class="navbar-back" @click="onBack">
<view class="navbar-back-arrow"></view>
</view>
<view class="navbar-title">
<text>{{ title }}</text>
</view>
<slot name="right">
<view class="navbar-right" :style="{ width: menuButtonWidthPx + 'px' }"></view>
</slot>
</view>
</view>
</template>
<script setup lang="ts">
/**
* 通用导航栏竖屏专用
* - 自动适配状态栏与小程序胶囊按钮高度
* - showBack 控制返回按钮显示
*/
import { computed, onMounted, ref } from 'vue';
interface Props {
title?: string;
showBack?: boolean;
bg?: string;
color?: string;
transparent?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: '',
showBack: true,
bg: '',
color: '',
transparent: false,
});
const emit = defineEmits<{
(e: 'back'): void;
}>();
const statusBarHeight = ref(20);
const navHeight = ref(44);
const menuButtonWidthPx = ref(0);
onMounted(() => {
try {
const sys = uni.getSystemInfoSync();
statusBarHeight.value = sys.statusBarHeight || 20;
// #ifdef MP-WEIXIN
const menu = uni.getMenuButtonBoundingClientRect();
if (menu) {
navHeight.value = (menu.top - statusBarHeight.value) * 2 + menu.height;
menuButtonWidthPx.value = sys.windowWidth - menu.left + 8;
}
// #endif
} catch (e) {
console.warn('NavBar getSystemInfo error', e);
}
});
const navbarStyle = computed(() => {
const bg = props.transparent
? 'transparent'
: props.bg || 'rgba(255,255,255,0.92)';
return {
background: bg,
color: props.color || '#312E81',
backdropFilter: props.transparent ? 'none' : 'blur(12px)',
} as any;
});
const onBack = () => {
emit('back');
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
} else {
uni.reLaunch({ url: '/pages/index/index' });
}
};
</script>
<style lang="scss" scoped>
.navbar {
position: sticky;
top: 0;
left: 0;
right: 0;
z-index: 99;
width: 100%;
box-sizing: border-box;
}
.navbar-bar {
position: relative;
display: flex;
align-items: center;
padding: 0 24rpx;
}
.navbar-back {
position: absolute;
left: 24rpx;
top: 50%;
transform: translateY(-50%);
width: 64rpx;
height: 64rpx;
border-radius: 22rpx;
background: #ffffff;
box-shadow:
0 6rpx 16rpx rgba(99, 102, 241, 0.18),
inset 0 -4rpx 0 rgba(199, 210, 254, 0.6);
display: flex;
align-items: center;
justify-content: center;
transition: transform 150ms ease, box-shadow 150ms ease;
&:active {
transform: translateY(-50%) scale(0.92);
box-shadow: 0 2rpx 8rpx rgba(99, 102, 241, 0.18);
}
}
.navbar-back-arrow {
width: 18rpx;
height: 18rpx;
border-left: 5rpx solid #4f46e5;
border-bottom: 5rpx solid #4f46e5;
border-radius: 2rpx;
transform: rotate(45deg);
margin-left: 6rpx;
}
.navbar-title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 700;
color: inherit;
letter-spacing: 1rpx;
text {
max-width: 60vw;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.navbar-right {
height: 1rpx;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,66 @@
<template>
<view class="progress" :style="{ width: width + 'rpx' }">
<view class="progress-track" :style="{ background: bg, borderColor: border }">
<view
class="progress-fill"
:style="{
width: percent + '%',
background: activeBg,
}"
></view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
rate: number; // 0~1
width?: number | string;
bg?: string;
border?: string;
activeBg?: string;
color?: string;
}>(),
{
rate: 0,
width: 200,
bg: '#E0E7FF',
border: '#C7D2FE',
activeBg: 'linear-gradient(90deg, #818CF8 0%, #4F46E5 100%)',
color: '#4F46E5',
},
);
const percent = computed(() => {
const r = Number(props.rate);
if (Number.isNaN(r)) return 0;
return Math.max(0, Math.min(100, Math.round(r * 100)));
});
</script>
<style lang="scss" scoped>
.progress {
display: inline-block;
vertical-align: middle;
}
.progress-track {
position: relative;
width: 100%;
height: 16rpx;
border-radius: 999rpx;
overflow: hidden;
border: 2rpx solid;
box-shadow: inset 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
}
.progress-fill {
height: 100%;
border-radius: 999rpx;
transition: width 400ms ease-out;
box-shadow: inset 0 -2rpx 0 rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -0,0 +1,147 @@
<template>
<view class="select" @click.stop="toggle">
<view class="select-current">
<text>{{ currentLabel }}</text>
<view class="select-arrow" :class="{ open: visible }"></view>
</view>
<view v-if="visible" class="select-mask" @click.stop="visible = false"></view>
<view v-if="visible" class="select-panel">
<view
v-for="item in selectList"
:key="String(item.value)"
:class="['select-option', { active: modelValue === item.value }]"
@click.stop="onPick(item)"
>
<text>{{ item.label }}</text>
<view v-if="modelValue === item.value" class="select-check"></view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
/**
* 下拉选择器Claymorphism
* 与原 qy-Select API 一致v-model + selectList + @selectChange
*/
import { computed, ref } from 'vue';
interface Option {
label: string;
value: string | number;
}
const props = defineProps<{
modelValue: string | number;
selectList: Option[];
placeholder?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: string | number): void;
(e: 'selectChange', item: Option): void;
}>();
const visible = ref(false);
const currentLabel = computed(() => {
const found = props.selectList.find((i) => i.value === props.modelValue);
return found?.label || props.placeholder || '请选择';
});
const toggle = () => {
visible.value = !visible.value;
};
const onPick = (item: Option) => {
visible.value = false;
if (props.modelValue === item.value) return;
emit('update:modelValue', item.value);
emit('selectChange', item);
};
</script>
<style lang="scss" scoped>
.select {
position: relative;
display: inline-block;
}
.select-current {
height: 64rpx;
padding: 0 24rpx;
background: #ffffff;
border-radius: 999rpx;
display: flex;
align-items: center;
gap: 12rpx;
font-size: 26rpx;
color: #4f46e5;
font-weight: 500;
box-shadow:
0 6rpx 14rpx rgba(99, 102, 241, 0.12),
inset 0 -3rpx 0 rgba(199, 210, 254, 0.6);
}
.select-arrow {
width: 12rpx;
height: 12rpx;
border-right: 4rpx solid #4f46e5;
border-bottom: 4rpx solid #4f46e5;
border-radius: 2rpx;
transform: rotate(45deg);
transition: transform 200ms ease;
margin-top: -6rpx;
&.open {
transform: rotate(-135deg);
margin-top: 6rpx;
}
}
.select-mask {
position: fixed;
inset: 0;
background: transparent;
z-index: 100;
}
.select-panel {
position: absolute;
top: calc(100% + 12rpx);
right: 0;
z-index: 101;
min-width: 220rpx;
padding: 12rpx;
background: #ffffff;
border-radius: 24rpx;
box-shadow:
0 16rpx 40rpx rgba(99, 102, 241, 0.18),
inset 0 -4rpx 0 rgba(199, 210, 254, 0.4);
}
.select-option {
height: 72rpx;
padding: 0 20rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
color: #312e81;
&.active {
background: #eef2ff;
color: #4f46e5;
font-weight: 600;
}
}
.select-check {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: #22c55e;
}
</style>

View File

@ -0,0 +1,103 @@
<template>
<view class="sidebar">
<view
v-for="item in config"
:key="item.id"
:class="['sidebar-item', { active: modelValue === item.id }]"
@click="onSelect(item)"
>
<view class="sidebar-dot" v-if="item.warning && modelValue !== item.id"></view>
<text>{{ item.name }}</text>
</view>
</view>
</template>
<script setup lang="ts">
/**
* 学科 / 分组 选择侧边栏
* - v-model 绑定当前激活项 id
* - config: [{ id, name, warning? }]
*
* 与原 qy-Sidebar API 保持一致
*/
interface Item {
id: string | number;
name: string;
warning?: boolean;
[key: string]: any;
}
const props = defineProps<{
modelValue: string | number;
config: Item[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', val: string | number): void;
(e: 'handleChange', item: Item): void;
}>();
const onSelect = (item: Item) => {
if (props.modelValue === item.id) return;
emit('update:modelValue', item.id);
emit('handleChange', item);
};
</script>
<style lang="scss" scoped>
.sidebar {
width: 100%;
height: 100%;
padding: 16rpx 12rpx;
box-sizing: border-box;
background: #ffffff;
border-radius: 32rpx;
box-shadow:
0 8rpx 24rpx rgba(99, 102, 241, 0.1),
inset 0 -4rpx 0 rgba(199, 210, 254, 0.4);
display: flex;
flex-direction: column;
gap: 12rpx;
overflow-y: auto;
}
.sidebar-item {
position: relative;
flex-shrink: 0;
height: 88rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7ff;
color: #6366f1;
font-size: 26rpx;
font-weight: 500;
transition: all 200ms ease;
&:active {
transform: scale(0.96);
}
&.active {
background: linear-gradient(180deg, #818cf8 0%, #4f46e5 100%);
color: #ffffff;
box-shadow:
0 6rpx 16rpx rgba(79, 70, 229, 0.35),
inset 0 -4rpx 0 rgba(0, 0, 0, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.3);
}
}
.sidebar-dot {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: #ef4444;
box-shadow: 0 0 0 4rpx rgba(239, 68, 68, 0.18);
}
</style>

12
src/enum/http.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* @author zhouyankai
* @description http的枚举类
* @Date: 2022-07-31
*/
export const filterRouter = ['/login'];
export enum sex {
'男' = 0,
'女',
'未知',
}

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

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue';
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>;
export default component;
}

15
src/main.ts Normal file
View File

@ -0,0 +1,15 @@
import { createSSRApp } from 'vue';
import '@vingogo/uni-ui/lib/style.css';
import App from './App.vue';
import pinia from './state/index';
import router from './router';
import './style/index.css';
export function createApp() {
const app = createSSRApp(App);
app.use(pinia);
app.use(router);
return {
app,
};
}

94
src/manifest.json Normal file
View File

@ -0,0 +1,94 @@
{
"name" : "灵犀学",
"appid" : "__UNI__F5571DB",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"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" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wxa5522671f30c5891",
"setting" : {
"urlCheck" : false,
"minified" : true,
"ignoreUploadUnusedFiles" : false
},
"usingComponents" : true,
"packOptions" : {
"ignore" : [],
"include" : [
{
"type" : "folder",
"value" : "uni_modules/qiun-data-charts"
}
]
}
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3",
"h5" : {
"router" : {
"mode" : "hash",
"base" : "./"
},
"optimization" : {
"treeShaking" : {
"enable" : true
}
}
}
}

268
src/pages.json Normal file
View File

@ -0,0 +1,268 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^vin-(.*)": "@vingogo/uni-ui/lib/components/$1/index.vue",
"^xy-(.*)": "@/components/$1/index.vue"
}
},
"pages": [
{
"path": "pages/index/index",
"name": "home",
"meta": {
"islogin": false
},
"style": {
"navigationBarTitleText": "学小乐AI",
"enablePullDownRefresh": false,
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "pages/login/index",
"name": "login",
"meta": {
"islogin": false
},
"style": {
"navigationBarTitleText": "登录",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "pages/homework/index",
"name": "homework",
"meta": {
"islogin": true
},
"style": {
"navigationBarTitleText": "我的作业",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "pages/homework/history/index",
"name": "homework-history",
"meta": {
"islogin": true
},
"style": {
"navigationBarTitleText": "作业记录",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "pages/homework/reports/index",
"name": "homework-reports",
"meta": {
"islogin": true
},
"style": {
"navigationBarTitleText": "作业报告",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "pages/wrong/index",
"name": "wrong",
"meta": {
"islogin": true
},
"style": {
"navigationBarTitleText": "错题练习",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "pages/doWork/index",
"name": "doWork",
"meta": {
"islogin": true
},
"style": {
"navigationBarTitleText": "答题",
"disableScroll": true,
"pageOrientation": "landscape",
"app-plus": {
"bounce": "none",
"allowsBounceVertical": "NO"
},
"mp-weixin": {
"pageOrientation": "landscape"
}
}
},
{
"path": "pages/wode/index",
"name": "wode",
"meta": {
"islogin": true
},
"style": {
"navigationBarTitleText": "我的",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
}
],
"subPackages": [
{
"root": "pages/teacher",
"name": "teacher",
"pages": [
{
"path": "index/index",
"name": "teacher-home",
"meta": {
"islogin": true,
"role": "teacher"
},
"style": {
"navigationBarTitleText": "老师首页",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "homework/filter",
"name": "teacher-homework-filter",
"meta": {
"islogin": true,
"role": "teacher"
},
"style": {
"navigationBarTitleText": "筛选条件",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "homework/index",
"name": "teacher-homework",
"meta": {
"islogin": true,
"role": "teacher"
},
"style": {
"navigationBarTitleText": "学情报告",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "weakKnowledge/index",
"name": "teacher-weak-knowledge",
"meta": {
"islogin": true,
"role": "teacher"
},
"style": {
"navigationBarTitleText": "薄弱知识点",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
}
]
},
{
"root": "pages/parent",
"pages": [
{
"path": "home/index",
"name": "parent-home",
"meta": {
"islogin": true,
"role": "parent"
},
"style": {
"navigationBarTitleText": "成绩查询家长主页",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "reports/index",
"name": "parent-reports",
"meta": {
"islogin": true,
"role": "parent"
},
"style": {
"navigationBarTitleText": "考试报告",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "reports/detail",
"name": "parent-report-detail",
"meta": {
"islogin": true,
"role": "parent"
},
"style": {
"navigationBarTitleText": "作业详情",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "analysis/index",
"name": "parent-analysis",
"meta": {
"islogin": true,
"role": "parent"
},
"style": {
"navigationBarTitleText": "学情追踪",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
},
{
"path": "profile/index",
"name": "parent-profile",
"meta": {
"islogin": true,
"role": "parent"
},
"style": {
"navigationBarTitleText": "个人中心",
"mp-weixin": {
"pageOrientation": "portrait"
}
}
}
]
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "学小乐AI",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F5F7FF",
"pageOrientation": "portrait"
}
}

File diff suppressed because it is too large Load Diff

695
src/pages/doWork/index.vue Normal file
View File

@ -0,0 +1,695 @@
<template>
<view class="dw-page">
<!-- 装饰背景 -->
<view class="bg-decor bg-decor-a"></view>
<view class="bg-decor bg-decor-b"></view>
<!-- 顶部条 -->
<view class="top">
<view class="top-back" @click="handleBack">
<view class="back-arrow"></view>
</view>
<view class="top-title">
<text class="ellipsis">{{ title }}</text>
</view>
<view
v-if="!pageLoading"
class="top-meta"
:style="{ paddingRight: menuButtonRightRpx + 'rpx' }"
>
<view class="top-progress">
<text class="cur">{{ activeIdx + 1 }}</text>
<text class="divider">/</text>
<text class="total">{{ paperList.length }}</text>
</view>
<view class="top-clock">
<view class="clock-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="#4F46E5" stroke-width="1.4" />
<path d="M8 5v3l2 1.2" stroke="#4F46E5" stroke-width="1.4"
stroke-linecap="round" />
</svg>
</view>
<text>{{ timeFilter }}</text>
</view>
</view>
</view>
<!-- 主体 -->
<view v-if="!pageLoading" class="main">
<view class="book-box">
<Question
v-if="paperList.length && paperList[activeIdx]"
:key="`question-${activeIdx}-${paperList[activeIdx]?.id ?? ''}`"
:data="paperList[activeIdx]"
:idx="activeIdx + 1"
:total="paperList.length"
:resourceType="resourceType"
:reportFlag="reportFlag"
:subjectId="paperInfo.subjectId"
@submit="submitQuestion"
/>
</view>
<!-- 底部题号 + 操作 -->
<view class="bottom">
<scroll-view class="indicator" scroll-x :show-scrollbar="false">
<view class="indicator-track">
<view
v-for="(item, idx) in paperList"
v-show="!item.unLook"
:key="item.id || idx"
:class="[
'indicator-item',
{
active: activeIdx === idx,
success: reportFlag && (item.correctResult || item.markFlag),
error: reportFlag && !(item.correctResult || item.markFlag) && (item.submitAnswer || item.submitAnswerPic),
empty: !reportFlag && !item.submitAnswer && !item.submitAnswerPic,
answered: !reportFlag && (item.submitAnswer || item.submitAnswerPic),
},
]"
@click="switchQuestion(idx)"
>
<text>{{ idx + 1 }}</text>
</view>
</view>
</scroll-view>
<view
v-if="!reportFlag"
:class="['btn-next', { disabled: submitLoading, submit: activeIdx >= paperList.length - 1 }]"
@click="handleNext"
>
<text>{{ activeIdx < paperList.length - 1 ? '下一题' : '提交' }}</text>
<view class="btn-arrow">
<svg viewBox="0 0 16 16" fill="none">
<path d="m6 4 4 4-4 4" stroke="#FFFFFF" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</view>
</view>
</view>
</view>
<!-- 加载中 -->
<view v-else class="loading-page">
<Loading :text="reportFlag ? '正在加载报告中...' : '正在精选题目中...'" />
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import {
startExam,
saveRecordAnswer,
submitExam,
getPaperRecordDetail,
} from '@/api/homework';
import Loading from '@/components/Loading/index.vue';
import Question from './components/Question.vue';
const RESOURCE_TYPE_HOMEWORK = 'homework';
const pageLoading = ref(true);
const submitLoading = ref(false);
const menuButtonRightRpx = ref(0);
const paperList = ref<any[]>([]);
const paperInfo = ref<any>({});
const activeIdx = ref(0);
const reportFlag = ref<0 | 1>(0);
const params = ref<any>({});
let timer: any = null;
const initialIdx = ref(0);
const resourceType = computed(() => params.value.resourceType || RESOURCE_TYPE_HOMEWORK);
const title = computed(() => {
const name = paperInfo.value.paperName;
if (name) {
return name.replace(/-\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/, '');
}
return '作业';
});
const timeFilter = computed(() => {
const cost = paperInfo.value.costTime || 0;
const h = Math.floor(cost / 3600);
const m = Math.floor((cost % 3600) / 60);
const s = cost % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
});
onLoad((options: any) => {
initialIdx.value = Number(options.idx) || 0;
if (options.id) {
reportFlag.value = 1;
params.value = { ...options, id: options.id };
} else {
reportFlag.value = Number(options.reportFlag || 0) as 0 | 1;
params.value = { ...options };
}
init();
});
const handleBack = () => {
if (reportFlag.value) {
uni.navigateBack();
return;
}
uni.showModal({
title: '提示',
content: '是否确认退出,退出后不保留答题记录',
confirmColor: '#4f46e5',
success: (res) => {
if (res.confirm) {
clearTimer();
uni.navigateBack();
}
},
});
};
const switchQuestion = async (idx?: number) => {
if (idx === activeIdx.value) {
if (!reportFlag.value && resourceType.value === RESOURCE_TYPE_HOMEWORK) {
await saveAnswer();
}
return;
}
if (idx !== undefined) {
if (!reportFlag.value && resourceType.value === RESOURCE_TYPE_HOMEWORK) {
await saveAnswer();
}
activeIdx.value = idx;
} else if (activeIdx.value < paperList.value.length - 1) {
if (!reportFlag.value && resourceType.value === RESOURCE_TYPE_HOMEWORK) {
await saveAnswer();
}
activeIdx.value++;
}
if (!reportFlag.value && paperList.value[activeIdx.value]) {
paperList.value[activeIdx.value].unLook = false;
}
};
const handleNext = async () => {
if (submitLoading.value) return;
if (activeIdx.value < paperList.value.length - 1) {
await switchQuestion();
} else {
if (paperList.value.some((i) => !i.submitAnswer && !i.submitAnswerPic)) {
uni.showModal({
title: '提示',
content: '还有题目未作答,确定要提交答案吗?',
confirmColor: '#4f46e5',
success: (res) => {
if (res.confirm) submitTrain();
},
});
} else {
submitTrain();
}
}
};
const saveAnswer = async () => {
const cur = paperList.value[activeIdx.value];
if (!cur) return;
try {
await saveRecordAnswer({
answer: cur.submitAnswer || '',
answerPic: cur.submitAnswerPic || '',
costTime: (paperInfo.value.costTime || 0) * 1000,
itemId: cur.itemId || cur.id,
recordId: params.value.recordId,
});
} catch (err) {
console.error('[doWork] save err', err);
}
};
const submitQuestion = (answer: any) => {
if (reportFlag.value) return;
const idx = activeIdx.value;
if (answer.submitAnswer !== undefined) {
paperList.value[idx].submitAnswer = answer.submitAnswer;
}
if (answer.submitAnswerPic !== undefined) {
paperList.value[idx].submitAnswerPic = answer.submitAnswerPic;
}
if (answer.toNext && activeIdx.value < paperList.value.length - 1) {
setTimeout(() => switchQuestion(), 300);
}
};
const submitTrain = async () => {
clearTimer();
submitLoading.value = true;
uni.showLoading({ title: '提交中...', mask: true });
try {
await saveAnswer();
await submitExam({
answerDtoList: paperList.value.map((item) => ({
itemId: item.itemId || item.id,
answer: item.submitAnswer || '',
answerPic: item.submitAnswerPic || '',
})),
costTime: (paperInfo.value.costTime || 0) * 1000,
recordId: params.value.recordId,
});
uni.hideLoading();
uni.showModal({
title: '提示',
content: '试卷提交成功请等待批改',
showCancel: false,
confirmText: '好的',
confirmColor: '#4f46e5',
success: () => uni.navigateBack(),
});
} catch (err: any) {
uni.hideLoading();
uni.showToast({ title: err?.message || '提交失败', icon: 'none' });
startTimer();
} finally {
submitLoading.value = false;
}
};
const startTimer = (init?: boolean) => {
clearTimer();
if (init) paperInfo.value.costTime = 0;
timer = setInterval(() => {
paperInfo.value.costTime = (paperInfo.value.costTime || 0) + 1;
}, 1000);
};
const clearTimer = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const initReport = async () => {
const res = await getPaperRecordDetail({
recordId: params.value.recordId || params.value.id,
reportFlag: 1,
});
paperInfo.value = res.data;
paperList.value = res.data.subjectTitleVoList || [];
};
const initNewTrain = async () => {
const res: any = await startExam(params.value.recordId);
paperInfo.value = res.data || res;
if (paperInfo.value.costTime) {
paperInfo.value.costTime = Math.floor(paperInfo.value.costTime / 1000);
}
paperList.value = (res.data?.subjectTitleVoList || res.subjectTitleVoList || []).map(
(item: any, idx: number) => ({
...item,
unLook: idx > 0,
}),
);
};
const getMenuButtonInfo = () => {
try {
// #ifdef MP-WEIXIN
const m = uni.getMenuButtonBoundingClientRect();
const sys = uni.getSystemInfoSync();
if (m && sys) {
const rightPx = sys.windowWidth - m.right;
const widthPx = m.width;
const pxToRpx = 750 / sys.windowWidth;
menuButtonRightRpx.value = Math.ceil((rightPx + widthPx) * pxToRpx) + 16;
} else {
menuButtonRightRpx.value = 120;
}
// #endif
// #ifndef MP-WEIXIN
menuButtonRightRpx.value = 0;
// #endif
} catch {
menuButtonRightRpx.value = 120;
}
};
const init = async () => {
try {
pageLoading.value = true;
if (reportFlag.value) {
await initReport();
} else {
await initNewTrain();
startTimer();
}
const target = Math.min(initialIdx.value, paperList.value.length - 1);
activeIdx.value = Math.max(0, target);
} catch (err: any) {
console.error('[doWork] init err', err);
uni.showToast({ title: err?.message || '加载失败', icon: 'none' });
setTimeout(() => uni.navigateBack(), 1500);
} finally {
pageLoading.value = false;
}
};
onMounted(() => {
getMenuButtonInfo();
});
onUnmounted(() => {
clearTimer();
paperInfo.value = {};
paperList.value = [];
activeIdx.value = 0;
});
</script>
<style lang="scss" scoped>
.dw-page {
height: 100vh;
width: 100vw;
overflow: hidden;
box-sizing: border-box;
position: relative;
background: #f5f7ff;
display: flex;
flex-direction: column;
}
/* 装饰背景 */
.bg-decor {
position: absolute;
border-radius: 50%;
filter: blur(60rpx);
pointer-events: none;
z-index: 0;
&-a {
top: -80rpx;
right: -80rpx;
width: 280rpx;
height: 280rpx;
background: radial-gradient(circle, rgba(129, 140, 248, 0.35), transparent 70%);
}
&-b {
bottom: -80rpx;
left: -80rpx;
width: 280rpx;
height: 280rpx;
background: radial-gradient(circle, rgba(34, 197, 94, 0.3), transparent 70%);
}
}
/* 顶部条 */
.top {
position: relative;
z-index: 10;
display: flex;
align-items: center;
padding: 12rpx 20rpx 8rpx;
gap: 16rpx;
}
.top-back {
width: 56rpx;
height: 56rpx;
flex-shrink: 0;
background: #ffffff;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4rpx 10rpx rgba(99, 102, 241, 0.18),
inset 0 -3rpx 0 rgba(199, 210, 254, 0.6);
&:active {
transform: scale(0.92);
}
}
.back-arrow {
width: 14rpx;
height: 14rpx;
border-left: 4rpx solid #4f46e5;
border-bottom: 4rpx solid #4f46e5;
transform: rotate(45deg);
margin-left: 4rpx;
}
.top-title {
flex: 1;
min-width: 0;
font-size: 24rpx;
font-weight: 700;
color: #312e81;
text-align: center;
text {
max-width: 100%;
}
}
.top-meta {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.top-progress {
display: flex;
align-items: baseline;
gap: 2rpx;
padding: 6rpx 16rpx;
background: linear-gradient(180deg, #818cf8, #4f46e5);
border-radius: 999rpx;
color: #ffffff;
box-shadow:
0 4rpx 10rpx rgba(79, 70, 229, 0.3),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.12);
.cur {
font-size: 22rpx;
font-weight: 800;
}
.divider {
font-size: 16rpx;
opacity: 0.6;
}
.total {
font-size: 18rpx;
opacity: 0.8;
}
}
.top-clock {
display: flex;
align-items: center;
gap: 6rpx;
padding: 6rpx 14rpx;
background: #ffffff;
border-radius: 999rpx;
box-shadow:
0 4rpx 10rpx rgba(99, 102, 241, 0.15),
inset 0 -3rpx 0 rgba(199, 210, 254, 0.5);
.clock-icon {
width: 24rpx;
height: 24rpx;
svg {
width: 100%;
height: 100%;
}
}
text {
font-size: 20rpx;
font-weight: 700;
color: #4f46e5;
font-variant-numeric: tabular-nums;
}
}
/* 主体 */
.main {
flex: 1;
height: 0;
position: relative;
z-index: 1;
padding: 0 20rpx 12rpx;
display: flex;
flex-direction: column;
}
.book-box {
flex: 1;
overflow: hidden;
position: relative;
}
.bottom {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12rpx;
padding-top: 10rpx;
}
.indicator {
flex: 1;
width: 0;
height: 56rpx;
white-space: nowrap;
}
.indicator-track {
display: inline-flex;
align-items: center;
gap: 10rpx;
padding: 4rpx 0;
}
.indicator-item {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
background: #ffffff;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4rpx 8rpx rgba(99, 102, 241, 0.12),
inset 0 -3rpx 0 rgba(199, 210, 254, 0.5);
transition: all 150ms ease;
text {
font-size: 22rpx;
font-weight: 700;
color: #6366f1;
}
&.active {
background: linear-gradient(180deg, #818cf8, #4f46e5);
box-shadow:
0 6rpx 14rpx rgba(79, 70, 229, 0.4),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
text {
color: #ffffff;
}
}
&.answered {
background: linear-gradient(180deg, #c7d2fe, #818cf8);
text {
color: #ffffff;
}
}
&.empty {
background: #f1f5f9;
box-shadow: inset 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
text {
color: #94a3b8;
}
}
&.success {
background: linear-gradient(180deg, #4ade80, #16a34a);
box-shadow:
0 6rpx 12rpx rgba(22, 163, 74, 0.3),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
text {
color: #ffffff;
}
}
&.error {
background: linear-gradient(180deg, #f87171, #dc2626);
box-shadow:
0 6rpx 12rpx rgba(220, 38, 38, 0.3),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
text {
color: #ffffff;
}
}
}
.btn-next {
flex-shrink: 0;
height: 56rpx;
padding: 0 28rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
background: linear-gradient(180deg, #4ade80, #16a34a);
color: #ffffff;
box-shadow:
0 6rpx 14rpx rgba(22, 163, 74, 0.35),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
transition: all 150ms ease;
text {
font-size: 22rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.btn-arrow {
width: 22rpx;
height: 22rpx;
svg {
width: 100%;
height: 100%;
}
}
&.submit {
background: linear-gradient(180deg, #818cf8, #4f46e5);
box-shadow:
0 6rpx 14rpx rgba(79, 70, 229, 0.4),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15);
}
&.disabled {
opacity: 0.6;
}
&:active {
transform: translateY(2rpx);
}
}
/* 加载 */
.loading-page {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
</style>

View File

@ -0,0 +1,702 @@
<template>
<view class="hist-page">
<!-- 装饰背景 -->
<view class="bg-decor bg-decor-a"></view>
<view class="bg-decor bg-decor-b"></view>
<!-- 状态筛选 (sticky 段控件) -->
<view class="filter-bar">
<view class="filter-segment">
<view
v-for="item in statusList"
:key="item.value"
:class="['filter-tab', { active: activeStatus === item.value }]"
@click="onChangeStatus(item.value)"
>
<text>{{ item.label }}</text>
</view>
</view>
</view>
<!-- 学科横向滚动 -->
<view v-if="subjectList.length" class="subject-bar">
<scroll-view scroll-x :show-scrollbar="false" class="subject-scroll">
<view class="subject-list">
<view
v-for="item in subjectListAll"
:key="item.id"
:class="['subject-pill', { active: activeSubjectId === item.id }]"
@click="onSelectSubject(item.id)"
>
<text>{{ item.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 列表区 -->
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false">
<view v-if="jobList.length" class="job-list">
<view
v-for="item in jobList"
:key="item.id"
class="job-card"
@click="onTapJob(item)"
>
<!-- 上方左右两栏 -->
<view class="job-head">
<view class="job-subject">
<view class="job-subject-emoji">{{ getSubjectEmoji(item.subjectName) }}</view>
<text>{{ item.subjectName || activeSubjectName }}</text>
</view>
<view :class="['job-status', item.gradingStatus === 1 ? 'corrected' : 'pending']">
<view class="status-dot"></view>
<text>{{ item.gradingStatus === 1 ? '已批改' : '待批改' }}</text>
</view>
</view>
<view class="job-title">{{ item.paperName }}</view>
<!-- 已批改 -> 显示得分卡 -->
<view v-if="item.gradingStatus === 1" class="job-score" :class="getRateType(item)">
<view class="score-left">
<text class="score-label">得分率</text>
<view class="score-num">
<text class="num">{{ getScoreRate(item) }}</text>
<text class="unit">%</text>
</view>
<ProgressBar
:rate="(item.score || 0) / (item.totalScore || 1)"
:width="280"
v-bind="getProgressColor(item)"
/>
</view>
<view class="score-right">
<view class="score-circle">
<text class="rank-emoji">{{ getRankEmoji(item) }}</text>
</view>
<view v-if="item.addDiamond || item.addExp" class="reward-row">
<view v-if="item.addDiamond" class="reward-pill diamond">
<view class="reward-icon">
<svg viewBox="0 0 16 16" fill="none">
<path d="M3 6 8 2l5 4-5 9-5-9z" fill="#0EA5E9" stroke="#0369A1"
stroke-width="1" stroke-linejoin="round" />
</svg>
</view>
<text>+{{ item.addDiamond }}</text>
</view>
<view v-if="item.addExp" class="reward-pill exp">
<view class="reward-icon">
<svg viewBox="0 0 16 16" fill="none">
<path d="m8 1 2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L.7 6.2l5-.7L8 1z"
fill="#F59E0B" stroke="#B45309" stroke-width="1" />
</svg>
</view>
<text>+{{ item.addExp }}</text>
</view>
</view>
</view>
</view>
<!-- 未批改 -> 提示信息 -->
<view v-else class="job-pending">
<view class="pending-icon">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="#94A3B8" stroke-width="1.6" />
<path d="M12 7v5l3 2" stroke="#94A3B8" stroke-width="1.6"
stroke-linecap="round" />
</svg>
</view>
<text>等待老师批改中</text>
</view>
<!-- 时间信息 -->
<view class="job-meta">
<view v-if="item.createTime" class="meta-line">
<text class="meta-label">发布</text>
<text class="meta-value">{{ formatTime(item.createTime) }}</text>
</view>
<view v-if="item.markTime" class="meta-line">
<text class="meta-label">批改</text>
<text class="meta-value">{{ formatTime(item.markTime) }}</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<Empty
v-else-if="!listLoading"
class="empty"
:type="pageError ? 'network' : 'learn'"
:content="pageError ? '网络出问题了啦' : '暂无作业记录,快去做作业吧!'"
/>
<view style="height: 80rpx"></view>
</scroll-view>
<view v-if="listLoading" class="page-loader">
<Loading text="加载中..." />
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import Empty from '@/components/Empty/index.vue';
import Loading from '@/components/Loading/index.vue';
import ProgressBar from '@/components/ProgressBar/index.vue';
import { getListCompleteRecord } from '@/api/homework';
import { formatTime } from '@/utils/format';
const statusList = [
{ label: '全部', value: '' },
{ label: '已批改', value: '1' },
{ label: '未批改', value: '0' },
];
interface SubjectItem {
id: string;
name: string;
}
const activeStatus = ref('');
const subjectList = ref<SubjectItem[]>([]);
const activeSubjectId = ref<string>('');
const jobList = ref<any[]>([]);
const listLoading = ref(false);
const pageError = ref(false);
const subjectListAll = computed<SubjectItem[]>(() => {
return [{ id: '', name: '全部' }, ...subjectList.value];
});
const activeSubjectName = computed(() => {
return subjectList.value.find((i) => i.id === activeSubjectId.value)?.name || '';
});
const colorConfig: Record<string, any> = {
success: { color: '#16A34A', border: '#16A34A', bg: '#DCFCE7', activeBg: '#22C55E' },
primary: { color: '#4F46E5', border: '#4F46E5', bg: '#E0E7FF', activeBg: '#818CF8' },
warning: { color: '#D97706', border: '#D97706', bg: '#FEF3C7', activeBg: '#F59E0B' },
error: { color: '#DC2626', border: '#DC2626', bg: '#FEE2E2', activeBg: '#EF4444' },
};
const getScoreRate = (item: any) => {
if (!item.totalScore) return 0;
return Math.round((item.score / item.totalScore) * 100);
};
const getRateType = (item: any) => {
const r = getScoreRate(item);
if (r >= 80) return 'success';
if (r >= 60) return 'primary';
if (r >= 40) return 'warning';
return 'error';
};
const getProgressColor = (item: any) => colorConfig[getRateType(item)] || colorConfig.primary;
const getRankEmoji = (item: any) => {
const r = getScoreRate(item);
if (r >= 95) return '🏆';
if (r >= 80) return '🌟';
if (r >= 60) return '👍';
if (r >= 40) return '💪';
return '📚';
};
const getSubjectEmoji = (name: string) => {
if (!name) return '📘';
if (name.includes('语文')) return '📖';
if (name.includes('数学')) return '🧮';
if (name.includes('英语')) return '🔤';
if (name.includes('物理')) return '🔬';
if (name.includes('化学')) return '⚗️';
if (name.includes('生物')) return '🌿';
if (name.includes('历史')) return '📜';
if (name.includes('地理')) return '🌏';
if (name.includes('政治')) return '⚖️';
return '📘';
};
const onChangeStatus = (val: string) => {
activeStatus.value = val;
refresh();
};
const onSelectSubject = (id: string) => {
activeSubjectId.value = id;
refresh();
};
const onTapJob = (item: any) => {
if (item.gradingStatus !== 1) {
uni.showToast({ title: '作业未批改,请耐心等待老师批改', icon: 'none' });
return;
}
uni.navigateTo({
url: `/pages/homework/reports/index?id=${item.id}&name=${encodeURIComponent(item.paperName || '')}`,
});
};
const initSubject = async () => {
try {
const res = await getListCompleteRecord({});
const records = res.data || [];
const map = new Map<string, SubjectItem>();
records.forEach((item: any) => {
const sid = String(item.subjectId);
if (!map.has(sid)) {
map.set(sid, { id: sid, name: item.subjectName });
}
});
subjectList.value = Array.from(map.values());
} catch (err) {
console.error('[history] init subject err', err);
}
};
const fetchJobList = async () => {
const params: Record<string, any> = {};
if (activeStatus.value !== '') params.gradingStatus = activeStatus.value;
if (activeSubjectId.value) params.subjectId = activeSubjectId.value;
const res = await getListCompleteRecord(params);
jobList.value = res.data || [];
};
const refresh = async () => {
pageError.value = false;
listLoading.value = true;
jobList.value = [];
try {
await fetchJobList();
} catch (err) {
pageError.value = true;
console.error('[history] fetch job err', err);
} finally {
listLoading.value = false;
}
};
onMounted(async () => {
listLoading.value = true;
try {
await initSubject();
await fetchJobList();
} catch (err) {
pageError.value = true;
} finally {
listLoading.value = false;
}
});
onShow(() => {
if (subjectList.value.length || jobList.value.length) {
refresh();
}
});
</script>
<style lang="scss" scoped>
.hist-page {
min-height: 100vh;
background: #f5f7ff;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.bg-decor {
position: absolute;
border-radius: 50%;
filter: blur(60rpx);
pointer-events: none;
z-index: 0;
&-a {
top: -120rpx;
left: -100rpx;
width: 360rpx;
height: 360rpx;
background: radial-gradient(circle, rgba(34, 197, 94, 0.35), transparent 70%);
}
&-b {
top: 320rpx;
right: -120rpx;
width: 320rpx;
height: 320rpx;
background: radial-gradient(circle, rgba(129, 140, 248, 0.35), transparent 70%);
}
}
/* 状态筛选 */
.filter-bar {
padding: 24rpx 32rpx 8rpx;
position: relative;
z-index: 1;
}
.filter-segment {
display: flex;
background: rgba(255, 255, 255, 0.7);
border-radius: 999rpx;
padding: 8rpx;
box-shadow: inset 0 4rpx 8rpx rgba(99, 102, 241, 0.1);
}
.filter-tab {
flex: 1;
height: 64rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: #94a3b8;
font-weight: 500;
transition: color 200ms ease;
&.active {
background: linear-gradient(180deg, #818cf8 0%, #4f46e5 100%);
color: #ffffff;
font-weight: 700;
box-shadow:
0 6rpx 14rpx rgba(79, 70, 229, 0.3),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.12);
}
}
/* 学科横滚 */
.subject-bar {
padding: 12rpx 0 8rpx;
position: relative;
z-index: 1;
}
.subject-scroll {
width: 100%;
white-space: nowrap;
}
.subject-list {
display: inline-flex;
padding: 0 32rpx;
gap: 16rpx;
}
.subject-pill {
flex-shrink: 0;
padding: 12rpx 28rpx;
background: #ffffff;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 500;
color: #6366f1;
box-shadow:
0 4rpx 10rpx rgba(99, 102, 241, 0.1),
inset 0 -3rpx 0 rgba(199, 210, 254, 0.5);
&.active {
background: linear-gradient(180deg, #4ade80 0%, #16a34a 100%);
color: #ffffff;
font-weight: 700;
box-shadow:
0 8rpx 16rpx rgba(22, 163, 74, 0.3),
inset 0 -4rpx 0 rgba(0, 0, 0, 0.15);
}
}
/* 列表 */
.list-scroll {
flex: 1;
height: 0;
position: relative;
z-index: 1;
}
.job-list {
padding: 24rpx 32rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.job-card {
background: #ffffff;
border-radius: 32rpx;
padding: 28rpx;
box-shadow:
0 12rpx 24rpx rgba(99, 102, 241, 0.12),
inset 0 -5rpx 0 rgba(199, 210, 254, 0.5);
transition: transform 150ms ease;
&:active {
transform: scale(0.98);
}
}
.job-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.job-subject {
display: flex;
align-items: center;
gap: 8rpx;
padding: 6rpx 18rpx 6rpx 12rpx;
background: linear-gradient(135deg, #eef2ff, #e0e7ff);
border-radius: 999rpx;
.job-subject-emoji {
font-size: 26rpx;
}
text {
font-size: 22rpx;
color: #4f46e5;
font-weight: 600;
}
}
.job-status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 6rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
&.corrected {
background: #dcfce7;
color: #16a34a;
.status-dot {
background: #22c55e;
}
}
&.pending {
background: #fef3c7;
color: #d97706;
.status-dot {
background: #f59e0b;
}
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
}
}
.job-title {
font-size: 32rpx;
font-weight: 700;
color: #312e81;
line-height: 1.4;
margin-bottom: 18rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
}
/* 已批改得分卡 */
.job-score {
display: flex;
align-items: center;
padding: 24rpx;
border-radius: 24rpx;
margin-bottom: 18rpx;
gap: 20rpx;
&.success {
background: linear-gradient(135deg, rgba(220, 252, 231, 0.7), rgba(187, 247, 208, 0.5));
}
&.primary {
background: linear-gradient(135deg, rgba(224, 231, 255, 0.7), rgba(199, 210, 254, 0.5));
}
&.warning {
background: linear-gradient(135deg, rgba(254, 243, 199, 0.7), rgba(253, 230, 138, 0.5));
}
&.error {
background: linear-gradient(135deg, rgba(254, 226, 226, 0.7), rgba(254, 202, 202, 0.5));
}
}
.score-left {
flex: 1;
min-width: 0;
.score-label {
font-size: 22rpx;
color: #6366f1;
font-weight: 500;
opacity: 0.8;
}
.score-num {
display: flex;
align-items: baseline;
gap: 4rpx;
margin: 4rpx 0 12rpx;
.num {
font-size: 64rpx;
font-weight: 800;
color: #312e81;
line-height: 1;
}
.unit {
font-size: 28rpx;
color: #6366f1;
font-weight: 600;
}
}
}
.score-right {
width: 140rpx;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.score-circle {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 6rpx 12rpx rgba(99, 102, 241, 0.18),
inset 0 -3rpx 0 rgba(199, 210, 254, 0.6);
.rank-emoji {
font-size: 48rpx;
}
}
.reward-row {
display: flex;
flex-wrap: wrap;
gap: 6rpx;
justify-content: center;
}
.reward-pill {
display: flex;
align-items: center;
gap: 4rpx;
padding: 4rpx 10rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 700;
&.diamond {
background: #e0f2fe;
color: #0369a1;
}
&.exp {
background: #fef3c7;
color: #b45309;
}
.reward-icon {
width: 22rpx;
height: 22rpx;
svg {
width: 100%;
height: 100%;
}
}
}
/* 未批改提示 */
.job-pending {
display: flex;
align-items: center;
gap: 12rpx;
padding: 20rpx 24rpx;
background: #f5f7ff;
border-radius: 20rpx;
margin-bottom: 18rpx;
.pending-icon {
width: 40rpx;
height: 40rpx;
svg {
width: 100%;
height: 100%;
}
}
text {
font-size: 26rpx;
color: #94a3b8;
}
}
/* meta */
.job-meta {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.meta-line {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 22rpx;
.meta-label {
color: #94a3b8;
padding: 2rpx 10rpx;
background: #f1f5f9;
border-radius: 6rpx;
}
.meta-value {
color: #6366f1;
font-weight: 500;
}
}
.empty {
padding: 200rpx 0;
}
.page-loader {
position: fixed;
inset: 0;
background: rgba(245, 247, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 99;
}
</style>

View File

@ -0,0 +1,695 @@
<template>
<view class="hw-page">
<!-- 顶部装饰 -->
<view class="bg-decor bg-decor-a"></view>
<view class="bg-decor bg-decor-b"></view>
<!-- 主体 -->
<scroll-view class="hw-scroll" scroll-y :show-scrollbar="false" :enhanced="true">
<!-- 学科tab组 -->
<view v-if="subjectList.length" class="subject-bar">
<scroll-view scroll-x :show-scrollbar="false" class="subject-scroll">
<view class="subject-list">
<view
v-for="item in subjectList"
:key="item.id"
:class="['subject-tab', { active: activeSubjectId === item.id }]"
@click="onSelectSubject(item)"
>
<view class="subject-emoji">{{ getSubjectEmoji(item.name) }}</view>
<view class="subject-name">{{ item.name }}</view>
<view class="subject-count">{{ getSubjectCount(item.id) }}</view>
<view v-if="item.warning" class="subject-dot"></view>
</view>
</view>
</scroll-view>
</view>
<!-- 头部统计卡片 -->
<view v-if="totalCount > 0" class="stats-card">
<view class="stats-bg"></view>
<view class="stats-content">
<view class="stats-left">
<view class="stats-number">{{ totalCount }}</view>
<view class="stats-label">份待完成作业</view>
</view>
<view class="stats-right">
<view class="stats-illu">
<svg viewBox="0 0 80 80" fill="none">
<rect x="14" y="10" width="48" height="60" rx="6"
fill="#FFFFFF" stroke="#312E81" stroke-width="2" />
<rect x="22" y="20" width="32" height="3" rx="1.5" fill="#22C55E" />
<rect x="22" y="30" width="24" height="3" rx="1.5" fill="#94A3B8" />
<rect x="22" y="38" width="28" height="3" rx="1.5" fill="#94A3B8" />
<rect x="22" y="46" width="20" height="3" rx="1.5" fill="#94A3B8" />
<circle cx="55" cy="58" r="10" fill="#22C55E" stroke="#312E81" stroke-width="2" />
<path d="m51 58 3 3 6-6" stroke="#FFFFFF" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</view>
</view>
</view>
</view>
<!-- 作业列表 -->
<view v-if="paperList.length" class="paper-list">
<view
v-for="paper in paperList"
:key="paper.id"
class="paper-card"
@click="onStartPaper(paper)"
>
<!-- 顶部标签 -->
<view class="paper-head">
<view :class="['paper-status', paper.costTime ? 'continue' : 'new']">
<view class="status-dot"></view>
<text>{{ paper.costTime ? '继续答题' : '未开始' }}</text>
</view>
<view class="paper-deadline">
<text class="deadline-label">截止</text>
<text class="deadline-time">{{ formatDeadline(paper.endTime) }}</text>
</view>
</view>
<!-- 标题 -->
<view class="paper-title">{{ paper.paperName }}</view>
<!-- 备注 -->
<view v-if="paper.remark" class="paper-remark">
<view class="remark-icon">
<svg viewBox="0 0 16 16" fill="none">
<path d="M2 4h12M2 8h12M2 12h8" stroke="#94A3B8"
stroke-width="1.6" stroke-linecap="round" />
</svg>
</view>
<text>{{ paper.remark }}</text>
</view>
<!-- 底部信息 -->
<view class="paper-meta">
<view class="meta-item">
<view class="meta-avatar">
<text>{{ (paper.publisherName || '师').charAt(0) }}</text>
</view>
<text class="meta-text">{{ paper.publisherName || '老师' }}</text>
</view>
<view class="meta-spacer"></view>
<view class="meta-time">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="#94A3B8" stroke-width="1.4" />
<path d="M8 5v3l2 1" stroke="#94A3B8" stroke-width="1.4" stroke-linecap="round" />
</svg>
<text>{{ formatTime(paper.createTime) }}</text>
</view>
</view>
<!-- CTA 按钮 -->
<view :class="['paper-cta', paper.costTime ? 'continue' : 'start']">
<text>{{ paper.costTime ? '继续答题' : '开始答题' }}</text>
<view class="cta-arrow">
<svg viewBox="0 0 16 16" fill="none">
<path d="m6 4 4 4-4 4" stroke="#FFFFFF" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<Empty
v-else-if="!pageLoading && !paperList.length && subjectList.length"
class="empty"
type="task"
content="该科目暂无待完成作业"
/>
<!-- 整页空状态 -->
<Empty
v-else-if="!pageLoading && !subjectList.length"
class="empty empty-page"
type="task"
content="还没有作业哦~"
/>
<!-- 底部留白 -->
<view style="height: 60rpx"></view>
</scroll-view>
<!-- 加载状态 -->
<view v-if="pageLoading" class="page-loader">
<Loading text="加载作业中..." />
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import Empty from '@/components/Empty/index.vue';
import Loading from '@/components/Loading/index.vue';
import { getUnfinishedHomework, type PaperRecord } from '@/api/homework';
import { formatTime } from '@/utils/format';
const RESOURCE_TYPE = 'homework';
interface SubjectItem {
id: string;
name: string;
warning: boolean;
}
const pageLoading = ref(false);
const subjectList = ref<SubjectItem[]>([]);
const subjectPaperMap = ref(new Map<string, PaperRecord[]>());
const activeSubjectId = ref('');
const paperList = computed<PaperRecord[]>(() => {
if (!activeSubjectId.value) return [];
return subjectPaperMap.value.get(activeSubjectId.value) || [];
});
const totalCount = computed(() => {
let count = 0;
subjectPaperMap.value.forEach((list) => {
count += list.length;
});
return count;
});
const getSubjectCount = (id: string) => subjectPaperMap.value.get(id)?.length || 0;
/** 学科emoji映射 */
const getSubjectEmoji = (name: string) => {
if (!name) return '📘';
if (name.includes('语文') || name.includes('国学')) return '📖';
if (name.includes('数学') || name.includes('算')) return '🧮';
if (name.includes('英语') || name.includes('英文')) return '🔤';
if (name.includes('物理')) return '🔬';
if (name.includes('化学')) return '⚗️';
if (name.includes('生物')) return '🌿';
if (name.includes('历史')) return '📜';
if (name.includes('地理')) return '🌏';
if (name.includes('政治') || name.includes('道德')) return '⚖️';
if (name.includes('音乐')) return '🎵';
if (name.includes('美术') || name.includes('画')) return '🎨';
if (name.includes('体育')) return '⚽';
if (name.includes('科学')) return '🧪';
if (name.includes('信息') || name.includes('计算机')) return '💻';
return '📘';
};
/** 截止时间友好格式 */
const formatDeadline = (time: string) => {
if (!time) return '不限';
const target = new Date(time).getTime();
const now = Date.now();
const diff = target - now;
if (diff < 0) return '已截止';
const day = Math.floor(diff / 86400000);
if (day < 1) {
const hour = Math.floor(diff / 3600000);
if (hour < 1) return '不足1小时';
return `${hour}小时后`;
}
if (day < 7) return `${day}天后`;
return formatTime(time, 'MM/DD');
};
const onSelectSubject = (item: SubjectItem) => {
activeSubjectId.value = item.id;
};
const onStartPaper = (paper: PaperRecord) => {
uni.navigateTo({
url: `/pages/doWork/index?resourceType=${RESOURCE_TYPE}&recordId=${paper.id}&reportFlag=0&subjectId=${activeSubjectId.value}`,
});
};
const initData = async () => {
pageLoading.value = true;
try {
const res = await getUnfinishedHomework();
const records = res.data || [];
const map = new Map<string, PaperRecord[]>();
const subjects: SubjectItem[] = [];
records.forEach((item) => {
const list = map.get(item.subjectId);
if (list) {
list.push(item);
} else {
map.set(item.subjectId, [item]);
subjects.push({
id: item.subjectId,
name: item.subjectName,
warning: true,
});
}
});
subjectPaperMap.value = map;
subjectList.value = subjects;
if (subjects.length && (!activeSubjectId.value || !map.has(activeSubjectId.value))) {
activeSubjectId.value = subjects[0].id;
}
} catch (err) {
console.error('[homework] init err', err);
} finally {
pageLoading.value = false;
}
};
onMounted(() => {
initData();
});
onShow(() => {
if (subjectList.value.length) {
initData();
}
});
</script>
<style lang="scss" scoped>
.hw-page {
min-height: 100vh;
background: #f5f7ff;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 装饰背景 */
.bg-decor {
position: absolute;
border-radius: 50%;
filter: blur(60rpx);
pointer-events: none;
z-index: 0;
&-a {
top: -100rpx;
right: -120rpx;
width: 360rpx;
height: 360rpx;
background: radial-gradient(circle, rgba(129, 140, 248, 0.45), transparent 70%);
}
&-b {
bottom: 200rpx;
left: -100rpx;
width: 320rpx;
height: 320rpx;
background: radial-gradient(circle, rgba(34, 197, 94, 0.3), transparent 70%);
}
}
.hw-scroll {
flex: 1;
height: 0;
box-sizing: border-box;
position: relative;
z-index: 1;
}
/* 学科 tab */
.subject-bar {
padding: 24rpx 0 8rpx;
}
.subject-scroll {
width: 100%;
white-space: nowrap;
}
.subject-list {
display: inline-flex;
padding: 0 32rpx;
gap: 20rpx;
}
.subject-tab {
position: relative;
flex-shrink: 0;
width: 130rpx;
padding: 20rpx 12rpx 18rpx;
background: #ffffff;
border-radius: 24rpx;
text-align: center;
transition: transform 200ms ease;
box-shadow:
0 6rpx 16rpx rgba(99, 102, 241, 0.12),
inset 0 -4rpx 0 rgba(199, 210, 254, 0.5);
&.active {
background: linear-gradient(180deg, #818cf8 0%, #4f46e5 100%);
box-shadow:
0 10rpx 24rpx rgba(79, 70, 229, 0.45),
inset 0 -5rpx 0 rgba(0, 0, 0, 0.15);
.subject-name {
color: #ffffff;
}
.subject-count {
color: #c7d2fe;
background: rgba(255, 255, 255, 0.16);
}
}
&:active {
transform: scale(0.95);
}
}
.subject-emoji {
font-size: 44rpx;
line-height: 1;
margin-bottom: 8rpx;
}
.subject-name {
font-size: 24rpx;
font-weight: 600;
color: #312e81;
line-height: 1.2;
}
.subject-count {
margin-top: 8rpx;
font-size: 20rpx;
color: #6366f1;
font-weight: 500;
padding: 2rpx 0;
border-radius: 999rpx;
background: rgba(199, 210, 254, 0.5);
}
.subject-dot {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #ef4444;
border: 3rpx solid #ffffff;
box-shadow: 0 0 0 2rpx rgba(239, 68, 68, 0.3);
}
/* 统计卡片 */
.stats-card {
margin: 24rpx 32rpx 8rpx;
position: relative;
border-radius: 32rpx;
overflow: hidden;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
box-shadow:
0 12rpx 24rpx rgba(245, 158, 11, 0.25),
inset 0 -6rpx 0 rgba(0, 0, 0, 0.05);
}
.stats-bg {
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.6), transparent 50%),
radial-gradient(circle at 20% 80%, rgba(245, 158, 11, 0.2), transparent 60%);
}
.stats-content {
position: relative;
display: flex;
align-items: center;
padding: 36rpx 36rpx;
}
.stats-left {
flex: 1;
}
.stats-number {
font-size: 88rpx;
font-weight: 800;
color: #92400e;
line-height: 1;
letter-spacing: -2rpx;
}
.stats-label {
margin-top: 12rpx;
font-size: 28rpx;
color: #b45309;
font-weight: 600;
}
.stats-right {
width: 160rpx;
height: 160rpx;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 6rpx 12rpx rgba(146, 64, 14, 0.2));
}
}
/* 作业列表 */
.paper-list {
padding: 24rpx 32rpx 24rpx;
display: flex;
flex-direction: column;
gap: 28rpx;
}
.paper-card {
background: #ffffff;
border-radius: 36rpx;
padding: 28rpx;
box-shadow:
0 12rpx 28rpx rgba(99, 102, 241, 0.15),
inset 0 -6rpx 0 rgba(199, 210, 254, 0.5);
transition: transform 150ms ease;
&:active {
transform: scale(0.98);
}
}
.paper-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.paper-status {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
&.new {
background: #eef2ff;
color: #4f46e5;
.status-dot {
background: #4f46e5;
}
}
&.continue {
background: #dcfce7;
color: #16a34a;
.status-dot {
background: #22c55e;
}
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
}
}
.paper-deadline {
display: flex;
align-items: baseline;
gap: 8rpx;
.deadline-label {
font-size: 22rpx;
color: #94a3b8;
}
.deadline-time {
font-size: 24rpx;
font-weight: 700;
color: #f59e0b;
}
}
.paper-title {
font-size: 34rpx;
font-weight: 700;
color: #312e81;
line-height: 1.4;
margin-bottom: 16rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
}
.paper-remark {
display: flex;
gap: 12rpx;
padding: 18rpx 20rpx;
background: #f5f7ff;
border-radius: 20rpx;
margin-bottom: 20rpx;
.remark-icon {
width: 28rpx;
height: 28rpx;
flex-shrink: 0;
margin-top: 4rpx;
svg {
width: 100%;
height: 100%;
}
}
text {
flex: 1;
font-size: 26rpx;
color: #6366f1;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.paper-meta {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.meta-item {
display: flex;
align-items: center;
gap: 12rpx;
}
.meta-avatar {
width: 44rpx;
height: 44rpx;
border-radius: 14rpx;
background: linear-gradient(135deg, #818cf8, #4f46e5);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 22rpx;
font-weight: 700;
box-shadow: 0 4rpx 8rpx rgba(79, 70, 229, 0.3);
}
.meta-text {
font-size: 24rpx;
color: #475569;
font-weight: 500;
}
.meta-spacer {
flex: 1;
}
.meta-time {
display: flex;
align-items: center;
gap: 6rpx;
svg {
width: 24rpx;
height: 24rpx;
}
text {
font-size: 22rpx;
color: #94a3b8;
}
}
.paper-cta {
height: 84rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
color: #ffffff;
font-size: 28rpx;
font-weight: 700;
letter-spacing: 4rpx;
&.start {
background: linear-gradient(180deg, #818cf8 0%, #4f46e5 100%);
box-shadow:
0 8rpx 20rpx rgba(79, 70, 229, 0.35),
inset 0 -5rpx 0 rgba(0, 0, 0, 0.15);
}
&.continue {
background: linear-gradient(180deg, #4ade80 0%, #16a34a 100%);
box-shadow:
0 8rpx 20rpx rgba(22, 163, 74, 0.35),
inset 0 -5rpx 0 rgba(0, 0, 0, 0.15);
}
}
.cta-arrow {
width: 28rpx;
height: 28rpx;
svg {
width: 100%;
height: 100%;
}
}
.empty {
padding: 120rpx 0;
}
.empty-page {
padding: 200rpx 0;
}
.page-loader {
position: fixed;
inset: 0;
background: rgba(245, 247, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 99;
}
</style>

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,737 @@
<template>
<view class="home">
<!-- 顶部装饰云朵 -->
<view class="decor decor-1"></view>
<view class="decor decor-2"></view>
<view class="decor decor-3"></view>
<!-- 头部 -->
<view class="hero">
<view class="hero-greet">
<view class="hero-hello">{{ greeting }} 👋</view>
<view class="hero-title">学小乐<text class="hero-title-ai">AI</text></view>
<view class="hero-sub">让学习像玩游戏一样有趣</view>
</view>
<view class="hero-mascot">
<view class="mascot-blob"></view>
<view class="mascot-face">
<view class="mascot-eye left"></view>
<view class="mascot-eye right"></view>
<view class="mascot-smile"></view>
</view>
</view>
</view>
<!-- 用户卡 / 登录卡 -->
<view v-if="isLogin" class="user-card">
<view class="user-avatar-wrap">
<image class="user-avatar" :src="avatarUrl" mode="aspectFill" />
<view class="user-badge">VIP</view>
</view>
<view class="user-info">
<view class="user-name">{{ userInfo.nickName || '小同学' }}</view>
<view class="user-school" v-if="userInfo.schoolName || userInfo.className">
<view class="ic ic-school">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 3 2 9l10 6 10-6-10-6z" stroke="currentColor" stroke-width="1.6"
stroke-linejoin="round" />
<path d="m6 11 0 5c0 1 2 3 6 3s6-2 6-3v-5" stroke="currentColor" stroke-width="1.6"
stroke-linejoin="round" />
</svg>
</view>
<text>{{ userInfo.schoolName || '' }} · {{ userInfo.className || '' }}</text>
</view>
</view>
<view class="user-stats">
<view class="user-stat">
<view class="user-stat-num">{{ userInfo.diamond || 0 }}</view>
<view class="user-stat-label">钻石</view>
</view>
<view class="user-stat-divider"></view>
<view class="user-stat">
<view class="user-stat-num">{{ userInfo.experience || 0 }}</view>
<view class="user-stat-label">经验</view>
</view>
</view>
</view>
<view v-else class="user-card login-card" @click="goLogin">
<view class="login-illu">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" stroke="#4F46E5" stroke-width="1.8" />
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8" stroke="#4F46E5" stroke-width="1.8" stroke-linecap="round" />
</svg>
</view>
<view class="login-text">
<view class="login-title">点击登录账号</view>
<view class="login-desc">登录后查看作业 · 同步进度</view>
</view>
<view class="login-arrow"></view>
</view>
<!-- 主功能入口 -->
<view class="section-title">
<view class="section-title-bar"></view>
<text>开始学习</text>
</view>
<view class="grid">
<view class="grid-card grid-card-primary" @click="navigate('/pages/homework/index')">
<view class="grid-card-decor"></view>
<view class="grid-card-icon">
<svg viewBox="0 0 24 24" fill="none">
<path d="M4 4h12l4 4v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
stroke="#fff" stroke-width="1.8" stroke-linejoin="round" />
<path d="M16 4v4h4M7 13h8M7 17h6" stroke="#fff" stroke-width="1.8" stroke-linecap="round" />
</svg>
</view>
<view class="grid-card-info">
<view class="grid-card-title">我的作业</view>
<view class="grid-card-desc">
<text v-if="unfinishedCount > 0">{{ unfinishedCount }} 项待完成</text>
<text v-else>暂无待完成作业</text>
</view>
</view>
<view v-if="unfinishedCount > 0" class="grid-card-badge">{{ unfinishedCount }}</view>
</view>
<view class="grid-card grid-card-cta" @click="navigate('/pages/homework/history/index')">
<view class="grid-card-decor"></view>
<view class="grid-card-icon">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="1.8" />
<path d="M12 7v5l3 2" stroke="#fff" stroke-width="1.8" stroke-linecap="round" />
</svg>
</view>
<view class="grid-card-info">
<view class="grid-card-title">作业记录</view>
<view class="grid-card-desc">查看历史作业与成绩</view>
</view>
</view>
</view>
<!-- 推荐 / 工具入口 -->
<view class="section-title">
<view class="section-title-bar"></view>
<text>学习工具</text>
</view>
<view class="tools">
<view class="tool-item" v-for="t in tools" :key="t.id" @click="onToolClick(t)">
<view class="tool-icon" :style="{ background: t.color }">
<text class="tool-icon-emoji" v-if="t.emoji">{{ t.emoji }}</text>
</view>
<text class="tool-name">{{ t.name }}</text>
</view>
</view>
<!-- 退出按钮 -->
<view v-if="isLogin" class="logout" @click="onLogout">
<text>退出登录</text>
</view>
<!-- 底部装饰 -->
<view class="footer">
<text>· 用心做教育用爱伴成长 ·</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/state/modules/user';
import { getUnfinishedHomework } from '@/api/homework';
import { getCache } from '@/utils/cache';
const userStore = useUserStore();
const { userInfo, token, isLogin } = storeToRefs(userStore);
const unfinishedCount = ref(0);
const DEFAULT_AVATAR = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/logo.png';
const avatarUrl = computed(() => userInfo.value.avatarUrl || DEFAULT_AVATAR);
const greeting = computed(() => {
const h = new Date().getHours();
if (h < 6) return '夜深啦,注意休息';
if (h < 11) return '早上好';
if (h < 13) return '中午好';
if (h < 18) return '下午好';
return '晚上好';
});
const tools = [
{ id: 'speak', name: '口算练习', emoji: '🧮', color: 'linear-gradient(135deg,#fde68a 0%,#f59e0b 100%)' },
{ id: 'word', name: '英语单词', emoji: '🅰️', color: 'linear-gradient(135deg,#bfdbfe 0%,#3b82f6 100%)' },
{ id: 'wrong', name: '错题本', emoji: '📕', color: 'linear-gradient(135deg,#fecaca 0%,#ef4444 100%)' },
{ id: 'rank', name: '排行榜', emoji: '🏆', color: 'linear-gradient(135deg,#bbf7d0 0%,#22c55e 100%)' },
];
const onToolClick = (t: any) => {
if (t.id === 'wrong') {
navigate('/pages/wrong/index');
return;
}
uni.showToast({ title: `${t.name} · 即将上线`, icon: 'none' });
};
const navigate = (url: string) => {
if (!token.value) {
uni.navigateTo({ url: `/pages/login/index?redirect=${encodeURIComponent(url)}` });
return;
}
uni.navigateTo({ url });
};
const goLogin = () => {
uni.navigateTo({ url: '/pages/login/index' });
};
const onLogout = () => {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
confirmColor: '#4F46E5',
success: (res) => {
if (res.confirm) userStore.logout();
},
});
};
const fetchUnfinished = async () => {
if (!token.value) {
unfinishedCount.value = 0;
return;
}
try {
const res: any = await getUnfinishedHomework();
const list = Array.isArray(res?.data) ? res.data : Array.isArray(res?.data?.records) ? res.data.records : [];
unfinishedCount.value = list.length || 0;
} catch (e) {
console.warn('fetchUnfinished err', e);
unfinishedCount.value = 0;
}
};
onShow(() => {
if (token.value && getCache('role') === 'teacher') {
uni.reLaunch({ url: '/pages/teacher/index/index' });
return;
}
if (token.value) {
userStore.fetchUserInfo().catch(() => { /* ignore */ });
fetchUnfinished();
} else {
unfinishedCount.value = 0;
}
});
</script>
<style lang="scss" scoped>
.home {
min-height: 100vh;
background: linear-gradient(180deg, #eef2ff 0%, #f5f7ff 50%, #ffffff 100%);
padding: 0 32rpx 60rpx;
box-sizing: border-box;
position: relative;
overflow: hidden;
}
/* 装饰云朵 */
.decor {
position: absolute;
border-radius: 50%;
filter: blur(24rpx);
pointer-events: none;
z-index: 0;
&.decor-1 {
top: 60rpx;
right: -60rpx;
width: 280rpx;
height: 280rpx;
background: radial-gradient(circle, rgba(129, 140, 248, 0.5), transparent 70%);
}
&.decor-2 {
top: 320rpx;
left: -80rpx;
width: 240rpx;
height: 240rpx;
background: radial-gradient(circle, rgba(34, 197, 94, 0.35), transparent 70%);
}
&.decor-3 {
bottom: 120rpx;
right: -100rpx;
width: 320rpx;
height: 320rpx;
background: radial-gradient(circle, rgba(199, 210, 254, 0.6), transparent 70%);
}
}
/* Hero */
.hero {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 0 24rpx;
z-index: 2;
}
.hero-greet {
flex: 1;
}
.hero-hello {
font-size: 26rpx;
color: #6366f1;
font-weight: 500;
}
.hero-title {
font-size: 56rpx;
font-weight: 800;
color: #312e81;
margin-top: 8rpx;
letter-spacing: 2rpx;
.hero-title-ai {
margin-left: 12rpx;
background: linear-gradient(135deg, #4f46e5 0%, #22c55e 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.hero-sub {
font-size: 24rpx;
color: #94a3b8;
margin-top: 8rpx;
}
/* 吉祥物 */
.hero-mascot {
position: relative;
width: 160rpx;
height: 160rpx;
flex-shrink: 0;
}
.mascot-blob {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #818cf8 0%, #4f46e5 100%);
border-radius: 50% 50% 48% 52% / 56% 50% 50% 44%;
box-shadow:
0 16rpx 32rpx rgba(79, 70, 229, 0.35),
inset 0 -10rpx 0 rgba(0, 0, 0, 0.08),
inset 0 8rpx 0 rgba(255, 255, 255, 0.25);
animation: blob 6s ease-in-out infinite;
}
.mascot-face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16rpx;
}
.mascot-eye {
position: absolute;
width: 16rpx;
height: 24rpx;
background: #fff;
border-radius: 50%;
top: 56rpx;
&.left { left: 48rpx; }
&.right { right: 48rpx; }
}
.mascot-smile {
position: absolute;
bottom: 50rpx;
width: 50rpx;
height: 24rpx;
border: 4rpx solid #fff;
border-top: 0;
border-radius: 0 0 50rpx 50rpx;
box-sizing: border-box;
}
@keyframes blob {
0%, 100% { border-radius: 50% 50% 48% 52% / 56% 50% 50% 44%; }
50% { border-radius: 48% 52% 56% 44% / 50% 56% 44% 50%; }
}
/* 用户卡 */
.user-card {
position: relative;
z-index: 2;
display: flex;
align-items: center;
padding: 28rpx 28rpx;
background: #ffffff;
border-radius: 36rpx;
gap: 24rpx;
box-shadow:
0 16rpx 40rpx rgba(99, 102, 241, 0.16),
inset 0 -6rpx 0 rgba(199, 210, 254, 0.5);
}
.user-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.user-avatar {
width: 112rpx;
height: 112rpx;
border-radius: 32rpx;
border: 4rpx solid #ffffff;
box-shadow: 0 6rpx 16rpx rgba(99, 102, 241, 0.25);
background: #eef2ff;
}
.user-badge {
position: absolute;
bottom: -8rpx;
right: -8rpx;
padding: 4rpx 12rpx;
background: linear-gradient(135deg, #fde68a 0%, #f59e0b 100%);
color: #fff;
font-size: 18rpx;
font-weight: 700;
border-radius: 999rpx;
box-shadow: 0 4rpx 8rpx rgba(245, 158, 11, 0.4);
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 32rpx;
font-weight: 700;
color: #312e81;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-school {
margin-top: 8rpx;
display: flex;
align-items: center;
gap: 8rpx;
color: #818cf8;
font-size: 22rpx;
overflow: hidden;
text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.ic {
width: 28rpx;
height: 28rpx;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
}
}
.user-stats {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
.user-stat {
text-align: center;
&-num {
font-size: 32rpx;
font-weight: 800;
color: #4f46e5;
}
&-label {
font-size: 20rpx;
color: #94a3b8;
margin-top: 2rpx;
}
}
.user-stat-divider {
width: 2rpx;
height: 48rpx;
background: #e0e7ff;
}
/* 登录卡 */
.user-card.login-card {
cursor: pointer;
transition: transform 200ms ease;
&:active {
transform: scale(0.98);
}
}
.login-illu {
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 56rpx;
height: 56rpx;
}
}
.login-text {
flex: 1;
}
.login-title {
font-size: 30rpx;
font-weight: 700;
color: #312e81;
}
.login-desc {
font-size: 22rpx;
color: #94a3b8;
margin-top: 8rpx;
}
.login-arrow {
width: 18rpx;
height: 18rpx;
border-right: 4rpx solid #4f46e5;
border-top: 4rpx solid #4f46e5;
transform: rotate(45deg);
flex-shrink: 0;
}
/* Section title */
.section-title {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 14rpx;
margin: 48rpx 0 24rpx;
font-size: 30rpx;
font-weight: 700;
color: #312e81;
}
.section-title-bar {
width: 8rpx;
height: 28rpx;
background: linear-gradient(180deg, #4f46e5 0%, #22c55e 100%);
border-radius: 999rpx;
}
/* 主功能 grid */
.grid {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
}
.grid-card {
position: relative;
border-radius: 36rpx;
padding: 32rpx 28rpx;
height: 240rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
box-shadow:
0 16rpx 40rpx rgba(99, 102, 241, 0.2),
inset 0 -8rpx 0 rgba(0, 0, 0, 0.06),
inset 0 4rpx 0 rgba(255, 255, 255, 0.18);
transition: transform 200ms ease;
&:active {
transform: translateY(4rpx) scale(0.98);
}
&.grid-card-primary {
background: linear-gradient(135deg, #818cf8 0%, #4f46e5 100%);
}
&.grid-card-cta {
background: linear-gradient(135deg, #4ade80 0%, #16a34a 100%);
}
}
.grid-card-decor {
position: absolute;
top: -40rpx;
right: -40rpx;
width: 160rpx;
height: 160rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 50%;
}
.grid-card-icon {
width: 80rpx;
height: 80rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8rpx);
svg {
width: 44rpx;
height: 44rpx;
}
}
.grid-card-info {
color: #ffffff;
}
.grid-card-title {
font-size: 32rpx;
font-weight: 800;
letter-spacing: 1rpx;
}
.grid-card-desc {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.85);
margin-top: 6rpx;
}
.grid-card-badge {
position: absolute;
top: 24rpx;
right: 24rpx;
min-width: 44rpx;
height: 44rpx;
padding: 0 12rpx;
border-radius: 999rpx;
background: #ef4444;
color: #ffffff;
font-size: 22rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(239, 68, 68, 0.4);
}
/* 工具栏 */
.tools {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
padding: 24rpx;
background: #ffffff;
border-radius: 36rpx;
box-shadow:
0 16rpx 40rpx rgba(99, 102, 241, 0.12),
inset 0 -6rpx 0 rgba(199, 210, 254, 0.4);
}
.tool-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
padding: 12rpx 0;
transition: transform 200ms ease;
&:active {
transform: scale(0.92);
}
}
.tool-icon {
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 6rpx 14rpx rgba(0, 0, 0, 0.1),
inset 0 -4rpx 0 rgba(0, 0, 0, 0.1);
}
.tool-icon-emoji {
font-size: 44rpx;
}
.tool-name {
font-size: 22rpx;
color: #475569;
font-weight: 500;
}
/* 退出 */
.logout {
position: relative;
z-index: 2;
margin: 64rpx auto 0;
width: 320rpx;
height: 80rpx;
border-radius: 999rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
color: #ef4444;
font-size: 26rpx;
font-weight: 600;
box-shadow:
0 6rpx 16rpx rgba(239, 68, 68, 0.12),
inset 0 -4rpx 0 rgba(254, 202, 202, 0.5);
transition: transform 150ms ease;
&:active {
transform: scale(0.97);
}
}
.footer {
position: relative;
z-index: 2;
text-align: center;
margin-top: 32rpx;
color: #c7d2fe;
font-size: 22rpx;
}
</style>

550
src/pages/login/index.vue Normal file
View File

@ -0,0 +1,550 @@
<template>
<view class="login">
<!-- 装饰背景 -->
<view class="bg-blob blob-a"></view>
<view class="bg-blob blob-b"></view>
<view class="bg-blob blob-c"></view>
<!-- 主内容 -->
<view class="content">
<!-- 品牌区 -->
<view class="brand">
<view class="brand-logo" @click="onLogoTap">
<view class="brand-logo-glow"></view>
<image :src="logoUrl" mode="aspectFit" class="brand-logo-img" />
</view>
<view class="brand-name">学小乐<text class="brand-name-ai">AI</text></view>
<view class="brand-slogan">智能学习助手 · 让学习更轻松</view>
</view>
<!-- 角色切换 -->
<view class="role-switch">
<view class="role-switch-bg" :class="role"></view>
<view
v-for="item in roleOptions"
:key="item.value"
:class="['role-tab', { active: role === item.value }]"
@click="role = item.value"
>
<text>{{ item.label }}</text>
</view>
</view>
<!-- 表单 -->
<view class="form">
<view class="form-item" :class="{ focus: focus === 'account' }">
<view class="form-icon">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" stroke="#4F46E5" stroke-width="1.6" />
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8" stroke="#4F46E5" stroke-width="1.6"
stroke-linecap="round" />
</svg>
</view>
<input
v-model="account"
class="form-input"
placeholder="请输入账号"
placeholder-class="form-placeholder"
:adjust-position="false"
@focus="focus = 'account'"
@blur="focus = ''"
@confirm="onLogin"
/>
</view>
<view class="form-item" :class="{ focus: focus === 'password' }">
<view class="form-icon">
<svg viewBox="0 0 24 24" fill="none">
<rect x="4" y="11" width="16" height="9" rx="2" stroke="#4F46E5" stroke-width="1.6" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="#4F46E5" stroke-width="1.6" />
</svg>
</view>
<input
v-model="password"
class="form-input"
:password="hidePassword"
placeholder="请输入密码"
placeholder-class="form-placeholder"
:adjust-position="false"
@focus="focus = 'password'"
@blur="focus = ''"
@confirm="onLogin"
/>
<view class="form-eye" @click="hidePassword = !hidePassword">
<svg v-if="hidePassword" viewBox="0 0 24 24" fill="none">
<path d="m2 2 20 20M10 5c.7-.1 1.3-.1 2-.1 7 0 10 7 10 7-.6 1.4-1.5 2.6-2.5 3.6M6.6 6.6C3.5 8.6 2 12 2 12s3 7 10 7c1.4 0 2.6-.3 3.7-.7"
stroke="#94A3B8" stroke-width="1.6" stroke-linecap="round" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z" stroke="#4F46E5"
stroke-width="1.6" />
<circle cx="12" cy="12" r="3" stroke="#4F46E5" stroke-width="1.6" />
</svg>
</view>
</view>
</view>
<view class="agreement">
<text>登录即表示同意</text>
<text class="link">用户协议</text>
<text></text>
<text class="link">隐私政策</text>
</view>
<!-- 登录按钮 -->
<view :class="['btn-login', { loading: submitting }]" @click="onLogin">
<text v-if="!submitting"> </text>
<view v-else class="btn-loader">
<view class="btn-loader-dot"></view>
<view class="btn-loader-dot"></view>
<view class="btn-loader-dot"></view>
</view>
</view>
<view class="hint">
<text>登录账号由学校老师统一发放</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { useUserStore } from '@/state/modules/user';
import { useTeacherStore } from '@/state/modules/teacher';
import { setCache, getCache } from '@/utils/cache';
const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com';
const logoUrl = `${OSS_URL}/urm/logo.png`;
const account = ref('');
const password = ref('');
const hidePassword = ref(true);
const redirect = ref('');
const submitting = ref(false);
const focus = ref<string>('');
type LoginRole = 'student' | 'teacher' | 'parent';
const role = ref<LoginRole>('student');
const enterDebug = ref(0);
const roleOptions: { value: LoginRole; label: string }[] = [
{ value: 'student', label: '学生' },
{ value: 'teacher', label: '老师' },
{ value: 'parent', label: '家长' },
];
const userStore = useUserStore();
const teacherStore = useTeacherStore();
onLoad((options: any) => {
const cachedRole = getCache('role');
if (cachedRole === 'teacher' || cachedRole === 'student' || cachedRole === 'parent') {
role.value = cachedRole;
}
if (options?.redirect) {
redirect.value = decodeURIComponent(options.redirect);
}
if (options?.a) {
account.value = options.a;
}
});
const onLogoTap = () => {
enterDebug.value++;
if (enterDebug.value >= 8) {
enterDebug.value = 0;
uni.showToast({ title: 'Debug 模式', icon: 'none' });
}
};
const getLoginTargetUrl = () => {
const teacherHome = '/pages/teacher/index/index';
const parentHome = '/pages/parent/home/index';
const studentHome = '/pages/index/index';
const value = redirect.value.trim();
if (role.value === 'teacher') {
if (!value) return teacherHome;
if (value === 'teacher-home' || value === '/pages/teacher') return teacherHome;
return value.startsWith('/pages/teacher/') ? value : teacherHome;
}
if (role.value === 'parent') {
if (!value) return parentHome;
if (value === 'parent-home' || value === '/pages/parent') return parentHome;
return value.startsWith('/pages/parent/') ? value : parentHome;
}
if (!value) return studentHome;
if (value === 'home') return studentHome;
return value.startsWith('/pages/teacher') || value.startsWith('/pages/parent') ? studentHome : value;
};
const onLogin = async () => {
if (submitting.value) return;
if (role.value === 'parent') {
setCache('role', 'parent');
uni.reLaunch({ url: '/pages/parent/home/index' });
return;
}
if (!account.value.trim()) {
uni.showToast({ title: '请输入账号', icon: 'none' });
return;
}
if (!password.value) {
uni.showToast({ title: '请输入密码', icon: 'none' });
return;
}
submitting.value = true;
uni.showLoading({ title: '登录中...', mask: true });
try {
const data: any = {
account: account.value.trim(),
password: password.value,
};
if (role.value === 'teacher') {
data.clientType = 'MANAGE';
}
await userStore.accountLogin(data);
setCache('role', role.value);
uni.hideLoading();
uni.showToast({ title: '登录成功', icon: 'success' });
setTimeout(async () => {
if (role.value === 'teacher') {
try {
await teacherStore.getLoginUser();
} catch (e) {
console.warn('teacher.getLoginUser err', e);
}
}
const targetUrl = getLoginTargetUrl();
if (
targetUrl === '/pages/index/index'
|| targetUrl === '/pages/teacher/index/index'
|| targetUrl === '/pages/parent/home/index'
) {
uni.reLaunch({ url: targetUrl });
} else {
uni.redirectTo({ url: targetUrl });
}
}, 800);
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e?.message || '登录失败', icon: 'none' });
} finally {
submitting.value = false;
}
};
</script>
<style lang="scss" scoped>
.login {
min-height: 100vh;
background: linear-gradient(180deg, #eef2ff 0%, #c7d2fe 100%);
box-sizing: border-box;
position: relative;
overflow: hidden;
padding: 0 56rpx 80rpx;
}
/* 背景装饰 */
.bg-blob {
position: absolute;
border-radius: 50%;
filter: blur(40rpx);
pointer-events: none;
z-index: 0;
}
.blob-a {
top: -120rpx;
right: -120rpx;
width: 400rpx;
height: 400rpx;
background: radial-gradient(circle, rgba(129, 140, 248, 0.7), transparent 70%);
}
.blob-b {
top: 280rpx;
left: -160rpx;
width: 360rpx;
height: 360rpx;
background: radial-gradient(circle, rgba(34, 197, 94, 0.4), transparent 70%);
}
.blob-c {
bottom: -80rpx;
right: -80rpx;
width: 380rpx;
height: 380rpx;
background: radial-gradient(circle, rgba(254, 215, 170, 0.5), transparent 70%);
}
/* 主体 */
.content {
position: relative;
z-index: 2;
padding-top: 80rpx;
}
/* 品牌 */
.brand {
text-align: center;
margin-bottom: 60rpx;
}
.brand-logo {
position: relative;
width: 160rpx;
height: 160rpx;
margin: 0 auto 28rpx;
}
.brand-logo-glow {
position: absolute;
inset: -16rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.9), transparent 70%);
filter: blur(8rpx);
}
.brand-logo-img {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
border-radius: 48rpx;
background: #ffffff;
box-shadow:
0 12rpx 32rpx rgba(99, 102, 241, 0.3),
inset 0 -6rpx 0 rgba(0, 0, 0, 0.06);
}
.brand-name {
font-size: 56rpx;
font-weight: 800;
color: #312e81;
letter-spacing: 2rpx;
.brand-name-ai {
margin-left: 8rpx;
background: linear-gradient(135deg, #4f46e5 0%, #22c55e 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.brand-slogan {
font-size: 24rpx;
color: #6366f1;
margin-top: 12rpx;
}
/* 角色切换 */
.role-switch {
position: relative;
height: 88rpx;
margin-bottom: 40rpx;
background: rgba(255, 255, 255, 0.7);
border-radius: 999rpx;
padding: 8rpx;
display: flex;
box-shadow: inset 0 4rpx 8rpx rgba(99, 102, 241, 0.12);
}
.role-switch-bg {
position: absolute;
top: 8rpx;
left: 8rpx;
width: calc(33.333% - 8rpx);
height: calc(100% - 16rpx);
background: linear-gradient(180deg, #818cf8 0%, #4f46e5 100%);
border-radius: 999rpx;
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 6rpx 14rpx rgba(79, 70, 229, 0.35),
inset 0 -4rpx 0 rgba(0, 0, 0, 0.12);
&.teacher {
transform: translateX(100%);
background: linear-gradient(180deg, #4ade80 0%, #16a34a 100%);
box-shadow:
0 6rpx 14rpx rgba(22, 163, 74, 0.35),
inset 0 -4rpx 0 rgba(0, 0, 0, 0.12);
}
&.parent {
transform: translateX(200%);
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow:
0 6rpx 14rpx rgba(8, 145, 178, 0.35),
inset 0 -4rpx 0 rgba(0, 0, 0, 0.12);
}
}
.role-tab {
position: relative;
z-index: 1;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
font-size: 28rpx;
font-weight: 600;
color: #94a3b8;
transition: color 200ms ease;
&.active {
color: #ffffff;
}
}
/* 表单 */
.form {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.form-item {
display: flex;
align-items: center;
height: 104rpx;
padding: 0 28rpx;
background: #ffffff;
border-radius: 28rpx;
gap: 16rpx;
transition: box-shadow 200ms ease, transform 150ms ease;
box-shadow:
0 8rpx 20rpx rgba(99, 102, 241, 0.1),
inset 0 -4rpx 0 rgba(199, 210, 254, 0.5);
&.focus {
box-shadow:
0 0 0 4rpx rgba(129, 140, 248, 0.4),
0 8rpx 24rpx rgba(99, 102, 241, 0.18),
inset 0 -4rpx 0 rgba(199, 210, 254, 0.5);
}
}
.form-icon {
width: 44rpx;
height: 44rpx;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
}
}
.form-input {
flex: 1;
height: 100%;
font-size: 30rpx;
color: #312e81;
font-weight: 500;
}
.form-placeholder {
color: #94a3b8;
font-size: 28rpx;
}
.form-eye {
width: 56rpx;
height: 56rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 36rpx;
height: 36rpx;
}
&:active {
background: #eef2ff;
}
}
/* 协议 */
.agreement {
margin-top: 32rpx;
text-align: center;
font-size: 22rpx;
color: #94a3b8;
.link {
color: #4f46e5;
}
}
/* 登录按钮 */
.btn-login {
margin-top: 24rpx;
height: 104rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #818cf8 0%, #4f46e5 100%);
color: #ffffff;
font-size: 32rpx;
font-weight: 700;
letter-spacing: 8rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 12rpx 28rpx rgba(79, 70, 229, 0.4),
inset 0 -6rpx 0 rgba(0, 0, 0, 0.15),
inset 0 4rpx 0 rgba(255, 255, 255, 0.2);
transition: transform 150ms ease, box-shadow 150ms ease;
&:active {
transform: translateY(4rpx);
box-shadow:
0 4rpx 12rpx rgba(79, 70, 229, 0.4),
inset 0 -3rpx 0 rgba(0, 0, 0, 0.15),
inset 0 4rpx 0 rgba(255, 255, 255, 0.2);
}
&.loading {
opacity: 0.85;
}
}
.btn-loader {
display: flex;
gap: 12rpx;
&-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #ffffff;
animation: bounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
}
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.5); }
40% { transform: scale(1); }
}
.hint {
margin-top: 32rpx;
text-align: center;
color: #94a3b8;
font-size: 22rpx;
}
</style>

View File

@ -0,0 +1,488 @@
<template>
<ParentPage active="reports" :show-tab="false" :use-scroll-view="false">
<view class="filter-row">
<picker class="filter-picker" :range="subjectOptions" :value="subjectIndex" @change="onSubjectChange">
<view class="filter-pill">
<text>{{ subjectOptions[subjectIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
<picker class="filter-picker" :range="gradeOptions" :value="gradeIndex" @change="onGradeChange">
<view class="filter-pill">
<text>{{ gradeOptions[gradeIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
<picker class="filter-picker" :range="categoryOptions" :value="categoryIndex" @change="onCategoryChange">
<view class="filter-pill">
<text>{{ categoryOptions[categoryIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
</view>
<view class="module-section">
<ParentCard title="错题知识点占比" subtitle="按错题占比识别最近需要优先补强的知识点">
<view v-if="filteredKnowledge.length" class="ratio-layout">
<view class="ratio-main">
<view class="ratio-total">
<text class="ratio-number">{{ mainWrongPercent }}%</text>
<text class="ratio-label">最高错题占比</text>
</view>
<view
v-for="item in filteredKnowledge"
:key="`bar-${item.name}`"
class="ratio-bar"
>
<view class="ratio-fill" :class="item.tone" :style="{ width: `${item.wrongPercent}%` }"></view>
</view>
</view>
<view class="legend-list">
<view v-for="item in filteredKnowledge" :key="item.name" class="legend-item">
<view class="legend-dot" :class="item.tone"></view>
<text class="legend-name ellipsis">{{ item.name }}</text>
<text class="legend-percent">{{ item.wrongPercent }}%</text>
</view>
</view>
</view>
<xy-Empty v-else type="task" content="暂无符合条件的知识点数据" />
</ParentCard>
</view>
<view class="module-section">
<ParentCard title="知识点得分" subtitle="可切换得分等级,快速定位当前薄弱段">
<view class="level-tabs">
<view
v-for="level in scoreLevels"
:key="level.key"
class="level-tab"
:class="{ active: activeLevel === level.key }"
@click="activeLevel = level.key"
>
{{ level.label }}
</view>
</view>
<view v-if="levelKnowledge.length" class="score-table">
<view class="score-row table-head">
<text>知识点</text>
<text>满分</text>
<text>得分</text>
</view>
<view v-for="item in levelKnowledge" :key="`${item.name}-${activeLevel}`" class="score-row">
<text class="knowledge-name ellipsis">{{ item.name }}</text>
<text>{{ item.fullScore }}</text>
<text class="score-value">{{ item.score }}<text class="score-rate">({{ scoreRate(item) }}%)</text></text>
</view>
</view>
<xy-Empty v-else type="learn" content="当前等级暂无知识点" />
</ParentCard>
</view>
<view class="module-section">
<ParentCard title="得分能力" subtitle="综合最近多次练习得分,观察能力变化和稳定性">
<view class="ucharts-wrap">
<qiun-data-charts
class="ability-chart"
type="line"
canvasId="parentAbilityTrendCanvas2dLineChart"
background="rgba(255,255,255,0)"
:canvas2d="true"
:in-scroll-view="false"
:ontouch="false"
:disable-scroll="false"
:reshow="chartReshow"
:opts="abilityChartOpts"
:chartData="abilityChartData"
/>
</view>
<view class="summary-grid">
<view v-for="item in abilitySummary" :key="item.label" class="summary-item">
<text class="summary-label">{{ item.label }}</text>
<text class="summary-value">{{ item.value }}</text>
<text class="summary-desc">{{ item.desc }}</text>
</view>
</view>
</ParentCard>
</view>
</ParentPage>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onReady, onShow } from '@dcloudio/uni-app';
import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue';
import {
abilitySummary,
abilityTrend,
categoryOptions,
gradeOptions,
knowledgeWeakPoints,
subjectOptions,
type KnowledgeWeakPoint,
} from '../utils/data';
type ScoreLevel = 'weak' | 'pass' | 'good' | 'excellent';
const subjectIndex = ref(0);
const gradeIndex = ref(1);
const categoryIndex = ref(0);
const activeLevel = ref<ScoreLevel>('weak');
const scoreLevels: { key: ScoreLevel; label: string }[] = [
{ key: 'weak', label: '薄弱' },
{ key: 'pass', label: '合格' },
{ key: 'good', label: '良好' },
{ key: 'excellent', label: '优秀' },
];
const filteredKnowledge = computed(() => {
const subject = subjectOptions[subjectIndex.value];
const grade = gradeOptions[gradeIndex.value];
const category = categoryOptions[categoryIndex.value];
return knowledgeWeakPoints.filter((item) => {
const subjectMatched = subject === '全部学科' || item.subject === subject;
const gradeMatched = grade === '全部年级' || item.grade === grade;
const categoryMatched = category === '全部类型' || item.category === category;
return subjectMatched && gradeMatched && categoryMatched;
});
});
const mainWrongPercent = computed(() => Math.max(...filteredKnowledge.value.map((item) => item.wrongPercent), 0));
const scoreRate = (item: KnowledgeWeakPoint) => Math.round((item.score / item.fullScore) * 100);
const levelOf = (item: KnowledgeWeakPoint): ScoreLevel => {
const rate = scoreRate(item);
if (rate < 60) return 'weak';
if (rate < 75) return 'pass';
if (rate < 90) return 'good';
return 'excellent';
};
const levelKnowledge = computed(() => filteredKnowledge.value.filter((item) => levelOf(item) === activeLevel.value));
const abilityChartData = ref({});
const chartReshow = ref(false);
const buildAbilityChartData = () => ({
categories: abilityTrend.map((item) => item.date),
series: [
{
name: '得分能力',
data: abilityTrend.map((item) => item.score),
color: '#0891b2',
},
],
});
const fillChart = () => {
abilityChartData.value = JSON.parse(JSON.stringify(buildAbilityChartData()));
chartReshow.value = !chartReshow.value;
};
onReady(() => {
setTimeout(fillChart, 500);
});
onShow(() => {
if (Object.keys(abilityChartData.value).length) {
chartReshow.value = !chartReshow.value;
}
});
const abilityChartOpts = {
color: ['#0891b2'],
padding: [18, 12, 0, 12],
enableScroll: false,
legend: { show: false },
xAxis: {
disableGrid: false,
gridColor: '#eef2f7',
fontColor: '#64748b',
fontSize: 10,
boundaryGap: 'justify',
},
yAxis: {
gridType: 'dash',
dashLength: 2,
gridColor: '#eef2f7',
data: [
{
min: 40,
max: 100,
splitNumber: 4,
fontColor: '#94a3b8',
fontSize: 10,
},
],
},
extra: {
line: {
type: 'curve',
width: 3,
activeType: 'hollow',
},
markLine: {
type: 'dash',
dashLength: 6,
data: [
{ value: 85, lineColor: 'rgba(244, 63, 94, 0.32)', showLabel: true, labelText: '优秀' },
{ value: 70, lineColor: 'rgba(244, 63, 94, 0.28)', showLabel: true, labelText: '良好' },
{ value: 60, lineColor: 'rgba(244, 63, 94, 0.22)', showLabel: true, labelText: '一般' },
],
},
},
};
const onSubjectChange = (e: any) => { subjectIndex.value = Number(e.detail.value); };
const onGradeChange = (e: any) => { gradeIndex.value = Number(e.detail.value); };
const onCategoryChange = (e: any) => { categoryIndex.value = Number(e.detail.value); };
</script>
<style lang="scss" scoped>
.filter-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
padding-top: 12rpx;
}
.filter-picker {
min-width: 0;
}
.filter-pill {
height: 72rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
color: #164e63;
font-size: 24rpx;
font-weight: 800;
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 8rpx 22rpx rgba(8, 145, 178, 0.08),
inset 0 -2rpx 0 rgba(207, 250, 254, 0.9);
}
.filter-arrow {
width: 0;
height: 0;
border-left: 8rpx solid transparent;
border-right: 8rpx solid transparent;
border-top: 10rpx solid #0891b2;
flex-shrink: 0;
}
.module-section {
margin-top: 44rpx;
}
.filter-row + .module-section {
margin-top: 36rpx;
}
.ratio-layout {
display: flex;
align-items: center;
gap: 28rpx;
}
.ratio-main {
width: 232rpx;
flex-shrink: 0;
}
.ratio-total {
min-height: 116rpx;
border-radius: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #ecfeff 0%, #ffffff 100%);
box-shadow: inset 0 -2rpx 0 rgba(34, 211, 238, 0.24);
}
.ratio-number {
color: #164e63;
font-size: 42rpx;
font-weight: 900;
}
.ratio-label {
margin-top: 2rpx;
color: #64748b;
font-size: 20rpx;
}
.ratio-bar {
height: 16rpx;
margin-top: 14rpx;
border-radius: 999rpx;
overflow: hidden;
background: #e2e8f0;
}
.ratio-fill {
height: 100%;
border-radius: 999rpx;
&.blue { background: #3b82f6; }
&.orange { background: #f97316; }
&.green { background: #22c55e; }
&.rose { background: #f43f5e; }
}
.legend-list {
flex: 1;
min-width: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 12rpx;
min-height: 46rpx;
color: #164e63;
font-size: 24rpx;
font-weight: 700;
}
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
&.blue { background: #3b82f6; }
&.orange { background: #f97316; }
&.green { background: #22c55e; }
&.rose { background: #f43f5e; }
}
.legend-name {
flex: 1;
}
.legend-percent {
color: #0891b2;
}
.level-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14rpx;
margin-bottom: 20rpx;
}
.level-tab {
height: 64rpx;
border: 2rpx solid #e2e8f0;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
color: #475569;
background: #ffffff;
font-size: 24rpx;
font-weight: 800;
&.active {
border-color: #0891b2;
color: #ffffff;
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow: 0 10rpx 20rpx rgba(8, 145, 178, 0.2);
}
}
.score-table {
border: 2rpx solid #e2e8f0;
border-radius: 24rpx;
overflow: hidden;
}
.score-row {
display: grid;
grid-template-columns: 1fr 112rpx 150rpx;
align-items: center;
min-height: 76rpx;
padding: 0 22rpx;
color: #164e63;
font-size: 24rpx;
text-align: center;
border-top: 2rpx solid #f1f5f9;
&:first-child {
border-top: 0;
}
}
.table-head {
color: #475569;
background: #f8fafc;
font-weight: 900;
}
.knowledge-name {
text-align: left;
}
.score-value {
color: #ea580c;
font-weight: 900;
}
.score-rate {
margin-left: 2rpx;
font-size: 22rpx;
font-weight: 700;
}
.ucharts-wrap {
width: 100%;
height: 360rpx;
margin-top: 4rpx;
overflow: hidden;
}
.ability-chart {
width: 100%;
height: 100%;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-top: 26rpx;
}
.summary-item {
padding: 18rpx;
border-radius: 24rpx;
background: #f8fafc;
text-align: center;
}
.summary-label,
.summary-desc {
display: block;
color: #64748b;
font-size: 22rpx;
line-height: 1.45;
}
.summary-value {
display: block;
margin: 8rpx 0;
color: #0891b2;
font-size: 32rpx;
font-weight: 900;
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<view class="parent-card" :class="{ clickable }" @click="emit('click')">
<view v-if="title || subtitle" class="card-head">
<view>
<text v-if="title" class="card-title">{{ title }}</text>
<text v-if="subtitle" class="card-subtitle">{{ subtitle }}</text>
</view>
<slot name="extra"></slot>
</view>
<slot></slot>
</view>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
title?: string;
subtitle?: string;
clickable?: boolean;
}>(),
{
title: '',
subtitle: '',
clickable: false,
},
);
//
const emit = defineEmits<{
(event: 'click'): void;
}>();
</script>
<style lang="scss" scoped>
.parent-card {
padding: 28rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 14rpx 36rpx rgba(8, 145, 178, 0.08),
inset 0 -3rpx 0 rgba(207, 250, 254, 0.8);
}
.clickable:active {
transform: translateY(2rpx);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 22rpx;
}
.card-title {
display: block;
color: #164e63;
font-size: 32rpx;
font-weight: 900;
}
.card-subtitle {
display: block;
margin-top: 4rpx;
color: #64748b;
font-size: 24rpx;
}
</style>

View File

@ -0,0 +1,92 @@
<template>
<view class="parent-page" :class="{ 'native-scroll': !useScrollView }">
<view class="decor decor-a"></view>
<view class="decor decor-b"></view>
<scroll-view v-if="useScrollView" class="page-scroll" scroll-y enhanced :show-scrollbar="false">
<view class="page-content" :class="{ 'with-tab': showTab }">
<slot></slot>
</view>
</scroll-view>
<view v-else class="page-scroll native-page-scroll">
<view class="page-content" :class="{ 'with-tab': showTab }">
<slot></slot>
</view>
</view>
<ParentTabBar v-if="showTab" :active="active" />
</view>
</template>
<script setup lang="ts">
import ParentTabBar from './ParentTabBar.vue';
//
withDefaults(
defineProps<{
active?: 'home' | 'reports' | 'profile';
showTab?: boolean;
useScrollView?: boolean;
}>(),
{
active: 'home',
showTab: true,
useScrollView: true,
},
);
</script>
<style lang="scss" scoped>
.parent-page {
position: relative;
height: 100vh;
overflow: hidden;
background: linear-gradient(180deg, #ecfeff 0%, #f5f7ff 54%, #ffffff 100%);
}
.native-scroll {
min-height: 100vh;
height: auto;
overflow: visible;
}
.decor {
position: absolute;
border-radius: 50%;
filter: blur(64rpx);
pointer-events: none;
}
.decor-a {
width: 360rpx;
height: 360rpx;
top: 90rpx;
right: -150rpx;
background: rgba(34, 211, 238, 0.32);
}
.decor-b {
width: 300rpx;
height: 300rpx;
left: -120rpx;
bottom: 170rpx;
background: rgba(34, 197, 94, 0.16);
}
.page-scroll {
position: relative;
z-index: 1;
height: 100%;
}
.native-page-scroll {
min-height: 100vh;
height: auto;
}
.page-content {
padding: 24rpx 28rpx 0;
&.with-tab {
padding-bottom: calc(150rpx + env(safe-area-inset-bottom));
}
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<view class="parent-tab">
<view
v-for="item in tabs"
:key="item.key"
class="tab-item"
:class="{ active: item.key === props.active }"
@click="goPage(item)"
>
<view class="tab-icon">
<text>{{ item.icon }}</text>
</view>
<text class="tab-label">{{ item.label }}</text>
</view>
</view>
</template>
<script setup lang="ts">
type TabKey = 'home' | 'reports' | 'profile';
const props = defineProps<{
active: TabKey;
}>();
const tabs: { key: TabKey; label: string; icon: string; url: string }[] = [
{ key: 'home', label: '首页', icon: '首', url: '/pages/parent/home/index' },
{ key: 'reports', label: '考试报告', icon: '报', url: '/pages/parent/reports/index' },
{ key: 'profile', label: '个人中心', icon: '我', url: '/pages/parent/profile/index' },
];
// tab redirectTo
const goPage = (item: (typeof tabs)[number]) => {
if (item.key === props.active) return;
uni.redirectTo({ url: item.url });
};
</script>
<style lang="scss" scoped>
.parent-tab {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
height: calc(112rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
display: grid;
grid-template-columns: repeat(3, 1fr);
background: rgba(255, 255, 255, 0.94);
border-top: 2rpx solid rgba(14, 116, 144, 0.08);
box-shadow: 0 -12rpx 36rpx rgba(15, 118, 110, 0.08);
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
color: #64748b;
}
.tab-icon {
width: 44rpx;
height: 44rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 900;
background: #f8fafc;
}
.tab-label {
font-size: 22rpx;
font-weight: 600;
}
.tab-item.active {
color: #0891b2;
.tab-icon {
color: #ffffff;
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow: 0 8rpx 18rpx rgba(8, 145, 178, 0.28);
}
}
</style>

View File

@ -0,0 +1,304 @@
<template>
<ParentPage active="home">
<view class="profile">
<view class="avatar">{{ activeChild.name.slice(0, 1) }}</view>
<view class="profile-main">
<view class="name-row">
<text class="child-name">{{ activeChild.name }}家长</text>
<text class="vip-tag">家长会员</text>
</view>
<text class="school ellipsis">{{ activeChild.school }} · {{ activeChild.className }}</text>
<text class="exam-no">考号{{ activeChild.examNo }}</text>
</view>
</view>
<view class="grade-card">
<ParentCard>
<view v-for="band in gradeBands" :key="band.level" class="band-row">
<view class="band-badge" :class="band.level">{{ band.label.slice(0, 2) }}</view>
<view v-if="band.subjects.length" class="subject-list">
<view v-for="item in band.subjects" :key="`${band.level}-${item.subject}`" class="subject-chip">
<text>{{ item.subject }}</text>
<text class="trend" :class="item.status">{{ trendText(item.status) }}</text>
</view>
</view>
<text v-else class="empty-text">暂无需要关注的学科</text>
</view>
</ParentCard>
</view>
<view class="member-card">
<ParentCard clickable @click="showComingSoon('家长会员')">
<view class="member-tag">家长会员</view>
<view class="member-main">
<view>
<text class="member-title">心理关怀 · 习惯规划 · 深度学情</text>
<text class="member-desc">不只看分数陪孩子建立稳定学习节奏</text>
</view>
<text class="member-arrow">查看</text>
</view>
</ParentCard>
</view>
<view class="read-card">
<ParentCard title="本周家长必读" subtitle="系统按孩子最近表现生成关注建议">
<text class="read-text">数学周练后情绪略低建议先肯定一次认真订正再安排一条免费话术在家长关怀</text>
<view class="read-link" @click="showComingSoon('家长关怀')">进入家长关怀中心</view>
</ParentCard>
</view>
<view class="quick-grid">
<ParentCard v-for="item in quickActions" :key="item.title" class="quick-card" clickable @click="handleQuickAction(item)">
<view class="quick-icon" :class="item.tone">{{ item.title.slice(0, 1) }}</view>
<text class="quick-title">{{ item.title }}</text>
<text class="quick-desc">{{ item.desc }}</text>
</ParentCard>
</view>
</ParentPage>
</template>
<script setup lang="ts">
import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue';
import { gradeBands, parentChildren, quickActions, type QuickAction, type SubjectTrend } from '../utils/data';
const activeChild = parentChildren[0];
const trendText = (status: SubjectTrend['status']) => {
const map: Record<SubjectTrend['status'], string> = {
up: '进步',
down: '退步',
stable: '稳定',
};
return map[status];
};
const showComingSoon = (name: string) => {
uni.showToast({
title: `${name}建设中`,
icon: 'none',
});
};
const handleQuickAction = (item: QuickAction) => {
if (item.title === '学情追踪') {
uni.navigateTo({ url: '/pages/parent/analysis/index' });
return;
}
if (item.title === '错题练习') {
uni.navigateTo({ url: '/pages/wrong/index' });
return;
}
showComingSoon(item.title);
};
</script>
<style lang="scss" scoped>
.profile {
display: flex;
align-items: center;
gap: 22rpx;
padding: 30rpx 6rpx 18rpx;
}
.avatar {
width: 108rpx;
height: 108rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 40rpx;
font-weight: 900;
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow: 0 12rpx 28rpx rgba(8, 145, 178, 0.24);
}
.profile-main {
flex: 1;
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.child-name {
color: #164e63;
font-size: 38rpx;
font-weight: 900;
}
.vip-tag,
.member-tag {
display: inline-flex;
align-items: center;
padding: 6rpx 14rpx;
border-radius: 999rpx;
color: #92400e;
background: #fef3c7;
font-size: 22rpx;
font-weight: 800;
}
.school,
.exam-no {
display: block;
margin-top: 6rpx;
color: #64748b;
font-size: 24rpx;
}
.grade-card,
.member-card,
.read-card {
margin-top: 40rpx;
}
.band-row {
display: flex;
align-items: center;
gap: 18rpx;
min-height: 70rpx;
}
.band-row + .band-row {
margin-top: 18rpx;
}
.band-badge {
width: 58rpx;
height: 58rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 22rpx;
font-weight: 900;
&.excellent { background: #22c55e; }
&.good { background: #3b82f6; }
&.middle { background: #f59e0b; }
&.weak { background: #ef4444; }
}
.subject-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.subject-chip {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 16rpx;
border-radius: 999rpx;
color: #164e63;
background: #f8fafc;
font-size: 24rpx;
font-weight: 700;
}
.trend {
padding: 2rpx 8rpx;
border-radius: 999rpx;
color: #ffffff;
font-size: 18rpx;
&.up { background: #22c55e; }
&.down { background: #f43f5e; }
&.stable { background: #0891b2; }
}
.empty-text {
color: #94a3b8;
font-size: 24rpx;
}
.member-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(236, 254, 255, 0.94));
}
.member-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin-top: 20rpx;
}
.member-title {
display: block;
color: #164e63;
font-size: 30rpx;
font-weight: 900;
}
.member-desc,
.read-text {
display: block;
margin-top: 8rpx;
color: #64748b;
font-size: 24rpx;
line-height: 1.6;
}
.member-arrow,
.read-link {
color: #0891b2;
font-size: 24rpx;
font-weight: 800;
}
.read-link {
margin-top: 20rpx;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18rpx;
margin-top: 40rpx;
}
.quick-card {
padding: 24rpx 14rpx;
text-align: center;
}
.quick-icon {
width: 72rpx;
height: 72rpx;
margin: 0 auto 14rpx;
border-radius: 26rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30rpx;
font-weight: 900;
&.cyan { background: #22d3ee; }
&.green { background: #22c55e; }
&.orange { background: #f59e0b; }
}
.quick-title {
display: block;
color: #164e63;
font-size: 26rpx;
font-weight: 900;
}
.quick-desc {
display: block;
margin-top: 6rpx;
color: #64748b;
font-size: 20rpx;
}
</style>

View File

@ -0,0 +1,217 @@
<template>
<ParentPage active="profile">
<view class="account-card">
<ParentCard>
<view class="account-row">
<view class="account-avatar"></view>
<view class="account-main">
<text class="phone">138****4546</text>
<text class="current-child">当前孩子{{ activeChild.name }} · {{ activeChild.className }}</text>
</view>
</view>
</ParentCard>
</view>
<view class="children-card">
<ParentCard title="孩子管理" subtitle="切换后首页与报告将默认展示该孩子">
<view class="child-grid">
<view
v-for="(child, index) in parentChildren"
:key="child.id"
class="child-card"
:class="{ active: index === activeChildIndex }"
@click="selectChild(index)"
>
<text class="child-name">{{ child.name }}</text>
<text class="child-account">{{ child.account }}</text>
</view>
</view>
<view class="bind-btn" @click="bindChild">+ 绑定孩子账号</view>
</ParentCard>
</view>
<view class="menu-card">
<ParentCard>
<view class="menu-item" @click="showAbout">
<text>关于我们</text>
<text class="menu-arrow">查看</text>
</view>
<view class="menu-line"></view>
<view class="menu-item danger" @click="handleLogout">
<text>退出登录</text>
</view>
</ParentCard>
</view>
</ParentPage>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useUserStore } from '@/state/modules/user';
import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue';
import { parentChildren } from '../utils/data';
const userStore = useUserStore();
const activeChildIndex = ref(0);
const activeChild = computed(() => parentChildren[activeChildIndex.value]);
const selectChild = (index: number) => {
activeChildIndex.value = index;
uni.showToast({
title: `已切换到${parentChildren[index].name}`,
icon: 'none',
});
};
const bindChild = () => {
uni.showToast({
title: '绑定功能建设中',
icon: 'none',
});
};
const showAbout = () => {
uni.showModal({
title: '关于学小乐AI',
content: '学小乐AI为学生、老师和家长提供学习过程分析与成长陪伴。',
showCancel: false,
});
};
const handleLogout = () => {
uni.showModal({
title: '提示',
content: '确定退出家长端吗?',
success: async (res) => {
if (!res.confirm) return;
await userStore.logout();
},
});
};
</script>
<style lang="scss" scoped>
.account-card {
margin-top: 28rpx;
}
.account-row {
display: flex;
align-items: center;
gap: 24rpx;
}
.account-avatar {
width: 112rpx;
height: 112rpx;
border-radius: 38rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 40rpx;
font-weight: 900;
background: linear-gradient(180deg, #67e8f9 0%, #0891b2 100%);
box-shadow: 0 12rpx 26rpx rgba(8, 145, 178, 0.22);
}
.account-main {
flex: 1;
min-width: 0;
}
.phone {
display: block;
color: #164e63;
font-size: 38rpx;
font-weight: 900;
}
.current-child {
display: block;
margin-top: 8rpx;
color: #64748b;
font-size: 24rpx;
}
.children-card,
.menu-card {
margin-top: 40rpx;
}
.child-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.child-card {
padding: 20rpx;
border-radius: 24rpx;
border: 2rpx solid #e2e8f0;
background: #f8fafc;
&.active {
border-color: #0891b2;
background: #ecfeff;
box-shadow: inset 0 -3rpx 0 rgba(34, 211, 238, 0.3);
}
}
.child-name {
display: block;
color: #164e63;
font-size: 28rpx;
font-weight: 900;
}
.child-account {
display: block;
margin-top: 4rpx;
color: #94a3b8;
font-size: 22rpx;
}
.bind-btn {
height: 78rpx;
margin-top: 18rpx;
border-radius: 24rpx;
border: 2rpx dashed #67e8f9;
display: flex;
align-items: center;
justify-content: center;
color: #0891b2;
font-size: 26rpx;
font-weight: 900;
}
.menu-card {
padding-top: 16rpx;
padding-bottom: 16rpx;
}
.menu-item {
min-height: 82rpx;
display: flex;
align-items: center;
justify-content: space-between;
color: #164e63;
font-size: 28rpx;
font-weight: 700;
&.danger {
color: #ef4444;
}
}
.menu-arrow {
color: #94a3b8;
font-size: 24rpx;
}
.menu-line {
height: 2rpx;
background: #f1f5f9;
}
</style>

View File

@ -0,0 +1,774 @@
<template>
<ParentPage active="reports" :show-tab="false">
<view class="detail-page">
<ParentCard class="hero-card">
<view class="hero-top">
<view>
<text class="category-tag">{{ report.category }}</text>
<text class="report-title">{{ report.title }}</text>
<text class="report-date">{{ report.date }}</text>
</view>
<text class="subject-text">{{ report.subject }}</text>
</view>
<view class="score-grid">
<view v-for="item in detail.scoreBlocks" :key="item.label" class="score-item">
<text class="score-label">{{ item.label }}</text>
<text class="score-value">{{ item.value }}</text>
<text class="score-desc">{{ item.desc }}</text>
</view>
</view>
</ParentCard>
<ParentCard class="nav-card">
<view class="tab-row">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
</view>
</view>
<view class="rank-grid">
<view v-for="item in detail.ranks" :key="item.label" class="rank-item">
<text class="rank-label">{{ item.label }}</text>
<text class="rank-value">{{ item.value }}</text>
</view>
</view>
</ParentCard>
<template v-if="activeTab === 'analysis'">
<ParentCard class="section-card" title="题型得分" subtitle="按题型拆解本次卷面得分">
<view class="data-table">
<view class="table-row table-head">
<text>题号</text>
<text>题型</text>
<text>得分</text>
<text>得分率</text>
</view>
<view v-for="row in detail.analysisRows" :key="row.range" class="table-row">
<text>{{ row.range }}</text>
<text>{{ row.type }}</text>
<text class="strong">{{ row.score }}</text>
<text class="rate-good">{{ row.rate }}</text>
</view>
</view>
</ParentCard>
<ParentCard class="section-card" title="难度得分" subtitle="关注中难题稳定性,决定后续练习重点">
<view class="data-table">
<view class="table-row table-head">
<text>难度</text>
<text>题号</text>
<text>得分</text>
<text>得分率</text>
</view>
<view v-for="row in detail.difficultyRows" :key="row.difficulty" class="table-row">
<text>{{ row.difficulty }}</text>
<text>{{ row.range }}</text>
<text class="strong">{{ row.score }}</text>
<text :class="row.rate === '100%' ? 'rate-good' : 'rate-warn'">{{ row.rate }}</text>
</view>
</view>
</ParentCard>
</template>
<template v-else-if="activeTab === 'answers'">
<ParentCard class="section-card" title="答题卡详情" subtitle="点击题号查看每道题的答题情况">
<view class="answer-legend">
<view><text class="legend-dot correct"></text>正确</view>
<view><text class="legend-dot wrong"></text>错误</view>
</view>
<view class="question-grid">
<view
v-for="question in detail.questions"
:key="question.no"
class="question-no"
:class="[question.status, { active: selectedNo === question.no }]"
@click="selectedNo = question.no"
>
{{ question.no }}
</view>
</view>
</ParentCard>
<ParentCard class="section-card" title="当前题目详情">
<view class="question-head">
<text> {{ selectedQuestion.no }} </text>
<text>{{ selectedQuestion.type }}</text>
<text :class="selectedQuestion.status === 'correct' ? 'status-correct' : 'status-wrong'">
{{ selectedQuestion.status === 'correct' ? '正确' : '错误' }}
</text>
</view>
<view class="detail-fields">
<view class="field-card">
<text class="field-label">题干</text>
<text class="field-content">{{ selectedQuestion.stem }}</text>
</view>
<view class="field-card success">
<text class="field-label">正确答案</text>
<text class="field-content">{{ selectedQuestion.correctAnswer }}</text>
</view>
<view class="field-card student">
<text class="field-label">学员答案</text>
<text class="field-content">{{ selectedQuestion.studentAnswer }}</text>
</view>
<view class="field-card">
<text class="field-label">得分情况</text>
<text class="field-content">{{ selectedQuestion.score }}</text>
</view>
<view class="field-card">
<text class="field-label">班级得分率</text>
<text class="field-content">{{ selectedQuestion.classRate }}</text>
</view>
<view class="field-card">
<text class="field-label">学校得分率</text>
<text class="field-content">{{ selectedQuestion.schoolRate }}</text>
</view>
</view>
</ParentCard>
<ParentCard class="section-card" title="每道题具体情况">
<scroll-view scroll-x class="question-table-scroll">
<view class="question-table">
<view class="question-row table-head">
<text>题号</text>
<text>题型</text>
<text>得分</text>
<text>状态</text>
<text>知识点</text>
<text>班级得分率</text>
<text>学校得分率</text>
</view>
<view v-for="question in detail.questions" :key="`row-${question.no}`" class="question-row">
<text>{{ question.no }}</text>
<text>{{ question.type }}</text>
<text>{{ question.score }}</text>
<text :class="question.status === 'correct' ? 'status-correct' : 'status-wrong'">
{{ question.status === 'correct' ? '正确' : '错误' }}
</text>
<text>{{ question.knowledge }}</text>
<text>{{ question.classRate }}</text>
<text>{{ question.schoolRate }}</text>
</view>
</view>
</scroll-view>
</ParentCard>
</template>
<template v-else>
<ParentCard class="section-card" title="错题知识点占比" subtitle="用于判断最近应优先补强的知识点">
<view class="knowledge-summary">
<view class="wrong-total">
<text class="wrong-number">{{ wrongCount }}</text>
<text class="wrong-label">错题数</text>
</view>
<view class="ratio-list">
<view v-for="item in detail.knowledgeRatios" :key="item.name" class="ratio-row">
<view class="ratio-meta">
<text>{{ item.name }}</text>
<text>{{ item.percent }}%</text>
</view>
<view class="ratio-track">
<view class="ratio-fill" :class="item.tone" :style="{ width: `${item.percent}%` }"></view>
</view>
</view>
</view>
</view>
</ParentCard>
<ParentCard class="section-card" title="知识点得分">
<view class="level-tabs">
<view
v-for="level in knowledgeTabs"
:key="level.key"
class="level-tab"
:class="{ active: activeKnowledgeLevel === level.key }"
@click="activeKnowledgeLevel = level.key"
>
{{ level.label }}
</view>
</view>
<view class="data-table">
<view class="table-row table-head knowledge-row">
<text>知识点</text>
<text>满分</text>
<text>得分</text>
</view>
<view v-for="item in filteredKnowledgeScores" :key="item.name" class="table-row knowledge-row">
<text class="knowledge-name">{{ item.name }}</text>
<text>{{ item.fullScore }}</text>
<text class="score-danger">{{ item.score }}[{{ knowledgeRate(item) }}%]</text>
</view>
</view>
<xy-Empty v-if="!filteredKnowledgeScores.length" type="learn" content="当前分段暂无知识点" />
</ParentCard>
</template>
</view>
<view class="bottom-actions">
<button class="download-btn" @click="downloadReport">下载报告</button>
<!-- <view class="home-btn" @click="goHome">
<text class="home-roof"></text>
<text class="home-body"></text>
</view> -->
</view>
</ParentPage>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue';
import { reportDetails, reportList, type ReportKnowledgeScore } from '../utils/data';
type DetailTab = 'analysis' | 'answers' | 'knowledge';
type KnowledgeLevel = 'all' | 'weak' | 'pass' | 'good';
const tabs: { key: DetailTab; label: string }[] = [
{ key: 'analysis', label: '试卷分析' },
{ key: 'answers', label: '答题卡' },
{ key: 'knowledge', label: '知识点' },
];
const knowledgeTabs: { key: KnowledgeLevel; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'weak', label: '薄弱' },
{ key: 'pass', label: '合格' },
{ key: 'good', label: '良好' },
];
const reportId = ref('r1');
const activeTab = ref<DetailTab>('analysis');
const selectedNo = ref(1);
const activeKnowledgeLevel = ref<KnowledgeLevel>('all');
onLoad((query) => {
reportId.value = String(query?.id || 'r1');
});
const report = computed(() => reportList.find((item) => item.id === reportId.value) || reportList[0]);
const detail = computed(() => reportDetails[report.value.id] || reportDetails.r1);
const selectedQuestion = computed(() => detail.value.questions.find((item) => item.no === selectedNo.value) || detail.value.questions[0]);
const wrongCount = computed(() => detail.value.questions.filter((item) => item.status === 'wrong').length);
const knowledgeRate = (item: ReportKnowledgeScore) => Math.round((item.score / item.fullScore) * 100);
const knowledgeLevelOf = (item: ReportKnowledgeScore): KnowledgeLevel => {
const rate = knowledgeRate(item);
if (rate < 60) return 'weak';
if (rate < 80) return 'pass';
return 'good';
};
const filteredKnowledgeScores = computed(() => {
if (activeKnowledgeLevel.value === 'all') return detail.value.knowledgeScores;
return detail.value.knowledgeScores.filter((item) => knowledgeLevelOf(item) === activeKnowledgeLevel.value);
});
const downloadReport = () => {
uni.showToast({
title: '报告生成中',
icon: 'none',
});
};
const goHome = () => {
uni.reLaunch({ url: '/pages/parent/home/index' });
};
</script>
<style lang="scss" scoped>
.detail-page {
padding-bottom: calc(144rpx + env(safe-area-inset-bottom));
}
.hero-card {
margin-top: 10rpx;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(20, 184, 166, 0.08)),
rgba(255, 255, 255, 0.96);
}
.hero-top {
display: flex;
justify-content: space-between;
gap: 22rpx;
}
.category-tag {
display: inline-flex;
padding: 8rpx 16rpx;
border-radius: 999rpx;
color: #1d4ed8;
background: #dbeafe;
font-size: 22rpx;
font-weight: 800;
}
.report-title {
display: block;
margin-top: 18rpx;
color: #0f172a;
font-size: 34rpx;
font-weight: 900;
line-height: 1.35;
}
.report-date {
display: block;
margin-top: 12rpx;
color: #64748b;
font-size: 24rpx;
}
.subject-text {
color: #0f172a;
font-size: 28rpx;
font-weight: 900;
flex-shrink: 0;
}
.score-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18rpx;
margin-top: 30rpx;
}
.score-item {
min-height: 138rpx;
border-radius: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #ffffff;
box-shadow: inset 0 -2rpx 0 rgba(191, 219, 254, 0.7);
}
.score-label,
.score-desc {
color: #64748b;
font-size: 22rpx;
}
.score-value {
margin: 8rpx 0 4rpx;
color: #2563eb;
font-size: 42rpx;
font-weight: 900;
}
.nav-card,
.section-card {
margin-top: 26rpx;
}
.tab-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
padding: 8rpx;
border-radius: 28rpx;
background: #f1f5f9;
}
.tab-item {
height: 70rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
color: #475569;
font-size: 25rpx;
font-weight: 800;
}
.tab-item.active {
color: #1d4ed8;
background: #ffffff;
box-shadow: 0 10rpx 22rpx rgba(37, 99, 235, 0.12);
}
.rank-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
margin-top: 24rpx;
}
.rank-item {
min-height: 110rpx;
border-radius: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f8fafc;
}
.rank-label {
color: #64748b;
font-size: 22rpx;
}
.rank-value {
margin-top: 6rpx;
color: #2563eb;
font-size: 38rpx;
font-weight: 900;
}
.data-table {
overflow: hidden;
border: 2rpx solid #e2e8f0;
border-radius: 26rpx;
}
.table-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
min-height: 74rpx;
align-items: center;
color: #334155;
font-size: 24rpx;
text-align: center;
border-top: 2rpx solid #f1f5f9;
}
.table-row:first-child {
border-top: 0;
}
.table-head {
color: #475569;
background: #f8fafc;
font-weight: 900;
}
.strong,
.rate-good,
.status-correct {
color: #16a34a;
font-weight: 900;
}
.rate-warn,
.score-danger,
.status-wrong {
color: #ef4444;
font-weight: 900;
}
.answer-legend {
display: flex;
gap: 28rpx;
color: #475569;
font-size: 24rpx;
}
.answer-legend view {
display: flex;
align-items: center;
gap: 10rpx;
}
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.legend-dot.correct {
background: #22c55e;
}
.legend-dot.wrong {
background: #ef4444;
}
.question-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16rpx;
margin-top: 24rpx;
}
.question-no {
height: 58rpx;
border: 2rpx solid #bbf7d0;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
color: #15803d;
background: #f0fdf4;
font-size: 24rpx;
font-weight: 900;
}
.question-no.wrong {
border-color: #fecaca;
color: #dc2626;
background: #fef2f2;
}
.question-no.active {
border-color: #2563eb;
box-shadow: 0 0 0 4rpx rgba(37, 99, 235, 0.1);
}
.question-head {
display: flex;
align-items: center;
gap: 20rpx;
color: #0f172a;
font-size: 26rpx;
font-weight: 900;
}
.detail-fields {
display: grid;
gap: 18rpx;
margin-top: 22rpx;
}
.field-card {
padding: 18rpx 20rpx;
border: 2rpx solid #dbeafe;
border-radius: 18rpx;
background: #ffffff;
}
.field-card.success {
border-color: #bbf7d0;
}
.field-card.student {
border-color: #ddd6fe;
}
.field-label {
display: inline-flex;
margin-bottom: 8rpx;
padding: 6rpx 12rpx;
border-radius: 12rpx;
color: #ffffff;
background: #2563eb;
font-size: 22rpx;
font-weight: 800;
}
.field-card.success .field-label {
background: #22c55e;
}
.field-card.student .field-label {
background: #7c3aed;
}
.field-content {
display: block;
color: #334155;
font-size: 25rpx;
line-height: 1.55;
}
.question-table-scroll {
width: 100%;
}
.question-table {
width: 1060rpx;
border: 2rpx solid #e2e8f0;
border-radius: 24rpx;
overflow: hidden;
}
.question-row {
display: grid;
grid-template-columns: 90rpx 140rpx 110rpx 110rpx 210rpx 190rpx 190rpx;
min-height: 72rpx;
align-items: center;
color: #334155;
font-size: 23rpx;
text-align: center;
border-top: 2rpx solid #f1f5f9;
}
.question-row:first-child {
border-top: 0;
}
.knowledge-summary {
display: flex;
align-items: center;
gap: 28rpx;
}
.wrong-total {
width: 170rpx;
height: 170rpx;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
box-shadow: inset 0 0 0 16rpx #dbeafe;
flex-shrink: 0;
}
.wrong-number {
color: #2563eb;
font-size: 46rpx;
font-weight: 900;
}
.wrong-label {
color: #64748b;
font-size: 22rpx;
}
.ratio-list {
flex: 1;
min-width: 0;
}
.ratio-row + .ratio-row {
margin-top: 18rpx;
}
.ratio-meta {
display: flex;
justify-content: space-between;
color: #334155;
font-size: 24rpx;
font-weight: 800;
}
.ratio-track {
height: 16rpx;
margin-top: 10rpx;
border-radius: 999rpx;
overflow: hidden;
background: #e2e8f0;
}
.ratio-fill {
height: 100%;
border-radius: 999rpx;
}
.ratio-fill.blue {
background: #2563eb;
}
.ratio-fill.orange {
background: #f97316;
}
.ratio-fill.green {
background: #22c55e;
}
.level-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14rpx;
margin-bottom: 20rpx;
}
.level-tab {
height: 62rpx;
border: 2rpx solid #e2e8f0;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #475569;
background: #ffffff;
font-size: 24rpx;
font-weight: 800;
}
.level-tab.active {
border-color: #2563eb;
color: #ffffff;
background: #2563eb;
}
.knowledge-row {
grid-template-columns: 1fr 120rpx 180rpx;
}
.knowledge-name {
text-align: left;
padding-left: 24rpx;
}
.bottom-actions {
position: fixed;
left: 28rpx;
right: 28rpx;
bottom: calc(22rpx + env(safe-area-inset-bottom));
z-index: 20;
display: flex;
align-items: center;
}
.download-btn {
flex: 1;
height: 84rpx;
border: 0;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
background: linear-gradient(135deg, #2563eb 0%, #0891b2 100%);
font-size: 30rpx;
font-weight: 900;
box-shadow: 0 16rpx 32rpx rgba(37, 99, 235, 0.22);
}
.download-btn::after {
border: 0;
}
.home-btn {
width: 88rpx;
height: 88rpx;
margin-left: -12rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
background: #2563eb;
box-shadow: 0 16rpx 32rpx rgba(37, 99, 235, 0.26);
}
.home-roof {
position: absolute;
width: 28rpx;
height: 28rpx;
border-top: 5rpx solid #ffffff;
border-left: 5rpx solid #ffffff;
transform: translateY(-8rpx) rotate(45deg);
}
.home-body {
width: 34rpx;
height: 28rpx;
margin-top: 16rpx;
border: 5rpx solid #ffffff;
border-top: 0;
border-radius: 4rpx;
}
</style>

View File

@ -0,0 +1,206 @@
<template>
<ParentPage active="reports">
<view class="filter-row">
<picker class="filter-picker" :range="subjectOptions" :value="subjectIndex" @change="onSubjectChange">
<view class="filter-pill">
<text>{{ subjectOptions[subjectIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
<picker class="filter-picker" :range="gradeOptions" :value="gradeIndex" @change="onGradeChange">
<view class="filter-pill">
<text>{{ gradeOptions[gradeIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
<picker class="filter-picker" :range="categoryOptions" :value="categoryIndex" @change="onCategoryChange">
<view class="filter-pill">
<text>{{ categoryOptions[categoryIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
</view>
<view v-if="filteredReports.length" class="report-list">
<ParentCard
v-for="item in filteredReports"
:key="item.id"
class="report-card"
clickable
@click="openReport(item.id)"
>
<view class="report-body">
<view class="subject-badge" :class="subjectClass(item.subject)">{{ item.subject }}</view>
<view class="report-main">
<view class="category">[{{ item.category }}]</view>
<text class="report-title ellipsis-2">{{ item.title }}</text>
</view>
<view class="score-wrap">
<text class="score">{{ formatScore(item.score) }}</text>
<text class="date">{{ item.date }}</text>
</view>
</view>
</ParentCard>
</view>
<view v-else class="empty-card">
<xy-Empty type="task" content="暂无符合条件的考试报告" />
</view>
</ParentPage>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue';
import { categoryOptions, gradeOptions, reportList, subjectOptions, type ReportItem } from '../utils/data';
const subjectIndex = ref(0);
const gradeIndex = ref(0);
const categoryIndex = ref(0);
const filteredReports = computed(() => {
const subject = subjectOptions[subjectIndex.value];
const grade = gradeOptions[gradeIndex.value];
const category = categoryOptions[categoryIndex.value];
return reportList.filter((item) => {
const subjectMatched = subject === '全部学科' || item.subject === subject;
const gradeMatched = grade === '全部年级' || item.grade === grade;
const categoryMatched = category === '全部类型' || item.category === category;
return subjectMatched && gradeMatched && categoryMatched;
});
});
const onSubjectChange = (e: any) => { subjectIndex.value = Number(e.detail.value); };
const onGradeChange = (e: any) => { gradeIndex.value = Number(e.detail.value); };
const onCategoryChange = (e: any) => { categoryIndex.value = Number(e.detail.value); };
const formatScore = (score: number) => `${score.toFixed(1).replace(/\.0$/, '')}%`;
const subjectClass = (subject: ReportItem['subject']) => {
if (subject === '数学') return 'math';
if (subject === '语文') return 'chinese';
if (subject === '英语') return 'english';
return 'multi';
};
const openReport = (id: string) => {
uni.navigateTo({ url: `/pages/parent/reports/detail?id=${id}` });
};
</script>
<style lang="scss" scoped>
.filter-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
padding-top: 12rpx;
}
.filter-picker {
min-width: 0;
}
.filter-pill {
height: 72rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
color: #164e63;
font-size: 24rpx;
font-weight: 800;
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 8rpx 22rpx rgba(8, 145, 178, 0.08),
inset 0 -2rpx 0 rgba(207, 250, 254, 0.9);
}
.filter-arrow {
width: 0;
height: 0;
border-left: 8rpx solid transparent;
border-right: 8rpx solid transparent;
border-top: 10rpx solid #0891b2;
flex-shrink: 0;
}
.report-list {
margin-top: 22rpx;
}
.report-card {
margin-bottom: 18rpx;
padding: 24rpx;
}
.report-body {
display: flex;
align-items: center;
gap: 20rpx;
}
.subject-badge {
width: 86rpx;
height: 86rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 24rpx;
font-weight: 900;
flex-shrink: 0;
&.math { background: #14b8a6; }
&.chinese { background: #f97316; }
&.english { background: #ec4899; }
&.multi { background: #3b82f6; }
}
.report-main {
flex: 1;
min-width: 0;
}
.category {
color: #64748b;
font-size: 22rpx;
font-weight: 700;
}
.report-title {
display: block;
margin-top: 4rpx;
color: #164e63;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.score-wrap {
width: 112rpx;
text-align: right;
flex-shrink: 0;
}
.score {
display: block;
color: #ea580c;
font-size: 30rpx;
font-weight: 900;
}
.date {
display: block;
margin-top: 8rpx;
color: #94a3b8;
font-size: 22rpx;
}
.empty-card {
height: 520rpx;
margin-top: 24rpx;
}
</style>

View File

@ -0,0 +1,354 @@
export interface ParentChild {
id: string;
name: string;
account: string;
school: string;
className: string;
examNo: string;
}
export interface SubjectTrend {
subject: string;
status: 'up' | 'down' | 'stable';
}
export interface GradeBand {
label: string;
level: 'excellent' | 'good' | 'middle' | 'weak';
subjects: SubjectTrend[];
}
export interface ReportItem {
id: string;
subject: string;
category: string;
title: string;
score: number;
date: string;
grade: string;
}
export interface ReportScoreBlock {
label: string;
value: string;
desc: string;
}
export interface ReportRank {
label: string;
value: string;
}
export interface ReportAnalysisRow {
range: string;
type: string;
score: string;
rate: string;
}
export interface ReportDifficultyRow {
difficulty: string;
range: string;
score: string;
rate: string;
}
export interface ReportQuestion {
no: number;
type: string;
score: string;
status: 'correct' | 'wrong';
stem: string;
correctAnswer: string;
studentAnswer: string;
classRate: string;
schoolRate: string;
knowledge: string;
}
export interface ReportKnowledgeRatio {
name: string;
percent: number;
tone: 'blue' | 'orange' | 'green';
}
export interface ReportKnowledgeScore {
name: string;
fullScore: number;
score: number;
}
export interface ReportDetail {
id: string;
scoreBlocks: ReportScoreBlock[];
ranks: ReportRank[];
analysisRows: ReportAnalysisRow[];
difficultyRows: ReportDifficultyRow[];
questions: ReportQuestion[];
knowledgeRatios: ReportKnowledgeRatio[];
knowledgeScores: ReportKnowledgeScore[];
}
export interface QuickAction {
title: string;
desc: string;
tone: 'cyan' | 'green' | 'orange';
}
export interface KnowledgeWeakPoint {
name: string;
subject: string;
grade: string;
category: string;
fullScore: number;
score: number;
wrongPercent: number;
tone: 'blue' | 'orange' | 'green' | 'rose';
}
export interface AbilityTrendPoint {
date: string;
score: number;
}
export interface AbilitySummary {
label: string;
value: string;
desc: string;
}
// 家长端首版先用结构化本地数据承载页面功能,后续接接口只需要替换这里的来源。
export const parentChildren: ParentChild[] = [
{
id: 'child-1',
name: '小星河',
account: '3620012788',
school: '山东蓝翔技校(初中)',
className: '初2027级3班',
examNo: '3620012788',
},
{
id: 'child-2',
name: '小灵犀',
account: '3620012563',
school: '山东蓝翔技校(初中)',
className: '初2027级3班',
examNo: '3620012563',
},
];
export const gradeBands: GradeBand[] = [
{
label: '优势保持',
level: 'excellent',
subjects: [],
},
{
label: '良好巩固',
level: 'good',
subjects: [{ subject: '数学', status: 'down' }],
},
{
label: '重点提升',
level: 'middle',
subjects: [
{ subject: '语文', status: 'up' },
{ subject: '英语', status: 'up' },
],
},
{
label: '及时补弱',
level: 'weak',
subjects: [],
},
];
export const quickActions: QuickAction[] = [
{ title: '学情追踪', desc: '查看本周学习变化', tone: 'cyan' },
{ title: '错题练习', desc: '按薄弱点再练一遍', tone: 'orange' },
{ title: '课程学习', desc: '进入课程任务', tone: 'green' },
];
export const reportList: ReportItem[] = [
{
id: 'r1',
subject: '数学',
category: '周作业',
title: '2023-2024学年初2023级七年级上期末模拟数学',
score: 100,
date: '2024-01-12',
grade: '初一',
},
{
id: 'r2',
subject: '多科',
category: '月考',
title: '2023-2024学年初2023级阶段性练习',
score: 60,
date: '2024-01-08',
grade: '初一',
},
{
id: 'r3',
subject: '语文',
category: '周作业',
title: '2023-2024学年初2023级七年级上期末模拟语文',
score: 92,
date: '2023-12-28',
grade: '初一',
},
{
id: 'r4',
subject: '英语',
category: '周作业',
title: '2023-2024学年初2023级七年级上期末模拟英语',
score: 88.5,
date: '2023-12-28',
grade: '初一',
},
];
export const reportDetails: Record<string, ReportDetail> = {
r1: {
id: 'r1',
scoreBlocks: [
{ label: '我的得分', value: '120', desc: '总分 120' },
{ label: '满分', value: '120', desc: '本次卷面' },
{ label: '得分率', value: '100.0%', desc: '超过多数同学' },
],
ranks: [
{ label: '班级排名', value: '1/45' },
{ label: '年级排名', value: '6/680' },
],
analysisRows: [
{ range: '1-10', type: '选择题', score: '40/40', rate: '100%' },
{ range: '11-16', type: '填空题', score: '30/30', rate: '100%' },
{ range: '17-21', type: '解答题', score: '20/20', rate: '100%' },
{ range: '22-23', type: '压轴题', score: '5/10', rate: '50%' },
],
difficultyRows: [
{ difficulty: '简单题', range: '1-8', score: '32/32', rate: '100%' },
{ difficulty: '中等题', range: '9-18', score: '42/44', rate: '95.5%' },
{ difficulty: '难题', range: '19-23', score: '18/24', rate: '75.0%' },
],
questions: [
{ no: 1, type: '选择题', score: '4/4', status: 'correct', stem: '请结合材料完成本题要求。', correctAnswer: 'A', studentAnswer: 'A', classRate: '88%', schoolRate: '84%', knowledge: '字音字形' },
{ no: 2, type: '选择题', score: '4/4', status: 'correct', stem: '根据题意选择最符合要求的一项。', correctAnswer: 'C', studentAnswer: 'C', classRate: '84%', schoolRate: '81%', knowledge: '词语运用' },
{ no: 3, type: '选择题', score: '4/4', status: 'correct', stem: '阅读材料并判断关键条件。', correctAnswer: 'B', studentAnswer: 'B', classRate: '80%', schoolRate: '78%', knowledge: '病句辨析' },
{ no: 4, type: '选择题', score: '4/4', status: 'correct', stem: '从选项中选出表达准确的一项。', correctAnswer: 'D', studentAnswer: 'D', classRate: '76%', schoolRate: '75%', knowledge: '文学常识' },
{ no: 5, type: '选择题', score: '4/4', status: 'correct', stem: '根据语境判断标点使用。', correctAnswer: 'A', studentAnswer: 'A', classRate: '72%', schoolRate: '72%', knowledge: '标点符号' },
{ no: 6, type: '选择题', score: '4/4', status: 'correct', stem: '判断句子修辞手法。', correctAnswer: 'B', studentAnswer: 'B', classRate: '88%', schoolRate: '69%', knowledge: '修辞手法' },
{ no: 7, type: '选择题', score: '4/4', status: 'correct', stem: '结合古诗文选择正确理解。', correctAnswer: 'C', studentAnswer: 'C', classRate: '84%', schoolRate: '84%', knowledge: '古诗文默写' },
{ no: 8, type: '选择题', score: '4/4', status: 'correct', stem: '阅读片段后概括中心信息。', correctAnswer: 'D', studentAnswer: 'D', classRate: '80%', schoolRate: '81%', knowledge: '名著阅读' },
{ no: 9, type: '选择题', score: '4/4', status: 'correct', stem: '判断句间衔接是否连贯。', correctAnswer: 'A', studentAnswer: 'A', classRate: '76%', schoolRate: '78%', knowledge: '语段衔接' },
{ no: 10, type: '选择题', score: '4/4', status: 'correct', stem: '完成句式转换。', correctAnswer: 'C', studentAnswer: 'C', classRate: '72%', schoolRate: '75%', knowledge: '句式转换' },
{ no: 11, type: '填空题', score: '5/5', status: 'correct', stem: '补全古文实词解释。', correctAnswer: '明察', studentAnswer: '明察', classRate: '88%', schoolRate: '72%', knowledge: '古文实词' },
{ no: 12, type: '填空题', score: '5/5', status: 'correct', stem: '补全古文虚词用法。', correctAnswer: '之', studentAnswer: '之', classRate: '84%', schoolRate: '69%', knowledge: '古文虚词' },
{ no: 13, type: '填空题', score: '5/5', status: 'correct', stem: '判断文言断句位置。', correctAnswer: 'B', studentAnswer: 'B', classRate: '80%', schoolRate: '84%', knowledge: '文言断句' },
{ no: 14, type: '填空题', score: '5/5', status: 'correct', stem: '翻译关键句子。', correctAnswer: '符合语境', studentAnswer: '符合语境', classRate: '76%', schoolRate: '81%', knowledge: '翻译理解' },
{ no: 15, type: '填空题', score: '5/5', status: 'correct', stem: '赏析诗句表达效果。', correctAnswer: '借景抒情', studentAnswer: '借景抒情', classRate: '72%', schoolRate: '78%', knowledge: '诗歌鉴赏' },
{ no: 16, type: '填空题', score: '5/5', status: 'correct', stem: '概括现代文段落作用。', correctAnswer: '承上启下', studentAnswer: '承上启下', classRate: '68%', schoolRate: '74%', knowledge: '段落作用' },
{ no: 17, type: '解答题', score: '4/4', status: 'correct', stem: '结合文本分析人物形象。', correctAnswer: '要点完整', studentAnswer: '要点完整', classRate: '81%', schoolRate: '77%', knowledge: '人物形象' },
{ no: 18, type: '解答题', score: '4/4', status: 'correct', stem: '解释标题的含义。', correctAnswer: '双层含义', studentAnswer: '双层含义', classRate: '79%', schoolRate: '73%', knowledge: '标题含义' },
{ no: 19, type: '解答题', score: '4/4', status: 'correct', stem: '分析语句表达效果。', correctAnswer: '修辞与情感', studentAnswer: '修辞与情感', classRate: '73%', schoolRate: '70%', knowledge: '语句赏析' },
{ no: 20, type: '解答题', score: '4/4', status: 'correct', stem: '根据材料提出观点。', correctAnswer: '观点明确', studentAnswer: '观点明确', classRate: '70%', schoolRate: '68%', knowledge: '观点表达' },
{ no: 21, type: '解答题', score: '4/4', status: 'correct', stem: '完成综合探究。', correctAnswer: '材料充分', studentAnswer: '材料充分', classRate: '66%', schoolRate: '63%', knowledge: '综合探究' },
{ no: 22, type: '压轴题', score: '2/5', status: 'wrong', stem: '结合整篇文章完成开放表达。', correctAnswer: '围绕主题并联系生活', studentAnswer: '联系不足', classRate: '52%', schoolRate: '49%', knowledge: '开放表达' },
{ no: 23, type: '压轴题', score: '3/5', status: 'wrong', stem: '完成写作片段升格。', correctAnswer: '结构完整、语言有层次', studentAnswer: '结构较散', classRate: '48%', schoolRate: '44%', knowledge: '写作表达' },
],
knowledgeRatios: [
{ name: '函数与图像', percent: 40, tone: 'blue' },
{ name: '几何证明', percent: 35, tone: 'orange' },
{ name: '方程应用', percent: 25, tone: 'green' },
],
knowledgeScores: [
{ name: '《西游记》', fullScore: 2, score: 0 },
{ name: '情感主旨', fullScore: 2, score: 0 },
{ name: '环境描写', fullScore: 4, score: 1.5 },
{ name: '开放表达', fullScore: 5, score: 2 },
{ name: '写作表达', fullScore: 5, score: 3 },
],
},
};
export const subjectOptions = ['全部学科', '数学', '语文', '英语', '多科'];
export const gradeOptions = ['全部年级', '初一', '初二', '初三'];
export const categoryOptions = ['全部类型', '周作业', '月考'];
export const knowledgeWeakPoints: KnowledgeWeakPoint[] = [
{
name: '函数与图像',
subject: '数学',
grade: '初一',
category: '周作业',
fullScore: 20,
score: 8,
wrongPercent: 40,
tone: 'blue',
},
{
name: '几何证明',
subject: '数学',
grade: '初一',
category: '周作业',
fullScore: 20,
score: 9,
wrongPercent: 45,
tone: 'orange',
},
{
name: '方程应用',
subject: '数学',
grade: '初一',
category: '月考',
fullScore: 20,
score: 15,
wrongPercent: 25,
tone: 'green',
},
{
name: '现代文阅读',
subject: '语文',
grade: '初一',
category: '周作业',
fullScore: 16,
score: 10,
wrongPercent: 38,
tone: 'rose',
},
{
name: '完形填空',
subject: '英语',
grade: '初一',
category: '月考',
fullScore: 15,
score: 11,
wrongPercent: 27,
tone: 'blue',
},
];
export const abilityTrend: AbilityTrendPoint[] = [
{ date: '11-22', score: 62 },
{ date: '11-28', score: 68 },
{ date: '12-05', score: 72 },
{ date: '12-13', score: 91 },
{ date: '12-20', score: 75 },
{ date: '12-27', score: 78 },
{ date: '01-03', score: 81 },
{ date: '01-08', score: 76 },
{ date: '01-14', score: 79 },
];
export const abilitySummary: AbilitySummary[] = [
{
label: '得分能力综合评估',
value: '良好',
desc: '最近一次峰值明显,整体维持在良好区间。',
},
{
label: '得分能力稳定性',
value: '相对波动',
desc: '12月中旬后回落需要关注高分后的复盘质量。',
},
];

View File

@ -0,0 +1,384 @@
<template>
<view class="filter-page">
<view class="bg-decor"></view>
<scroll-view class="filter-scroll" scroll-y enhanced :show-scrollbar="false">
<view class="content">
<view class="section-card clay-card">
<view class="section-head">
<text class="section-title">选择班级</text>
<text class="section-required">必选</text>
</view>
<view v-if="classLoading" class="state-line">加载班级中...</view>
<view v-else-if="!classList.length" class="state-line">暂无班级数据</view>
<view v-else class="option-grid">
<view
v-for="item in classList"
:key="item.id"
class="option-item"
:class="{ active: selectedClassId === item.id }"
@click="selectedClassId = item.id"
>
<text class="option-text">{{ item.name }}</text>
<text v-if="selectedClassId === item.id" class="check-mark"></text>
</view>
</view>
</view>
<view class="section-card clay-card">
<view class="section-head">
<text class="section-title">选择学科</text>
<text class="section-hint">可多选不选默认全部</text>
</view>
<view v-if="!selectedClassId" class="state-line">请先选择班级</view>
<view v-else-if="subjectLoading" class="state-line">加载学科中...</view>
<view v-else-if="!subjectList.length" class="state-line">该班级暂无学科数据</view>
<view v-else class="option-grid">
<view
v-for="item in subjectList"
:key="item.value"
class="option-item"
:class="{ active: selectedSubjectIds.includes(item.value) }"
@click="toggleSubject(item.value)"
>
<text class="option-text">{{ item.label }}</text>
<text v-if="selectedSubjectIds.includes(item.value)" class="check-mark"></text>
</view>
</view>
</view>
<view class="section-card clay-card">
<view class="section-head">
<text class="section-title">时间范围</text>
<text class="section-hint">默认今天</text>
</view>
<view class="date-row">
<picker mode="date" :value="startTime" @change="onStartChange">
<view class="date-box">{{ startTime || '开始日期' }}</view>
</picker>
<text class="date-sep"></text>
<picker mode="date" :value="endTime" @change="onEndChange">
<view class="date-box">{{ endTime || '结束日期' }}</view>
</picker>
</view>
<view v-if="startTime || endTime" class="clear-date" @click="clearDate">清除时间</view>
</view>
<view class="bottom-space"></view>
</view>
</scroll-view>
<view class="bottom-bar">
<view class="reset-btn" @click="handleReset">重置</view>
<view class="confirm-btn clay-button" :class="{ disabled: !selectedClassId }" @click="handleConfirm">
{{ confirmButtonText }}
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import { useTeacherStore } from '@/state/modules/teacher';
import { getClassListByTeacher, getSubjectListByClassId } from '@/api/teacher';
const teacherStore = useTeacherStore();
const { teacherInfo } = storeToRefs(teacherStore);
const target = ref('');
const classList = ref<{ id: string; name: string }[]>([]);
const classLoading = ref(false);
const selectedClassId = ref('');
const subjectList = ref<{ value: number; label: string }[]>([]);
const subjectLoading = ref(false);
const selectedSubjectIds = ref<number[]>([]);
const getTodayStr = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const startTime = ref(getTodayStr());
const endTime = ref(getTodayStr());
const confirmButtonText = computed(() => target.value === 'weakKnowledge' ? '查看薄弱知识点' : '查询学情报告');
const normalizeClassList = (data: any) => {
const rows = Array.isArray(data) ? data : data?.rows || data?.list || [];
return rows.map((item: any) => ({
...item,
id: String(item.id ?? item.classId ?? ''),
name: item.name ?? item.className ?? '未命名班级',
})).filter((item: any) => item.id);
};
const fetchClassList = async () => {
classLoading.value = true;
try {
const res = await getClassListByTeacher();
classList.value = normalizeClassList(res.data);
if (!selectedClassId.value && classList.value.length === 1) {
selectedClassId.value = classList.value[0].id;
}
} catch (err) {
console.error('获取班级失败', err);
classList.value = [];
} finally {
classLoading.value = false;
}
};
watch(selectedClassId, async (value) => {
subjectList.value = [];
selectedSubjectIds.value = [];
if (!value) return;
subjectLoading.value = true;
try {
const res = await getSubjectListByClassId(value);
const rows = Array.isArray(res.data) ? res.data : [];
subjectList.value = rows.map((item: any) => ({
value: Number(item.subjectId ?? item.id ?? item.value),
label: item.subjectName ?? item.name ?? item.label ?? '',
})).filter((item) => item.value && item.label);
const teacherSubjectId = Number(teacherInfo.value.subjectId);
if (teacherSubjectId && subjectList.value.some((item) => item.value === teacherSubjectId)) {
selectedSubjectIds.value = [teacherSubjectId];
}
} catch (err) {
console.error('获取学科失败', err);
} finally {
subjectLoading.value = false;
}
});
const toggleSubject = (value: number) => {
selectedSubjectIds.value = selectedSubjectIds.value.includes(value)
? selectedSubjectIds.value.filter((item) => item !== value)
: [...selectedSubjectIds.value, value];
};
const onStartChange = (e: any) => { startTime.value = e.detail?.value || ''; };
const onEndChange = (e: any) => { endTime.value = e.detail?.value || ''; };
const clearDate = () => {
startTime.value = '';
endTime.value = '';
};
const handleReset = () => {
selectedClassId.value = '';
selectedSubjectIds.value = [];
startTime.value = '';
endTime.value = '';
};
const handleConfirm = () => {
if (!selectedClassId.value) {
uni.showToast({ title: '请先选择班级', icon: 'none' });
return;
}
const selectedClass = classList.value.find((item) => item.id === selectedClassId.value);
const query: string[] = [
`classId=${encodeURIComponent(selectedClassId.value)}`,
`className=${encodeURIComponent(selectedClass?.name || '')}`,
];
if (selectedSubjectIds.value.length) {
query.push(`subjectIds=${encodeURIComponent(selectedSubjectIds.value.join(','))}`);
}
if (startTime.value) query.push(`startTime=${encodeURIComponent(startTime.value)}`);
if (endTime.value) query.push(`endTime=${encodeURIComponent(endTime.value)}`);
const path = target.value === 'weakKnowledge'
? '/pages/teacher/weakKnowledge/index'
: '/pages/teacher/homework/index';
uni.navigateTo({ url: `${path}?${query.join('&')}` });
};
onLoad((options: any) => {
target.value = options?.target || '';
});
onShow(async () => {
try {
if (!teacherInfo.value?.id) await teacherStore.getLoginUser();
if (!classList.value.length) await fetchClassList();
} catch (err) {
console.error('筛选页初始化失败', err);
}
});
</script>
<style lang="scss" scoped>
.filter-page {
height: 100vh;
background: linear-gradient(180deg, #eef2ff 0%, #f8fafc 62%, #ffffff 100%);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.bg-decor {
position: absolute;
width: 420rpx;
height: 420rpx;
right: -160rpx;
top: 180rpx;
border-radius: 50%;
background: rgba(129, 140, 248, 0.24);
filter: blur(70rpx);
}
.filter-scroll {
flex: 1;
height: 0;
position: relative;
z-index: 1;
}
.content {
padding: 24rpx 28rpx 0;
}
.section-card {
padding: 30rpx 28rpx;
margin-bottom: 22rpx;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 800;
color: #312e81;
}
.section-required,
.section-hint {
font-size: 22rpx;
color: #64748b;
}
.section-required {
color: #ef4444;
}
.option-grid {
display: flex;
flex-wrap: wrap;
gap: 18rpx;
}
.option-item {
min-height: 76rpx;
max-width: 100%;
padding: 0 24rpx;
display: flex;
align-items: center;
gap: 12rpx;
border-radius: 24rpx;
background: #f8fafc;
border: 2rpx solid #e2e8f0;
color: #475569;
font-size: 26rpx;
&.active {
color: #ffffff;
background: linear-gradient(180deg, #6366f1 0%, #4f46e5 100%);
border-color: #4f46e5;
box-shadow: 0 10rpx 22rpx rgba(79, 70, 229, 0.22);
}
}
.option-text {
max-width: 540rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.check-mark {
font-size: 24rpx;
font-weight: 900;
}
.state-line {
padding: 28rpx 0;
text-align: center;
font-size: 26rpx;
color: #94a3b8;
}
.date-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.date-box {
min-width: 220rpx;
padding: 20rpx;
border-radius: 24rpx;
background: #f8fafc;
border: 2rpx solid #e0e7ff;
text-align: center;
color: #4f46e5;
font-size: 26rpx;
font-weight: 600;
}
.date-sep {
color: #94a3b8;
}
.clear-date {
margin-top: 20rpx;
color: #ef4444;
font-size: 24rpx;
}
.bottom-space {
height: 24rpx;
}
.bottom-bar {
position: relative;
z-index: 9;
display: flex;
flex-shrink: 0;
gap: 20rpx;
padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 -8rpx 24rpx rgba(99, 102, 241, 0.08);
}
.reset-btn,
.confirm-btn {
height: 88rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 800;
}
.reset-btn {
flex: 1;
background: #f8fafc;
color: #64748b;
border: 2rpx solid #e2e8f0;
}
.confirm-btn {
flex: 2;
&.disabled {
opacity: 0.55;
}
}
</style>

View File

@ -0,0 +1,407 @@
<template>
<view class="report-page">
<view class="summary-card clay-card" @click="goBackFilter">
<view>
<text class="summary-title">{{ className || reportData?.className || '班级作业完成情况' }}</text>
<text class="summary-desc">{{ filterSummary }}</text>
</view>
<text class="summary-action">修改</text>
</view>
<view v-if="loading" class="state-card">
<xy-Loading text="加载报告中..." />
</view>
<view v-else-if="errorMsg" class="state-card">
<xy-Empty type="network" :content="errorMsg" />
</view>
<scroll-view v-else class="report-scroll" scroll-y enhanced :show-scrollbar="false">
<view v-if="reportData" class="content">
<view class="overview-card clay-card">
<view class="overview-item">
<text class="overview-value">{{ studentCount }}</text>
<text class="overview-label">学生数</text>
</view>
<view class="overview-item">
<text class="overview-value">{{ totalHomework }}</text>
<text class="overview-label">作业总数</text>
</view>
<view class="overview-item">
<text class="overview-value">{{ completeRate }}</text>
<text class="overview-label">完成率</text>
</view>
</view>
<view v-if="!reportData.studentList?.length" class="state-card in-scroll">
<xy-Empty type="task" content="暂无学生作业数据" />
</view>
<view v-for="student in reportData.studentList" :key="student.userId" class="student-card clay-card">
<view class="student-head">
<view class="avatar">{{ firstName(student.userName) }}</view>
<view class="student-main">
<text class="student-name">{{ student.userName || '未命名学生' }}</text>
<view class="progress-row">
<view class="progress-bar">
<view class="progress-fill" :class="{ full: studentRate(student) >= 100 }" :style="{ width: studentRate(student) + '%' }"></view>
</view>
<text class="progress-text">{{ studentRate(student) }}%</text>
</view>
</view>
</view>
<view class="stat-grid">
<view class="stat-item">
<text class="stat-value">{{ student.totalCount || 0 }}</text>
<text class="stat-label">总数</text>
</view>
<view class="stat-item done">
<text class="stat-value">{{ student.completedCount || 0 }}</text>
<text class="stat-label">完成</text>
</view>
<view class="stat-item undone">
<text class="stat-value">{{ student.uncompletedCount || 0 }}</text>
<text class="stat-label">未完成</text>
</view>
</view>
<view v-if="student.subjectStats?.length" class="subject-wrap">
<view
v-for="item in student.subjectStats"
:key="item.subjectId"
class="subject-chip"
:class="{ full: item.homeworkCount > 0 && item.completedCount >= item.homeworkCount }"
>
<text>{{ item.subjectName }}</text>
<text>{{ item.completedCount }}/{{ item.homeworkCount }}</text>
</view>
</view>
</view>
<view class="bottom-space"></view>
</view>
<view v-else class="state-card in-scroll">
<xy-Empty type="task" content="暂无报告数据" />
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getClassHomeworkReport, type ClassHomeworkReportData, type ClassHomeworkStudentItem } from '@/api/teacher';
const subjectMap: Record<number, string> = {
2: '语文',
3: '数学',
4: '英语',
5: '科学',
6: '物理',
7: '化学',
8: '历史',
9: '道德与法治',
10: '地理',
11: '生物',
12: '政治',
13: '信息',
14: '通用',
15: '日语',
};
const classId = ref('');
const className = ref('');
const subjectIds = ref<number[]>([]);
const startTime = ref('');
const endTime = ref('');
const reportData = ref<ClassHomeworkReportData | null>(null);
const loading = ref(false);
const errorMsg = ref('');
const studentCount = computed(() => reportData.value?.studentList?.length || 0);
const totalHomework = computed(() => (reportData.value?.studentList || []).reduce((sum, item) => sum + (item.totalCount || 0), 0));
const completedHomework = computed(() => (reportData.value?.studentList || []).reduce((sum, item) => sum + (item.completedCount || 0), 0));
const completeRate = computed(() => totalHomework.value ? `${Math.round(completedHomework.value / totalHomework.value * 100)}%` : '0%');
const filterSummary = computed(() => {
const parts: string[] = [];
if (subjectIds.value.length) {
const labels = subjectIds.value.map((id) => subjectMap[id]).filter(Boolean);
parts.push(labels.length ? labels.join('、') : `${subjectIds.value.length}个学科`);
} else {
parts.push('全部学科');
}
if (startTime.value || endTime.value) {
parts.push(`${startTime.value || '...'}${endTime.value || '...'}`);
}
return parts.join(' · ');
});
const parseSubjectIds = (value: any) => {
if (!value) return [];
return decodeURIComponent(String(value)).split(',').map(Number).filter((item) => !Number.isNaN(item));
};
const firstName = (name?: string) => (name || '学').slice(0, 1);
const studentRate = (student: ClassHomeworkStudentItem) => {
if (!student.totalCount) return 0;
return Math.min(100, Math.round(student.completedCount / student.totalCount * 100));
};
const fetchReport = async () => {
if (!classId.value) {
errorMsg.value = '缺少班级参数,请重新筛选';
return;
}
loading.value = true;
errorMsg.value = '';
try {
const res = await getClassHomeworkReport({
classId: classId.value,
subjectIds: subjectIds.value.length ? subjectIds.value : undefined,
startTime: startTime.value || undefined,
endTime: endTime.value || undefined,
});
reportData.value = res.data;
if (!className.value) className.value = res.data?.className || '';
} catch (err) {
console.error('获取学情报告失败', err);
errorMsg.value = '学情报告加载失败,请稍后重试';
reportData.value = null;
} finally {
loading.value = false;
}
};
const goBackFilter = () => {
uni.navigateBack();
};
onLoad((options: any) => {
classId.value = options?.classId ? decodeURIComponent(String(options.classId)) : '';
className.value = options?.className ? decodeURIComponent(String(options.className)) : '';
subjectIds.value = parseSubjectIds(options?.subjectIds);
startTime.value = options?.startTime ? decodeURIComponent(String(options.startTime)) : '';
endTime.value = options?.endTime ? decodeURIComponent(String(options.endTime)) : '';
fetchReport();
});
</script>
<style lang="scss" scoped>
.report-page {
min-height: 100vh;
background: linear-gradient(180deg, #eef2ff 0%, #f8fafc 60%, #ffffff 100%);
overflow: hidden;
}
.summary-card {
margin: 20rpx 28rpx 0;
padding: 24rpx 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.summary-title {
display: block;
color: #312e81;
font-size: 32rpx;
font-weight: 800;
}
.summary-desc {
display: block;
margin-top: 6rpx;
color: #64748b;
font-size: 24rpx;
}
.summary-action {
padding: 12rpx 22rpx;
border-radius: 999rpx;
color: #4f46e5;
background: #eef2ff;
font-size: 24rpx;
font-weight: 700;
flex-shrink: 0;
}
.report-scroll {
height: calc(100vh - 160rpx);
}
.content {
padding: 20rpx 28rpx 0;
}
.overview-card {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10rpx;
padding: 28rpx 16rpx;
margin-bottom: 20rpx;
}
.overview-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.overview-value {
color: #4f46e5;
font-size: 40rpx;
font-weight: 900;
}
.overview-label {
color: #64748b;
font-size: 22rpx;
}
.student-card {
padding: 28rpx;
margin-bottom: 20rpx;
}
.student-head {
display: flex;
align-items: center;
gap: 20rpx;
}
.avatar {
width: 82rpx;
height: 82rpx;
border-radius: 28rpx;
background: linear-gradient(180deg, #818cf8 0%, #4f46e5 100%);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
font-weight: 900;
flex-shrink: 0;
}
.student-main {
flex: 1;
min-width: 0;
}
.student-name {
display: block;
color: #312e81;
font-size: 30rpx;
font-weight: 800;
}
.progress-row {
margin-top: 12rpx;
display: flex;
align-items: center;
gap: 14rpx;
}
.progress-bar {
flex: 1;
height: 14rpx;
border-radius: 999rpx;
background: #eef2ff;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 999rpx;
background: #f59e0b;
&.full {
background: #22c55e;
}
}
.progress-text {
width: 72rpx;
text-align: right;
color: #4f46e5;
font-size: 24rpx;
font-weight: 800;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14rpx;
margin-top: 24rpx;
}
.stat-item {
padding: 18rpx 0;
border-radius: 22rpx;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
&.done .stat-value {
color: #16a34a;
}
&.undone .stat-value {
color: #ef4444;
}
}
.stat-value {
color: #312e81;
font-size: 34rpx;
font-weight: 900;
}
.stat-label {
color: #64748b;
font-size: 22rpx;
}
.subject-wrap {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-top: 22rpx;
}
.subject-chip {
display: flex;
gap: 10rpx;
padding: 12rpx 18rpx;
border-radius: 999rpx;
color: #475569;
background: #f8fafc;
border: 2rpx solid #e2e8f0;
font-size: 22rpx;
&.full {
color: #16a34a;
background: #f0fdf4;
border-color: #bbf7d0;
}
}
.state-card {
height: 520rpx;
display: flex;
align-items: center;
justify-content: center;
&.in-scroll {
height: 420rpx;
}
}
.bottom-space {
height: calc(60rpx + env(safe-area-inset-bottom));
}
</style>

View File

@ -0,0 +1,639 @@
<template>
<view class="teacher-page">
<view class="bg-decor bg-decor-a"></view>
<view class="bg-decor bg-decor-b"></view>
<scroll-view class="page-scroll" scroll-y enhanced :show-scrollbar="false">
<view class="content">
<view class="profile-card clay-card">
<image class="avatar" :src="avatarUrl" mode="aspectFill" />
<view class="profile-main">
<text class="hello">您好{{ teacherName }}</text>
<text class="school ellipsis">{{ teacherInfo.schoolName || '欢迎使用学小乐AI教师端' }}</text>
</view>
<view class="logout-btn" @click="handleLogout">退出</view>
</view>
<view class="quick-grid">
<view class="quick-card clay-card" @click="goReportFilter">
<view class="quick-icon">
<text class="quick-icon-text"></text>
</view>
<text class="quick-title">学情报告</text>
<text class="quick-desc">查看班级作业完成情况</text>
</view>
<view class="quick-card clay-card" @click="goWeakFilter">
<view class="quick-icon orange">
<text class="quick-icon-text orange"></text>
</view>
<text class="quick-title">薄弱知识点</text>
<text class="quick-desc">定位 Top10 高频薄弱点</text>
</view>
</view>
<view class="filter-card clay-card">
<view class="section-head">
<view>
<text class="section-title">AI 批改看板</text>
<text class="section-sub">按班级学科和日期查看效率数据</text>
</view>
</view>
<picker :range="classNames" :value="classPickerIndex" @change="onClassPickerChange">
<view class="picker-row">
<text class="picker-label">班级</text>
<text class="picker-value ellipsis">{{ selectedClassName || '请选择班级' }}</text>
</view>
</picker>
<scroll-view v-if="subjectList.length" class="subject-scroll" scroll-x enhanced :show-scrollbar="false">
<view class="subject-list">
<view
v-for="item in subjectList"
:key="item.value"
class="subject-pill"
:class="{ active: selectedSubjectIds.includes(item.value) }"
@click="toggleSubject(item.value)"
>
{{ item.label }}
</view>
</view>
</scroll-view>
<view class="date-row">
<picker mode="date" :value="startTime" @change="onStartChange">
<view class="date-box">{{ startTime || '开始日期' }}</view>
</picker>
<text class="date-sep"></text>
<picker mode="date" :value="endTime" @change="onEndChange">
<view class="date-box">{{ endTime || '结束日期' }}</view>
</picker>
</view>
<view class="query-btn clay-button" @click="fetchOverview">查询数据</view>
</view>
<view v-if="loading" class="state-card">
<xy-Loading text="加载看板中..." />
</view>
<view v-else-if="!selectedClassId" class="state-card">
<xy-Empty type="task" content="请选择班级查看看板" />
</view>
<view v-else-if="errorMsg" class="state-card">
<xy-Empty type="network" :content="errorMsg" />
</view>
<view v-else-if="overviewData" class="overview">
<view class="metric-grid">
<view class="metric-card clay-card">
<text class="metric-value">{{ overviewData.aiPaperCount || 0 }}</text>
<text class="metric-label">批改试卷</text>
</view>
<view class="metric-card clay-card">
<text class="metric-value">{{ overviewData.aiTitleCount || 0 }}</text>
<text class="metric-label">批改题目</text>
</view>
</view>
<view class="save-card clay-card">
<text class="save-title">节省批阅时间</text>
<text class="save-text">{{ saveTimeText }}</text>
</view>
<view class="grade-card clay-card">
<view class="section-head compact">
<text class="section-title">成绩分布</text>
</view>
<view class="grade-list">
<view v-for="item in gradeItems" :key="item.label" class="grade-item">
<view class="grade-top">
<text class="grade-name">{{ item.label }}</text>
<text class="grade-count">{{ item.count }} · {{ formatRate(item.rate) }}</text>
</view>
<view class="grade-bar">
<view class="grade-fill" :class="item.type" :style="{ width: rateWidth(item.rate) }"></view>
</view>
</view>
</view>
</view>
</view>
<view class="bottom-space"></view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import { useTeacherStore } from '@/state/modules/teacher';
import { useUserStore } from '@/state/modules/user';
import {
getAiCorrectionOverview,
getClassListByTeacher,
getSubjectListByClassId,
type AiCorrectionOverview,
} from '@/api/teacher';
const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com';
const defaultAvatar = `${OSS_URL}/urm/logo.png`;
const teacherStore = useTeacherStore();
const userStore = useUserStore();
const { teacherInfo, schoolId } = storeToRefs(teacherStore);
const classList = ref<{ id: string; name: string }[]>([]);
const classPickerIndex = ref(0);
const selectedClassId = ref('');
const subjectList = ref<{ value: number; label: string }[]>([]);
const selectedSubjectIds = ref<number[]>([]);
const loading = ref(false);
const errorMsg = ref('');
const overviewData = ref<AiCorrectionOverview | null>(null);
const avatarUrl = computed(() => teacherInfo.value.avatarUrl || teacherInfo.value.avatar || defaultAvatar);
const teacherName = computed(() => teacherInfo.value.nickName || teacherInfo.value.name || teacherInfo.value.account || '老师');
const classNames = computed(() => classList.value.map((item) => item.name));
const selectedClassName = computed(() => classList.value.find((item) => item.id === selectedClassId.value)?.name || '');
const getTodayStr = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const startTime = ref(getTodayStr());
const endTime = ref(getTodayStr());
const saveTimeText = computed(() => {
const mins = overviewData.value?.teacherSaveTimeMinutes || 0;
if (mins <= 0) return 'AI 小助手已准备好为您分担批阅工作。';
if (mins >= 1440) return `AI 小助手已为您节省 ${(mins / 1440).toFixed(1).replace(/\.0$/, '')} 天。`;
if (mins >= 60) return `AI 小助手已为您节省 ${(mins / 60).toFixed(1).replace(/\.0$/, '')} 小时。`;
return `AI 小助手已为您节省 ${mins} 分钟。`;
});
const gradeItems = computed(() => {
const data = overviewData.value;
return [
{ label: '优秀', count: data?.excellentCount || 0, rate: data?.excellentRate || 0, type: 'excellent' },
{ label: '良好', count: data?.goodCount || 0, rate: data?.goodRate || 0, type: 'good' },
{ label: '合格', count: data?.passCount || 0, rate: data?.passRate || 0, type: 'pass' },
{ label: '未提交', count: data?.unsubmittedCount || 0, rate: data?.unsubmittedRate || 0, type: 'unsubmit' },
];
});
const normalizeRate = (rate?: number | null) => {
if (!rate) return 0;
return rate > 1 ? rate : rate * 100;
};
const formatRate = (rate?: number | null) => `${normalizeRate(rate).toFixed(1).replace(/\.0$/, '')}%`;
const rateWidth = (rate?: number | null) => `${Math.min(100, Math.max(0, normalizeRate(rate)))}%`;
const normalizeClassList = (data: any) => {
const rows = Array.isArray(data) ? data : data?.rows || data?.list || [];
return rows.map((item: any) => ({
...item,
id: String(item.id ?? item.classId ?? ''),
name: item.name ?? item.className ?? '未命名班级',
})).filter((item: any) => item.id);
};
const fetchTeacherInfo = async () => {
if (!userStore.token) return;
await teacherStore.getLoginUser();
};
const fetchClassList = async () => {
const res = await getClassListByTeacher();
classList.value = normalizeClassList(res.data);
if (!selectedClassId.value && classList.value.length) {
selectedClassId.value = classList.value[0].id;
classPickerIndex.value = 0;
}
};
watch(selectedClassId, async (value) => {
subjectList.value = [];
selectedSubjectIds.value = [];
overviewData.value = null;
if (!value) return;
try {
const res = await getSubjectListByClassId(value);
const rows = Array.isArray(res.data) ? res.data : [];
subjectList.value = rows.map((item: any) => ({
value: Number(item.subjectId ?? item.id ?? item.value),
label: item.subjectName ?? item.name ?? item.label ?? '',
})).filter((item) => item.value && item.label);
const teacherSubjectId = Number(teacherInfo.value.subjectId);
if (teacherSubjectId && subjectList.value.some((item) => item.value === teacherSubjectId)) {
selectedSubjectIds.value = [teacherSubjectId];
}
} catch (err) {
console.error('获取学科失败', err);
}
fetchOverview();
});
const onClassPickerChange = (e: any) => {
const index = Number(e.detail.value);
const item = classList.value[index];
if (!item) return;
classPickerIndex.value = index;
selectedClassId.value = item.id;
};
const toggleSubject = (value: number) => {
selectedSubjectIds.value = selectedSubjectIds.value.includes(value)
? selectedSubjectIds.value.filter((item) => item !== value)
: [...selectedSubjectIds.value, value];
};
const onStartChange = (e: any) => { startTime.value = e.detail?.value || ''; };
const onEndChange = (e: any) => { endTime.value = e.detail?.value || ''; };
const fetchOverview = async () => {
if (!selectedClassId.value) return;
loading.value = true;
errorMsg.value = '';
try {
const res = await getAiCorrectionOverview({
classId: selectedClassId.value,
schoolId: schoolId.value || undefined,
subjectIds: selectedSubjectIds.value.length ? selectedSubjectIds.value : undefined,
startTime: startTime.value,
endTime: endTime.value,
});
overviewData.value = res.data;
} catch (err) {
console.error('获取看板失败', err);
errorMsg.value = '看板数据加载失败,请稍后重试';
overviewData.value = null;
} finally {
loading.value = false;
}
};
const goReportFilter = () => {
uni.navigateTo({ url: '/pages/teacher/homework/filter' });
};
const goWeakFilter = () => {
uni.navigateTo({ url: '/pages/teacher/homework/filter?target=weakKnowledge' });
};
const handleLogout = () => {
uni.showModal({
title: '提示',
content: '确定退出老师端吗?',
success: async (res) => {
if (!res.confirm) return;
teacherStore.clear();
await userStore.logout();
},
});
};
onShow(async () => {
try {
await fetchTeacherInfo();
if (!classList.value.length) await fetchClassList();
} catch (err) {
console.error('老师首页初始化失败', err);
}
});
</script>
<style lang="scss" scoped>
.teacher-page {
height: 100vh;
background: linear-gradient(180deg, #eef2ff 0%, #f5f7ff 54%, #ffffff 100%);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.bg-decor {
position: absolute;
border-radius: 50%;
filter: blur(60rpx);
pointer-events: none;
}
.bg-decor-a {
width: 360rpx;
height: 360rpx;
top: 120rpx;
right: -140rpx;
background: rgba(129, 140, 248, 0.35);
}
.bg-decor-b {
width: 320rpx;
height: 320rpx;
bottom: 140rpx;
left: -130rpx;
background: rgba(34, 197, 94, 0.18);
}
.page-scroll {
flex: 1;
height: 0;
position: relative;
z-index: 1;
}
.content {
padding: 24rpx 28rpx 0;
}
.profile-card {
display: flex;
align-items: center;
gap: 20rpx;
padding: 28rpx;
}
.avatar {
width: 96rpx;
height: 96rpx;
border-radius: 32rpx;
background: #eef2ff;
}
.profile-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.hello {
font-size: 34rpx;
font-weight: 800;
color: #312e81;
}
.school {
font-size: 24rpx;
color: #6366f1;
}
.logout-btn {
padding: 14rpx 22rpx;
border-radius: 999rpx;
color: #ef4444;
background: #fff1f2;
font-size: 24rpx;
font-weight: 600;
}
.quick-grid,
.metric-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-top: 20rpx;
}
.quick-card {
padding: 28rpx 24rpx;
}
.quick-icon {
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 18rpx;
&.orange {
background: #fff7ed;
}
}
.quick-icon-text {
color: #4f46e5;
font-size: 34rpx;
font-weight: 900;
&.orange {
color: #f97316;
}
}
.quick-title {
display: block;
font-size: 30rpx;
font-weight: 800;
color: #312e81;
}
.quick-desc {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #64748b;
}
.filter-card,
.grade-card,
.save-card {
margin-top: 20rpx;
padding: 28rpx;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 22rpx;
&.compact {
margin-bottom: 12rpx;
}
}
.section-title {
display: block;
font-size: 32rpx;
font-weight: 800;
color: #312e81;
}
.section-sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: #64748b;
}
.picker-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
border-radius: 24rpx;
background: #f8fafc;
border: 2rpx solid #e0e7ff;
}
.picker-label {
color: #64748b;
font-size: 26rpx;
}
.picker-value {
max-width: 420rpx;
color: #4f46e5;
font-size: 28rpx;
font-weight: 700;
}
.subject-scroll {
margin-top: 20rpx;
white-space: nowrap;
}
.subject-list {
display: inline-flex;
gap: 16rpx;
}
.subject-pill {
padding: 14rpx 24rpx;
border-radius: 999rpx;
background: #f8fafc;
color: #64748b;
font-size: 24rpx;
border: 2rpx solid #e2e8f0;
&.active {
background: #4f46e5;
color: #ffffff;
border-color: #4f46e5;
}
}
.date-row {
display: flex;
align-items: center;
gap: 14rpx;
margin-top: 20rpx;
}
.date-box {
min-width: 210rpx;
padding: 18rpx 20rpx;
text-align: center;
border-radius: 22rpx;
background: #f8fafc;
border: 2rpx solid #e0e7ff;
color: #4f46e5;
font-size: 24rpx;
}
.date-sep {
color: #94a3b8;
}
.query-btn {
height: 88rpx;
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 800;
}
.state-card {
height: 420rpx;
margin-top: 20rpx;
}
.metric-card {
min-height: 160rpx;
padding: 28rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.metric-value {
font-size: 52rpx;
line-height: 1;
color: #4f46e5;
font-weight: 900;
}
.metric-label {
margin-top: 14rpx;
color: #64748b;
font-size: 24rpx;
}
.save-title {
display: block;
color: #16a34a;
font-size: 26rpx;
font-weight: 800;
}
.save-text {
display: block;
margin-top: 8rpx;
color: #334155;
font-size: 26rpx;
}
.grade-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.grade-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.grade-name {
font-size: 26rpx;
color: #334155;
font-weight: 700;
}
.grade-count {
font-size: 24rpx;
color: #64748b;
}
.grade-bar {
height: 14rpx;
border-radius: 999rpx;
background: #eef2ff;
overflow: hidden;
}
.grade-fill {
height: 100%;
border-radius: 999rpx;
transition: width 240ms ease;
&.excellent { background: #22c55e; }
&.good { background: #3b82f6; }
&.pass { background: #f59e0b; }
&.unsubmit { background: #ef4444; }
}
.bottom-space {
height: calc(60rpx + env(safe-area-inset-bottom));
}
</style>

View File

@ -0,0 +1,261 @@
<template>
<view class="weak-page">
<view class="summary-card clay-card" @click="goBackFilter">
<view>
<text class="summary-title">{{ listData?.className || className || '薄弱知识点 Top10' }}</text>
<text class="summary-desc">{{ filterSummary }}</text>
</view>
<text class="summary-action">修改</text>
</view>
<view v-if="loading" class="state-card">
<xy-Loading text="分析薄弱点中..." />
</view>
<view v-else-if="errorMsg" class="state-card">
<xy-Empty type="network" :content="errorMsg" />
</view>
<scroll-view v-else class="list-scroll" scroll-y enhanced :show-scrollbar="false">
<view class="content">
<view v-if="!topList.length" class="state-card in-scroll">
<xy-Empty type="learn" content="暂无薄弱知识点数据" />
</view>
<view v-for="(item, index) in topList" :key="`${item.knowledgeId}-${index}`" class="knowledge-card clay-card">
<view class="rank" :class="`rank-${index + 1}`">{{ index + 1 }}</view>
<view class="knowledge-main">
<text class="knowledge-name ellipsis-2">{{ item.knowledgeName || '未命名知识点' }}</text>
<view class="meta-row">
<text v-if="item.subjectName" class="subject-tag">{{ item.subjectName }}</text>
<text class="meta danger"> {{ item.errorCount || 0 }} </text>
<text class="meta">涉及 {{ item.titleCount || 0 }} </text>
</view>
</view>
</view>
<view class="bottom-space"></view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import { useTeacherStore } from '@/state/modules/teacher';
import { getWeakKnowledgeTop10, type WeakKnowledgeItem, type WeakKnowledgeTop10Data } from '@/api/teacher';
const teacherStore = useTeacherStore();
const { schoolId } = storeToRefs(teacherStore);
const classId = ref('');
const className = ref('');
const subjectIds = ref<number[]>([]);
const startTime = ref('');
const endTime = ref('');
const loading = ref(false);
const errorMsg = ref('');
const listData = ref<WeakKnowledgeTop10Data | null>(null);
const topList = computed<WeakKnowledgeItem[]>(() => listData.value?.topWeakKnowledgeList || []);
const filterSummary = computed(() => {
const parts: string[] = [];
parts.push(subjectIds.value.length ? `${subjectIds.value.length} 个学科` : '全部学科');
if (startTime.value || endTime.value) {
parts.push(`${startTime.value || '...'}${endTime.value || '...'}`);
}
return parts.join(' · ');
});
const parseSubjectIds = (value: any) => {
if (!value) return [];
return decodeURIComponent(String(value)).split(',').map(Number).filter((item) => !Number.isNaN(item));
};
const fetchList = async () => {
if (!classId.value) {
errorMsg.value = '缺少班级参数,请重新筛选';
return;
}
loading.value = true;
errorMsg.value = '';
try {
const res = await getWeakKnowledgeTop10({
classId: classId.value,
schoolId: schoolId.value || undefined,
subjectIds: subjectIds.value.length ? subjectIds.value : undefined,
startTime: startTime.value || undefined,
endTime: endTime.value || undefined,
});
listData.value = res.data;
if (!className.value) className.value = res.data?.className || '';
} catch (err) {
console.error('获取薄弱知识点失败', err);
errorMsg.value = '薄弱知识点加载失败,请稍后重试';
listData.value = null;
} finally {
loading.value = false;
}
};
const goBackFilter = () => {
uni.navigateBack();
};
onLoad((options: any) => {
classId.value = options?.classId ? decodeURIComponent(String(options.classId)) : '';
className.value = options?.className ? decodeURIComponent(String(options.className)) : '';
subjectIds.value = parseSubjectIds(options?.subjectIds);
startTime.value = options?.startTime ? decodeURIComponent(String(options.startTime)) : '';
endTime.value = options?.endTime ? decodeURIComponent(String(options.endTime)) : '';
});
onShow(async () => {
try {
if (!schoolId.value) await teacherStore.getLoginUser();
} catch (err) {
console.error('刷新老师信息失败', err);
}
fetchList();
});
</script>
<style lang="scss" scoped>
.weak-page {
min-height: 100vh;
background: linear-gradient(180deg, #eef2ff 0%, #f8fafc 58%, #ffffff 100%);
overflow: hidden;
}
.summary-card {
margin: 20rpx 28rpx 0;
padding: 24rpx 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.summary-title {
display: block;
color: #312e81;
font-size: 32rpx;
font-weight: 800;
}
.summary-desc {
display: block;
margin-top: 6rpx;
color: #64748b;
font-size: 24rpx;
}
.summary-action {
padding: 12rpx 22rpx;
border-radius: 999rpx;
color: #4f46e5;
background: #eef2ff;
font-size: 24rpx;
font-weight: 700;
flex-shrink: 0;
}
.list-scroll {
height: calc(100vh - 160rpx);
}
.content {
padding: 24rpx 28rpx 0;
}
.knowledge-card {
display: flex;
align-items: flex-start;
gap: 22rpx;
padding: 28rpx;
margin-bottom: 20rpx;
}
.rank {
width: 62rpx;
height: 62rpx;
border-radius: 22rpx;
background: #c7d2fe;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 900;
flex-shrink: 0;
&.rank-1 {
background: linear-gradient(180deg, #fb7185 0%, #ef4444 100%);
}
&.rank-2 {
background: linear-gradient(180deg, #fbbf24 0%, #f97316 100%);
}
&.rank-3 {
background: linear-gradient(180deg, #60a5fa 0%, #3b82f6 100%);
}
}
.knowledge-main {
flex: 1;
min-width: 0;
}
.knowledge-name {
display: block;
color: #312e81;
font-size: 30rpx;
line-height: 1.45;
font-weight: 800;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.subject-tag,
.meta {
padding: 8rpx 14rpx;
border-radius: 999rpx;
font-size: 22rpx;
}
.subject-tag {
color: #4f46e5;
background: #eef2ff;
}
.meta {
color: #64748b;
background: #f8fafc;
&.danger {
color: #ef4444;
background: #fff1f2;
}
}
.state-card {
height: 520rpx;
display: flex;
align-items: center;
justify-content: center;
&.in-scroll {
height: 500rpx;
}
}
.bottom-space {
height: calc(60rpx + env(safe-area-inset-bottom));
}
</style>

122
src/pages/wode/index.vue Normal file
View File

@ -0,0 +1,122 @@
<template>
<vin-cell title="基础弹框" @click="baseClick"></vin-cell>
<vin-dialog
v-model:visible="visible1"
title="基础弹框"
content="这是基础弹框。"
@cancel="onCancel"
@ok="onOk"
/>
<vin-cell title="无标题弹框" @click="noTitleClick"></vin-cell>
<vin-dialog
v-model:visible="visible2"
content="这是无标题弹框。"
@cancel="onCancel"
@ok="onOk"
/>
<vin-cell title="提示弹框" @click="tipsClick"></vin-cell>
<vin-dialog
v-model:visible="visible3"
no-cancel-btn
title="温馨提示"
content="这是提示弹框。"
@cancel="onCancel"
@ok="onOk"
/>
<vin-cell title="底部按钮 垂直调用" @click="verticalClick"></vin-cell>
<vin-dialog
v-model:visible="visible5"
footer-direction="vertical"
teleport="#app"
title="温馨提示"
content="这是提示弹框。"
/>
<vin-cell title="异步关闭" @click="componentClick"></vin-cell>
<vin-dialog
title="异步关闭"
:content="closeContent"
:visible="visible4"
@cancel="onCancel"
@ok="onOkAsync"
/>
</template>
<script lang="ts">
import { ref } from 'vue';
export default {
setup() {
const visible1 = ref(false);
const visible2 = ref(false);
const visible3 = ref(false);
const visible4 = ref(false);
const visible5 = ref(false);
const closeContent = ref('');
const sleep = () => new Promise((resolve) => setTimeout(resolve, 1000));
const countDown = (second: number) => `倒计时 ${second}`;
const onCancel = () => {
console.log('event cancel');
};
const onOk = () => {
console.log('event ok');
};
const onOkAsync = () => {
sleep()
.then(() => {
closeContent.value = countDown(2);
return sleep();
})
.then(() => {
closeContent.value = countDown(1);
return sleep();
})
.then(() => {
closeContent.value = countDown(0);
})
.then(() => {
visible4.value = false;
});
};
const baseClick = (): void => {
visible1.value = true;
};
const noTitleClick = () => {
visible2.value = true;
};
const tipsClick = () => {
visible3.value = true;
};
const componentClick = () => {
closeContent.value = `点击确定时3s后关闭`;
visible4.value = true;
};
const verticalClick = () => {
visible5.value = true;
};
return {
visible1,
visible2,
visible3,
visible4,
visible5,
onCancel,
onOk,
closeContent,
onOkAsync,
baseClick,
noTitleClick,
componentClick,
tipsClick,
verticalClick,
};
},
};
</script>

971
src/pages/wrong/index.vue Normal file
View File

@ -0,0 +1,971 @@
<template>
<view class="wrong-page">
<view class="bg-blur bg-blur-a"></view>
<view class="bg-blur bg-blur-b"></view>
<scroll-view class="page-scroll" scroll-y :show-scrollbar="false">
<view class="hero">
<view>
<view class="eyebrow">错题练习</view>
<view class="hero-title">把失分题变成提分点</view>
<view class="hero-desc">按学科题型和掌握状态整理错题复习时更聚焦</view>
</view>
<view class="hero-badge">
<text class="hero-badge-num">{{ totalWrong }}</text>
<text class="hero-badge-text">道错题</text>
</view>
</view>
<view v-if="!pageLoading" class="content">
<view class="subject-grid">
<view
v-for="subject in subjectStats"
:key="subject.name"
class="subject-card"
:class="{ active: selectedSubject === subject.name }"
@click="selectSubject(subject.name)"
>
<view class="subject-icon" :class="subject.tone">
<text>{{ subject.shortName }}</text>
</view>
<view class="subject-main">
<view class="subject-name">{{ subject.name }}</view>
<view class="subject-count">{{ subject.total }} 道待复盘</view>
</view>
<view class="subject-meta">
<view>
<text>{{ subject.newCount }}</text>
<text>新增</text>
</view>
<view>
<text>{{ subject.reviewCount }}</text>
<text>复习本</text>
</view>
<view>
<text>{{ subject.masteredCount }}</text>
<text>已掌握</text>
</view>
</view>
</view>
</view>
<view class="tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
<text>{{ tab.label }}</text>
<text class="tab-count">{{ getTabCount(tab.key) }}</text>
</view>
</view>
<view class="filter-row">
<picker class="filter-picker" :range="gradeOptions" :value="gradeIndex" @change="onGradeChange">
<view class="filter-pill">
<text>{{ gradeOptions[gradeIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
<picker class="filter-picker" :range="typeOptions" :value="typeIndex" @change="onTypeChange">
<view class="filter-pill">
<text>{{ typeOptions[typeIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
<picker class="filter-picker" :range="examOptions" :value="examIndex" @change="onExamChange">
<view class="filter-pill">
<text>{{ examOptions[examIndex] }}</text>
<view class="filter-arrow"></view>
</view>
</picker>
</view>
<view v-if="filteredQuestions.length" class="question-list">
<view v-for="item in filteredQuestions" :key="item.id" class="question-card">
<view class="question-head">
<view class="question-index">
<text>{{ item.number }}</text>
<text>满分{{ item.score }}</text>
</view>
<view class="status-tag" :class="item.status">{{ getStatusText(item.status) }}</view>
</view>
<view class="exam-name">{{ item.examName }}</view>
<view class="question-title">
<rich-text :nodes="item.title" />
</view>
<view v-if="item.options.length" class="option-list">
<view v-for="option in item.options" :key="option.key" class="option-item">
<text class="option-key">{{ option.key }}</text>
<view class="option-value">
<rich-text :nodes="option.value" />
</view>
</view>
</view>
<view class="answer-tabs">
<view
class="answer-tab"
:class="{ active: item.openPanel === 'mine' }"
@click="item.openPanel = 'mine'"
>
我的作答
</view>
<view
class="answer-tab"
:class="{ active: item.openPanel === 'analysis' }"
@click="item.openPanel = 'analysis'"
>
答案&解析
</view>
<view class="answer-tab action" @click="toggleReview(item)">
{{ item.inReviewBook ? '移出复习本' : '加入复习本' }}
</view>
</view>
<view v-if="item.openPanel === 'mine'" class="info-box mine">
<view class="info-title">我的作答</view>
<view class="info-content">{{ item.myAnswer || '未作答' }}</view>
</view>
<template v-else>
<view class="info-box correct">
<view class="info-title">正确答案</view>
<view class="info-content">{{ item.answer || '暂无答案' }}</view>
</view>
<view v-if="item.analysis" class="info-box analysis">
<view class="info-title">试题解析</view>
<view class="info-content">
<rich-text :nodes="item.analysis" />
</view>
</view>
<view v-if="item.knowledge" class="info-box knowledge">
<view class="info-title">知识点</view>
<view class="info-content">{{ item.knowledge }}</view>
</view>
</template>
<view class="card-actions">
<view class="mark-btn" @click="markMastered(item)">
{{ item.status === 'mastered' ? '标为未掌握' : '标为已掌握' }}
</view>
<view class="practice-btn" @click="practiceAgain(item)">再练一次</view>
</view>
</view>
</view>
<view v-else class="empty-wrap">
<xy-Empty type="learn" content="当前筛选下暂无错题" />
</view>
</view>
<view v-else class="loading-wrap">
<xy-Empty type="loading" content="正在整理错题..." />
</view>
<view class="safe-bottom"></view>
</scroll-view>
<view class="home-float" @click="goHome">
<view class="home-roof"></view>
<view class="home-body"></view>
</view>
<view v-if="!pageLoading && filteredQuestions.length" class="bottom-bar">
<view class="download-btn" @click="downloadWrongQuestions">一键下载错题</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getListCompleteRecord, getPaperRecordDetail } from '../../api/homework';
type WrongStatus = 'new' | 'review' | 'mastered';
type TabKey = 'all' | 'new' | 'mastered';
interface WrongOption {
key: string;
value: string;
}
interface WrongQuestion {
id: string;
recordId: string;
subject: string;
grade: string;
type: string;
examName: string;
number: number;
score: number;
title: string;
options: WrongOption[];
myAnswer: string;
answer: string;
analysis: string;
knowledge: string;
status: WrongStatus;
inReviewBook: boolean;
openPanel: 'mine' | 'analysis';
}
const pageLoading = ref(true);
const wrongQuestions = ref<WrongQuestion[]>([]);
const selectedSubject = ref('全部');
const activeTab = ref<TabKey>('all');
const gradeIndex = ref(0);
const typeIndex = ref(0);
const examIndex = ref(0);
const tabs: { key: TabKey; label: string }[] = [
{ key: 'all', label: '全部错题' },
{ key: 'new', label: '未掌握' },
{ key: 'mastered', label: '已掌握' },
];
const subjectTones = ['blue', 'green', 'orange', 'rose', 'violet'];
const totalWrong = computed(() => wrongQuestions.value.length);
const subjectStats = computed(() => {
const subjects = ['全部', ...Array.from(new Set(wrongQuestions.value.map((item) => item.subject).filter(Boolean)))];
return subjects.map((name, index) => {
const list = name === '全部' ? wrongQuestions.value : wrongQuestions.value.filter((item) => item.subject === name);
return {
name,
shortName: name === '全部' ? '全' : name.slice(0, 1),
tone: subjectTones[index % subjectTones.length],
total: list.length,
newCount: list.filter((item) => item.status === 'new').length,
reviewCount: list.filter((item) => item.inReviewBook).length,
masteredCount: list.filter((item) => item.status === 'mastered').length,
};
});
});
const scopedQuestions = computed(() => {
let list = wrongQuestions.value;
if (selectedSubject.value !== '全部') {
list = list.filter((item) => item.subject === selectedSubject.value);
}
return list;
});
const gradeOptions = computed(() => ['不限年级', ...Array.from(new Set(scopedQuestions.value.map((item) => item.grade).filter(Boolean)))]);
const typeOptions = computed(() => ['不限类型', ...Array.from(new Set(scopedQuestions.value.map((item) => item.type).filter(Boolean)))]);
const examOptions = computed(() => ['选择考试', ...Array.from(new Set(scopedQuestions.value.map((item) => item.examName).filter(Boolean)))]);
const filteredQuestions = computed(() => {
const grade = gradeOptions.value[gradeIndex.value];
const type = typeOptions.value[typeIndex.value];
const exam = examOptions.value[examIndex.value];
return scopedQuestions.value.filter((item) => {
const tabMatched = activeTab.value === 'all'
|| (activeTab.value === 'new' && item.status !== 'mastered')
|| (activeTab.value === 'mastered' && item.status === 'mastered');
const gradeMatched = !grade || grade === '不限年级' || item.grade === grade;
const typeMatched = !type || type === '不限类型' || item.type === type;
const examMatched = !exam || exam === '选择考试' || item.examName === exam;
return tabMatched && gradeMatched && typeMatched && examMatched;
});
});
const getTabCount = (key: TabKey) => {
if (key === 'all') return scopedQuestions.value.length;
if (key === 'new') return scopedQuestions.value.filter((item) => item.status !== 'mastered').length;
return scopedQuestions.value.filter((item) => item.status === 'mastered').length;
};
const selectSubject = (name: string) => {
selectedSubject.value = name;
gradeIndex.value = 0;
typeIndex.value = 0;
examIndex.value = 0;
};
const onGradeChange = (e: any) => { gradeIndex.value = Number(e.detail.value); };
const onTypeChange = (e: any) => { typeIndex.value = Number(e.detail.value); };
const onExamChange = (e: any) => { examIndex.value = Number(e.detail.value); };
const normalizeHtml = (html?: string) => {
if (!html) return '';
return String(html)
.replace(/http:\/\//gi, 'https://')
.replace(/<img(?![^>]*formula-img)[^>]*\/?>/gi, '');
};
const normalizeAnswer = (value: any) => {
if (Array.isArray(value)) return value.join('、');
if (value == null) return '';
return String(value).replace(/^\[|\]$/g, '').replace(/"/g, '').replace(/,/g, '、');
};
const parseOptions = (raw: any): WrongOption[] => {
let list = raw;
if (typeof list === 'string') {
try {
list = JSON.parse(list);
} catch {
list = [];
}
}
if (!Array.isArray(list)) return [];
return list.map((item, index) => ({
key: typeof item === 'string' ? String.fromCharCode(65 + index) : (item?.key || item?.optionKey || item?.label || String.fromCharCode(65 + index)),
value: normalizeHtml(typeof item === 'string' ? item : (item?.value || item?.optionValue || '')),
})).filter((item) => item.value);
};
const getGradeText = (record: any, item: any) => {
return item.gradeName || item.grade || record.gradeName || record.grade || '未分年级';
};
const isWrongQuestion = (item: any) => {
const hasAnswer = Boolean(item.submitAnswer || item.submitAnswerPic);
const correct = item.correctResult === 1 || item.correctResult === true || item.markFlag === 1 || item.markFlag === true;
return hasAnswer && !correct;
};
const mapQuestion = (record: any, item: any, index: number): WrongQuestion => ({
id: String(item.itemId || item.id || `${record.id}-${index}`),
recordId: String(record.id || record.recordId || ''),
subject: item.subjectName || record.subjectName || '其他',
grade: getGradeText(record, item),
type: item.titleChannelTypeName || item.chanelTypeName || '综合题',
examName: record.paperName || record.jobName || item.paperName || '未命名考试',
number: Number(item.titleSort || item.sort || index + 1),
score: Number(item.titleScore || item.score || 0),
title: normalizeHtml(item.titleMu || item.title || item.pidTitle || '暂无题干'),
options: parseOptions(item.optionsMu ?? item.options),
myAnswer: normalizeAnswer(item.submitAnswer || item.submitAnswerPic),
answer: normalizeAnswer(item.answerMu || item.answer),
analysis: normalizeHtml(item.titleAnalyzeMu || item.titleAnalyze || item.aiAnalyze),
knowledge: item.knowledgeName || item.knowledge || item.knowledgePointName || '待归类',
status: 'new',
inReviewBook: false,
openPanel: 'analysis',
});
const loadWrongQuestions = async () => {
pageLoading.value = true;
try {
const recordsRes: any = await getListCompleteRecord();
const records = Array.isArray(recordsRes?.data) ? recordsRes.data : Array.isArray(recordsRes) ? recordsRes : [];
const detailRecords = await Promise.all(
records.slice(0, 20).map(async (record: any) => {
try {
const detailRes: any = await getPaperRecordDetail({
recordId: record.id || record.recordId,
reportFlag: 1,
});
return {
record,
questions: detailRes?.data?.subjectTitleVoList || [],
};
} catch (err) {
console.warn('[wrong] detail err', err);
return { record, questions: [] };
}
}),
);
wrongQuestions.value = detailRecords.reduce<WrongQuestion[]>((list, { record, questions }) => {
const current = questions
.filter(isWrongQuestion)
.map((item: any, index: number) => mapQuestion(record, item, index));
return list.concat(current);
}, []);
} catch (err) {
console.error('[wrong] load err', err);
uni.showToast({ title: '错题加载失败', icon: 'none' });
wrongQuestions.value = [];
} finally {
pageLoading.value = false;
}
};
const getStatusText = (status: WrongStatus) => {
if (status === 'mastered') return '已掌握';
if (status === 'review') return '复习中';
return '未掌握';
};
const toggleReview = (item: WrongQuestion) => {
item.inReviewBook = !item.inReviewBook;
if (item.inReviewBook && item.status === 'new') item.status = 'review';
if (!item.inReviewBook && item.status === 'review') item.status = 'new';
uni.showToast({ title: item.inReviewBook ? '已加入复习本' : '已移出复习本', icon: 'none' });
};
const markMastered = (item: WrongQuestion) => {
item.status = item.status === 'mastered' ? 'new' : 'mastered';
if (item.status === 'mastered') item.inReviewBook = false;
};
const practiceAgain = (item: WrongQuestion) => {
uni.navigateTo({
url: `/pages/doWork/index?recordId=${item.recordId}&reportFlag=1&idx=${Math.max(item.number - 1, 0)}`,
});
};
const downloadWrongQuestions = () => {
uni.showToast({ title: '下载功能对接中', icon: 'none' });
};
const goHome = () => {
uni.reLaunch({ url: '/pages/index/index' });
};
onLoad(() => {
loadWrongQuestions();
});
</script>
<style lang="scss" scoped>
.wrong-page {
position: relative;
min-height: 100vh;
background: linear-gradient(180deg, #ecfeff 0%, #f8fafc 42%, #ffffff 100%);
overflow: hidden;
}
.bg-blur {
position: absolute;
border-radius: 50%;
filter: blur(56rpx);
pointer-events: none;
z-index: 0;
}
.bg-blur-a {
top: 40rpx;
right: -120rpx;
width: 320rpx;
height: 320rpx;
background: rgba(34, 211, 238, 0.3);
}
.bg-blur-b {
top: 520rpx;
left: -140rpx;
width: 300rpx;
height: 300rpx;
background: rgba(34, 197, 94, 0.18);
}
.page-scroll {
position: relative;
z-index: 1;
height: 100vh;
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding: 40rpx 32rpx 24rpx;
}
.eyebrow {
display: inline-flex;
padding: 8rpx 18rpx;
border-radius: 999rpx;
color: #0e7490;
background: rgba(207, 250, 254, 0.9);
font-size: 22rpx;
font-weight: 800;
}
.hero-title {
margin-top: 18rpx;
color: #164e63;
font-size: 46rpx;
font-weight: 900;
line-height: 1.16;
}
.hero-desc {
max-width: 460rpx;
margin-top: 10rpx;
color: #64748b;
font-size: 24rpx;
line-height: 1.5;
}
.hero-badge {
width: 156rpx;
height: 156rpx;
border-radius: 48rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #ffffff;
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow: 0 18rpx 34rpx rgba(8, 145, 178, 0.22), inset 0 -6rpx 0 rgba(0, 0, 0, 0.08);
}
.hero-badge-num {
font-size: 46rpx;
font-weight: 900;
line-height: 1;
}
.hero-badge-text {
margin-top: 8rpx;
font-size: 22rpx;
font-weight: 700;
}
.content {
padding: 0 28rpx;
}
.subject-grid {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.subject-card {
display: grid;
grid-template-columns: 96rpx 1fr;
gap: 20rpx;
padding: 24rpx;
border: 2rpx solid rgba(226, 232, 240, 0.9);
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14rpx 28rpx rgba(8, 145, 178, 0.08);
transition: transform 160ms ease, border-color 160ms ease;
}
.subject-card:active {
transform: scale(0.98);
}
.subject-card.active {
border-color: #22d3ee;
box-shadow: 0 18rpx 34rpx rgba(8, 145, 178, 0.14);
}
.subject-icon {
grid-row: span 2;
width: 96rpx;
height: 96rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 34rpx;
font-weight: 900;
}
.subject-icon.blue { background: linear-gradient(180deg, #38bdf8, #0284c7); }
.subject-icon.green { background: linear-gradient(180deg, #4ade80, #16a34a); }
.subject-icon.orange { background: linear-gradient(180deg, #fbbf24, #f97316); }
.subject-icon.rose { background: linear-gradient(180deg, #fb7185, #e11d48); }
.subject-icon.violet { background: linear-gradient(180deg, #a78bfa, #7c3aed); }
.subject-main {
min-width: 0;
}
.subject-name {
color: #164e63;
font-size: 30rpx;
font-weight: 900;
}
.subject-count {
margin-top: 6rpx;
color: #64748b;
font-size: 22rpx;
}
.subject-meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
}
.subject-meta view {
padding: 12rpx 10rpx;
border-radius: 18rpx;
background: #f8fafc;
text-align: center;
}
.subject-meta text {
display: block;
}
.subject-meta text:first-child {
color: #0891b2;
font-size: 28rpx;
font-weight: 900;
}
.subject-meta text:last-child {
margin-top: 2rpx;
color: #64748b;
font-size: 20rpx;
}
.tabs {
display: flex;
gap: 14rpx;
margin-top: 28rpx;
padding: 8rpx;
border-radius: 999rpx;
background: rgba(226, 232, 240, 0.55);
}
.tab {
flex: 1;
height: 66rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #64748b;
font-size: 24rpx;
font-weight: 800;
}
.tab.active {
color: #ffffff;
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow: 0 10rpx 18rpx rgba(8, 145, 178, 0.18);
}
.tab-count {
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
background: rgba(255, 255, 255, 0.24);
font-size: 20rpx;
}
.filter-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14rpx;
margin-top: 22rpx;
}
.filter-picker {
min-width: 0;
}
.filter-pill {
height: 70rpx;
padding: 0 16rpx;
border: 2rpx solid #e2e8f0;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #334155;
background: rgba(255, 255, 255, 0.95);
font-size: 22rpx;
font-weight: 700;
}
.filter-pill text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-arrow {
width: 0;
height: 0;
border-left: 7rpx solid transparent;
border-right: 7rpx solid transparent;
border-top: 9rpx solid #0891b2;
flex-shrink: 0;
}
.question-list {
display: flex;
flex-direction: column;
gap: 24rpx;
margin-top: 26rpx;
}
.question-card {
padding: 28rpx;
border-radius: 34rpx;
background: #ffffff;
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.08);
}
.question-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.question-index {
display: flex;
align-items: baseline;
gap: 10rpx;
}
.question-index text:first-child {
color: #f97316;
font-size: 28rpx;
font-weight: 900;
}
.question-index text:last-child {
color: #94a3b8;
font-size: 22rpx;
}
.status-tag {
padding: 8rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 800;
}
.status-tag.new {
color: #b45309;
background: #fef3c7;
}
.status-tag.review {
color: #0369a1;
background: #e0f2fe;
}
.status-tag.mastered {
color: #15803d;
background: #dcfce7;
}
.exam-name {
margin-top: 18rpx;
color: #164e63;
font-size: 26rpx;
font-weight: 900;
line-height: 1.45;
}
.question-title {
margin-top: 20rpx;
color: #0f172a;
font-size: 28rpx;
font-weight: 700;
line-height: 1.65;
}
.question-title :deep(image),
.option-value :deep(image),
.info-content :deep(image) {
max-width: 100%;
height: auto;
}
.option-list {
margin-top: 16rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.option-item {
display: flex;
align-items: flex-start;
gap: 12rpx;
color: #334155;
font-size: 25rpx;
line-height: 1.55;
}
.option-key {
color: #0f172a;
font-weight: 900;
}
.option-value {
flex: 1;
min-width: 0;
}
.answer-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
margin-top: 24rpx;
border-top: 2rpx solid #f1f5f9;
}
.answer-tab {
min-height: 76rpx;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 24rpx;
font-weight: 800;
}
.answer-tab.active {
color: #0891b2;
}
.answer-tab.action {
color: #475569;
}
.info-box {
margin-top: 12rpx;
padding: 20rpx;
border: 2rpx solid transparent;
border-radius: 22rpx;
}
.info-box.mine {
border-color: #fde68a;
background: #fffbeb;
}
.info-box.correct {
border-color: #bbf7d0;
background: #f0fdf4;
}
.info-box.analysis {
border-color: #bae6fd;
background: #f0f9ff;
}
.info-box.knowledge {
border-color: #fde68a;
background: #fffbeb;
}
.info-title {
margin-bottom: 10rpx;
color: #164e63;
font-size: 23rpx;
font-weight: 900;
}
.info-content {
color: #334155;
font-size: 25rpx;
line-height: 1.6;
}
.card-actions {
display: flex;
gap: 16rpx;
margin-top: 22rpx;
}
.mark-btn,
.practice-btn {
flex: 1;
height: 72rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 900;
}
.mark-btn {
color: #0891b2;
background: #ecfeff;
}
.practice-btn {
color: #ffffff;
background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%);
box-shadow: 0 10rpx 18rpx rgba(22, 163, 74, 0.18);
}
.empty-wrap,
.loading-wrap {
height: 560rpx;
}
.home-float {
position: fixed;
right: 32rpx;
bottom: 144rpx;
z-index: 10;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #38bdf8, #0284c7);
box-shadow: 0 12rpx 24rpx rgba(2, 132, 199, 0.28);
}
.home-roof {
position: absolute;
top: 27rpx;
width: 32rpx;
height: 32rpx;
border-left: 5rpx solid #ffffff;
border-top: 5rpx solid #ffffff;
transform: rotate(45deg);
}
.home-body {
width: 34rpx;
height: 28rpx;
margin-top: 20rpx;
border: 5rpx solid #ffffff;
border-top: 0;
border-radius: 0 0 6rpx 6rpx;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9;
padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 -10rpx 24rpx rgba(15, 23, 42, 0.08);
}
.download-btn {
height: 84rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%);
font-size: 28rpx;
font-weight: 900;
}
.safe-bottom {
height: 150rpx;
}
</style>

View File

@ -0,0 +1,53 @@
/**
* 访
*/
import type { Router } from 'uni-mini-router';
import { useUserStore } from '@/state/modules/user';
import { getCache } from '@/utils/cache';
// 免登录白名单(路由 name
const whiteList = ['home', 'login', 'parent-home', 'parent-reports', 'parent-profile'];
export function userRouternext(router: Router) {
router.beforeEach((to, _from, next) => {
const userStore = useUserStore();
if (userStore.isLogin) {
const currentRole = getCache('role');
const targetRole = (to as any).meta?.role;
if (currentRole === 'teacher' && to.name === 'home') {
return next({
name: 'teacher-home',
});
}
if (currentRole === 'parent' && to.name === 'home') {
return next({
name: 'parent-home',
});
}
if (targetRole && currentRole && targetRole !== currentRole) {
return next({
name: currentRole === 'teacher' ? 'teacher-home' : currentRole === 'parent' ? 'parent-home' : 'home',
});
}
return next();
}
if (whiteList.includes(String(to.name))) {
return next();
}
// 未登录跳转到登录页,并保留目标地址
return next({
name: 'login',
query: { redirect: to.fullPath || (to.name as string) },
});
});
router.afterEach((to) => {
const userStore = useUserStore();
if (userStore.isLogin && to?.name === 'login') {
const role = getCache('role');
if (!role) return;
router.replaceAll({ name: role === 'teacher' ? 'teacher-home' : role === 'parent' ? 'parent-home' : 'home' });
}
});
}

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

@ -0,0 +1,9 @@
import { createRouter } from 'uni-mini-router';
import { userRouternext } from './Routeinterception';
const router = createRouter({
routes: [...ROUTES], // 路由表信息
});
userRouternext(router); //使用路由拦截
export default router;

6
src/shime-uni.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
export {};
declare module 'vue' {
type Hooks = App.AppInstance & Page.PageInstance;
interface ComponentCustomOptions extends Hooks {}
}

22
src/state/app.ts Normal file
View File

@ -0,0 +1,22 @@
import { defineStore } from 'pinia';
interface AppState {
conunt: number;
}
export const useAppStore = defineStore({
id: 'app-store',
state: (): AppState => ({
conunt: 1,
}),
getters: {
getNum(): number {
return this.conunt;
},
},
actions: {
addnum() {
this.conunt++;
},
},
});

5
src/state/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { createPinia } from 'pinia';
const store = createPinia();
export default store;

View File

@ -0,0 +1,47 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { getLoginUser as getLoginUserApi, type TeacherUserInfo } from '@/api/teacher';
import { setCache, getCache, removeCache } from '@/utils/cache';
export type TeacherInfo = TeacherUserInfo;
/**
* store -
*/
export const useTeacherStore = defineStore('teacher', () => {
const teacherInfo = ref<TeacherInfo>(getCache('teacherInfo') || {});
const isTeacherLoggedIn = computed(() => !!teacherInfo.value?.id);
const schoolId = computed(() => String(teacherInfo.value?.schoolId || ''));
/** 拉取老师身份的当前登录用户 */
const getLoginUser = async () => {
try {
const res = await getLoginUserApi();
teacherInfo.value = res.data || {};
setCache('teacherInfo', teacherInfo.value);
return teacherInfo.value;
} catch (e) {
console.error('[teacher] getLoginUser error', e);
throw e;
}
};
const updateTeacherInfo = (info: Partial<TeacherInfo>) => {
teacherInfo.value = { ...teacherInfo.value, ...info };
setCache('teacherInfo', teacherInfo.value);
};
const clear = () => {
teacherInfo.value = {};
removeCache('teacherInfo');
};
return {
teacherInfo,
isTeacherLoggedIn,
schoolId,
getLoginUser,
updateTeacherInfo,
clear,
};
});

119
src/state/modules/user.ts Normal file
View File

@ -0,0 +1,119 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { setCache, getCache, removeCache } from '@/utils/cache';
import {
psdLogin as psdLoginApi,
getUserInfo as getUserInfoApi,
logout as logoutApi,
type AccountLoginParams,
} from '@/api/login';
export interface UserInfo {
id?: string;
account?: string;
nickName?: string;
avatarUrl?: string;
learnGradeId?: string;
schoolName?: string;
className?: string;
classId?: string;
diamond?: number;
experience?: number;
schoolClass?: any[];
[key: string]: any;
}
/**
* store
*/
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo>(getCache('userInfo') || {});
const token = ref<string>(getCache('token') || '');
const isLogin = computed(() => !!token.value);
const gradeId = computed(() => userInfo.value.learnGradeId || '');
/** 清空登录态与缓存 */
const clear = async () => {
token.value = '';
userInfo.value = {};
removeCache('token');
removeCache('userInfo');
removeCache('teacherInfo');
removeCache('role');
};
/** 拉取并解析用户信息 */
const fetchUserInfo = async () => {
try {
const res = await getUserInfoApi();
const info: UserInfo = res.data || {};
// schoolClass 字段后端有时是 JSON 字符串
if (typeof info.schoolClass === 'string') {
try {
info.schoolClass = JSON.parse(info.schoolClass);
} catch {
info.schoolClass = [];
}
}
// 提取学校班级到顶层方便展示
if (Array.isArray(info.schoolClass) && info.schoolClass.length) {
const school = info.schoolClass[0];
info.schoolName = school?.schoolName || '';
const cls = school?.classList?.[0];
if (cls) {
info.className = cls.className;
info.classId = cls.classId;
}
}
userInfo.value = info;
setCache('userInfo', info);
return info;
} catch (err) {
console.error('[user] fetchUserInfo error', err);
throw err;
}
};
/** 账号密码登录 */
const accountLogin = async (data: AccountLoginParams) => {
await clear();
setCache('role', data.clientType === 'MANAGE' ? 'teacher' : 'student');
const res = await psdLoginApi(data);
token.value = res.data;
setCache('token', res.data);
await fetchUserInfo();
};
/** 退出登录 */
const logout = async () => {
try {
await logoutApi();
} catch (e) {
console.warn('[user] logout api error', e);
}
await clear();
uni.reLaunch({ url: '/pages/index/index' });
};
/** 部分更新本地用户信息 */
const updateUserInfo = (info: Partial<UserInfo>) => {
userInfo.value = { ...userInfo.value, ...info };
setCache('userInfo', userInfo.value);
};
return {
userInfo,
token,
isLogin,
gradeId,
accountLogin,
fetchUserInfo,
logout,
clear,
updateUserInfo,
};
});

BIN
src/static/back.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

110
src/style/global.scss Normal file
View File

@ -0,0 +1,110 @@
/* ==========================================
学小乐 AI · 全局样式
设计风格: Claymorphism (软立体 / 童趣)
主色: Indigo + Green
========================================== */
/* ----------- CSS 变量 ----------- */
page {
--primary: #4f46e5;
--primary-light: #818cf8;
--primary-soft: #c7d2fe;
--primary-bg: #eef2ff;
--cta: #22c55e;
--cta-light: #4ade80;
--warning: #f59e0b;
--danger: #ef4444;
--text: #312e81;
--text-secondary: #6366f1;
--text-muted: #94a3b8;
--bg: #f5f7ff;
--card: #ffffff;
--border: #e0e7ff;
background-color: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', PingFangSC-Regular,
'Helvetica Neue', Helvetica, Arial, 'Microsoft YaHei', sans-serif;
font-size: 28rpx;
line-height: 1.5;
}
/* ----------- 通用工具类 ----------- */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-center { display: flex; align-items: center; justify-content: center; }
.flex-between { display: flex; align-items: center; justify-content: space-between; }
.flex-1 { flex: 1; min-width: 0; min-height: 0; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.gap-12 { gap: 12rpx; }
.gap-16 { gap: 16rpx; }
.gap-24 { gap: 24rpx; }
/* 单行省略 */
.ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 多行省略 */
.ellipsis-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
/* ----------- Claymorphism 卡片基础类 ----------- */
.clay-card {
background: var(--card);
border-radius: 32rpx;
box-shadow:
0 8rpx 24rpx rgba(99, 102, 241, 0.08),
0 2rpx 8rpx rgba(99, 102, 241, 0.04),
inset 0 -2rpx 0 rgba(199, 210, 254, 0.4);
}
.clay-button {
border-radius: 999rpx;
background: linear-gradient(180deg, #6366f1 0%, #4f46e5 100%);
color: #fff;
box-shadow:
0 8rpx 16rpx rgba(79, 70, 229, 0.3),
inset 0 -4rpx 0 rgba(0, 0, 0, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.3);
transition: all 200ms ease-out;
&:active {
transform: translateY(2rpx);
box-shadow:
0 4rpx 8rpx rgba(79, 70, 229, 0.25),
inset 0 -2rpx 0 rgba(0, 0, 0, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.3);
}
}
/* ----------- 滚动条隐藏 ----------- */
::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
-webkit-appearance: none;
background: transparent;
}
/* ----------- 重置 ----------- */
view, text, button, input {
box-sizing: border-box;
}
button {
margin: 0;
padding: 0;
background: transparent;
&::after {
border: none;
}
}

3
src/style/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

2
src/type.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare type anyObj = Record<string, any>;
declare const ROUTES: any[];

76
src/uni.scss Normal file
View File

@ -0,0 +1,76 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;

View File

@ -0,0 +1,320 @@
## 2.5.0-202301012023-01-01
- 秋云图表组件 修改条件编译顺序确保uniapp的cli方式的项目依赖不完整时可以正常显示
- 秋云图表组件 恢复props属性directory的使用以修复vue3项目中开启echarts后echarts目录识别错误的bug
- uCharts.js 修复区域图、混合图只有一个数据时图表显示不正确的bug
- uCharts.js 修复折线图、区域图中时间轴类别图表tooltip指示点显示不正确的bug
- uCharts.js 修复x轴使用labelCount时并且boundaryGap = 'justify' 并且关闭Y轴显示的时候最后一个坐标值不显示的bug
- uCharts.js 修复折线图只有一组数据时 ios16 渲染颜色不正确的bug
- uCharts.js 修复玫瑰图半径显示不正确的bug
- uCharts.js 柱状图、山峰图增加正负图功能y轴网格如果需要显示0轴则由 min max 及 splitNumber 确定后续版本优化自动显示0轴
- uCharts.js 柱状图column增加 opts.extra.column.labelPosition数据标签位置有效值为 outside外部, insideTop内顶部, center内中间, bottom内底部
- uCharts.js 雷达图radar增加 opts.extra.radar.labelShow否显示各项标识文案是默认true
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.boxPadding提示窗边框填充距离默认3px
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.fontSize提示窗字体大小配置默认13px
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.lineHeight提示窗文字行高默认20px
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.legendShow是否显示左侧图例默认true
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.legendShape图例形状图例标识样式有效值为 auto自动跟随图例, diamond◆, circle●, triangle▲, square■, rect▬, line-
- uCharts.js 标记线markLine增加 opts.extra.markLine.labelFontSize字体大小配置默认13px
- uCharts.js 标记线markLine增加 opts.extra.markLine.labelPadding标签边框内填充距离默认6px
- uCharts.js 折线图line增加 opts.extra.line.linearType渐变色类型可选值 none关闭渐变色custom 自定义渐变色。使用自定义渐变色时请赋值serie.linearColor作为颜色值
- uCharts.js 折线图line增加 serie.linearColor渐变色数组格式为2维数组[起始位置,颜色值],例如[[0,'#0EE2F8'],[0.3,'#2BDCA8'],[0.6,'#1890FF'],[1,'#9A60B4']]
- uCharts.js 折线图line增加 opts.extra.line.onShadow是否开启折线阴影开启后请赋值serie.setShadow阴影设置
- uCharts.js 折线图line增加 serie.setShadow阴影配置格式为4位数组[offsetX,offsetY,blur,color]
- uCharts.js 折线图line增加 opts.extra.line.animation动画效果方向可选值为vertical 垂直动画效果horizontal 水平动画效果
- uCharts.js X轴xAxis增加 opts.xAxis.lineHeightX轴字体行高默认20px
- uCharts.js X轴xAxis增加 opts.xAxis.marginTopX轴文字距离轴线的距离默认0px
- uCharts.js X轴xAxis增加 opts.xAxis.title当前X轴标题
- uCharts.js X轴xAxis增加 opts.xAxis.titleFontSize标题字体大小默认13px
- uCharts.js X轴xAxis增加 opts.xAxis.titleOffsetY标题纵向偏移距离负数为向上偏移正数向下偏移
- uCharts.js X轴xAxis增加 opts.xAxis.titleOffsetX标题横向偏移距离负数为向左偏移正数向右偏移
- uCharts.js X轴xAxis增加 opts.xAxis.titleFontColor标题字体颜色默认#666666
## 报错TypeError: Cannot read properties of undefined (reading 'length')
- 如果是uni-modules版本组件请先登录HBuilderX账号
- 在HBuilderX中的manifest.json点击重新获取uniapp的appid或者删除appid重新粘贴重新运行
- 如果是cli项目请使用码云上的非uniCloud版本组件
- 或者添加uniCloud的依赖
- 或者使用原生uCharts
## 2.4.5-202211302022-11-30
- uCharts.js 优化tooltip当文字很多变为左侧显示时如果画布仍显显示不下提示框错位置变为以左侧0位置起画
- uCharts.js 折线图修复特殊情况下只有单点数据并改变线宽后点变为圆形的bug
- uCharts.js 修复Y轴disabled启用后无效并报错的bug
- uCharts.js 修复仪表盘起始结束角度特殊情况下显示不正确的bug
- uCharts.js 雷达图新增参数 opts.extra.radar.radius , 自定义雷达图半径
- uCharts.js 折线图、区域图增加tooltip指示点opts.extra.line.activeType/opts.extra.area.activeType可选值"none"不启用激活指示点,"hollow"空心点模式,"solid"实心点模式
## 2.4.4-202211022022-11-02
- 秋云图表组件 修复使用echarts时reload、reshow无法调用重新渲染的bug[详见码云PR](https://gitee.com/uCharts/uCharts/pulls/40)
- 秋云图表组件 修复使用echarts时初始化时宽高不正确的bug[详见码云PR](https://gitee.com/uCharts/uCharts/pulls/42)
- 秋云图表组件 修复uniapp的h5使用history模式时无法加载echarts的bug
- 秋云图表组件 小程序端@complete@scrollLeft@scrollRight@getTouchStart@getTouchMove@getTouchEnd事件增加opts参数传出,方便一些特殊需求的交互获取数据。
- uCharts.js 修复calTooltipYAxisData方法内formatter格式化方法未与y轴方法同步的问题[详见码云PR](https://gitee.com/uCharts/uCharts/pulls/43)
- uCharts.js 地图新增参数opts.series[i].fillOpacity以透明度方式来设置颜色过度效果[详见码云PR](https://gitee.com/uCharts/uCharts/pulls/38)
- uCharts.js 地图新增参数opts.extra.map.active是否启用点击激活变色
- uCharts.js 地图新增参数opts.extra.map.activeTextColor是否启用点击激活变色
- uCharts.js 地图新增渲染完成事件renderComplete
- uCharts.js 漏斗图修复当部分数据相同时tooltip提示窗点击错误的bug
- uCharts.js 漏斗图新增参数series.data[i].centerText 居中标签文案
- uCharts.js 漏斗图新增参数series.data[i].centerTextSize 居中标签文案字体大小默认opts.fontSize
- uCharts.js 漏斗图新增参数series.data[i].centerTextColor 居中标签文案字体颜色,默认#FFFFFF
- uCharts.js 漏斗图新增参数opts.extra.funnel.minSize 最小值的最小宽度默认0
- uCharts.js 进度条新增参数opts.extra.arcbar.direction动画方向可选值为cw顺时针、ccw逆时针
- uCharts.js 混合图新增参数opts.extra.mix.line.width折线的宽度默认2
- uCharts.js 修复tooltip开启horizentalLine水平横线标注时图表显示错位的bug
- uCharts.js 优化tooltip当文字很多变为左侧显示时如果画布仍显显示不下提示框错位置变为以左侧0位置起画
- uCharts.js 修复开启滚动条后X轴文字超出绘图区域后的隐藏逻辑
- uCharts.js 柱状图、条状图修复堆叠模式不能通过{value,color}赋值单个柱子颜色的问题
- uCharts.js 气泡图修复不识别series.textSize和series.textColor的bug
## 报错TypeError: Cannot read properties of undefined (reading 'length')
1. 如果是uni-modules版本组件请先登录HBuilderX账号
2. 在HBuilderX中的manifest.json点击重新获取uniapp的appid或者删除appid重新粘贴重新运行
3. 如果是cli项目请使用码云上的非uniCloud版本组件
4. 或者添加uniCloud的依赖
5. 或者使用原生uCharts
## 2.4.3-202205052022-05-05
- 秋云图表组件 修复开启canvas2d后将series赋值为空数组显示加载图标时再次赋值后画布闪动的bug
- 秋云图表组件 修复升级hbx最新版后ECharts的highlight方法报错的bug
- uCharts.js 雷达图新增参数opts.extra.radar.gridEval数据点位网格抽希默认1
- uCharts.js 雷达图新增参数opts.extra.radar.axisLabel 是否显示刻度点值默认false
- uCharts.js 雷达图新增参数opts.extra.radar.axisLabelTofix刻度点值小数位数默认0
- uCharts.js 雷达图新增参数opts.extra.radar.labelPointShow是否显示末端刻度圆点默认false
- uCharts.js 雷达图新增参数opts.extra.radar.labelPointRadius刻度圆点的半径默认3
- uCharts.js 雷达图新增参数opts.extra.radar.labelPointColor刻度圆点的颜色默认#cccccc
- uCharts.js 雷达图新增参数opts.extra.radar.linearType渐变色类型可选值"none"关闭渐变,"custom"开启渐变
- uCharts.js 雷达图新增参数opts.extra.radar.customColor自定义渐变颜色数组类型对应series的数组长度以匹配不同series颜色的不同配色方案例如["#FA7D8D", "#EB88E2"]
- uCharts.js 雷达图优化支持series.textColor、series.textSize属性
- uCharts.js 柱状图中温度计式图标优化支持全圆角类型修复边框有缝隙的bug详见官网【演示】中的温度计图表
- uCharts.js 柱状图新增参数opts.extra.column.activeWidth当前点击柱状图的背景宽度默认一个单元格单位
- uCharts.js 混合图增加opts.extra.mix.area.gradient 区域图是否开启渐变色
- uCharts.js 混合图增加opts.extra.mix.area.opacity 区域图透明度默认0.2
- uCharts.js 饼图、圆环图、玫瑰图、漏斗图增加opts.series[0].data[i].labelText自定义标签文字避免formatter格式化的繁琐详见官网【演示】中的饼图
- uCharts.js 饼图、圆环图、玫瑰图、漏斗图增加opts.series[0].data[i].labelShow自定义是否显示某一个指示标签避免因饼图类别太多导致标签重复或者居多导致图形变形的问题详见官网【演示】中的饼图
- uCharts.js 增加opts.series[i].legendText/opts.series[0].data[i].legendText与series.name同级自定义图例显示文字的方法
- uCharts.js 优化X轴、Y轴formatter格式化方法增加形参统一为fromatter:function(value,index,opts){}
- uCharts.js 修复横屏模式下无法使用双指缩放方法的bug
- uCharts.js 修复当只有一条数据或者多条数据值相等的时候Y轴自动计算的最大值错误的bug
- 【官网模板】增加外部自定义图例与图表交互的例子,[点击跳转](https://www.ucharts.cn/v2/#/layout/info?id=2)
## 注意非unimodules 版本如因更新 hbx 至 3.4.7 导致报错如下,请到码云更新非 unimodules 版本组件,[点击跳转](https://gitee.com/uCharts/uCharts/tree/master/uni-app/uCharts-%E7%BB%84%E4%BB%B6)
> Error in callback for immediate watcher "uchartsOpts": "SyntaxError: Unexpected token u in JSON at position 0"
## 2.4.2-202204212022-04-21
- 秋云图表组件 修复HBX升级3.4.6.20220420版本后echarts报错的问题
## 2.4.2-202204202022-04-20
## 重要此版本uCharts新增了很多功能修复了诸多已知问题
- 秋云图表组件 新增onzoom开启双指缩放功能仅uCharts前提需要直角坐标系类图表类型并且ontouch为true、opts.enableScroll为true详见实例项目K线图
- 秋云图表组件 新增optsWatch是否监听opts变化关闭optsWatch后动态修改opts不会触发图表重绘
- 秋云图表组件 修复开启canvas2d功能后动态更新数据后画布闪动的bug
- 秋云图表组件 去除directory属性改为自动获取echarts.min.js路径升级不受影响
- 秋云图表组件 增加getImage()方法及@getImage事件通过ref调用getImage()方法获,触发@getImage事件获取当前画布的base64图片文件流
- 秋云图表组件 支付宝、字节跳动、飞书、快手小程序支持开启canvas2d同层渲染设置。
- 秋云图表组件 新增加【非uniCloud】版本组件避免有些不需要uniCloud的使用组件发布至小程序需要提交隐私声明问题请到码云[【非uniCloud版本】](https://gitee.com/uCharts/uCharts/tree/master/uni-app/uCharts-%E7%BB%84%E4%BB%B6)或npm[【非uniCloud版本】](https://www.npmjs.com/package/@qiun/uni-ucharts)下载使用。
- uCharts.js 新增dobuleZoom双指缩放功能
- uCharts.js 新增山峰图type="mount"数据格式为饼图类格式不需要传入categories具体详见新版官网在线演示
- uCharts.js 修复折线图当数据中存在null时tooltip报错的bug
- uCharts.js 修复饼图类当画布比较小时自动计算的半径是负数报错的bug
- uCharts.js 统一各图表类型的series.formatter格式化方法的形参为(val, index, series, opts),方便格式化时有更多参数可用
- uCharts.js 标记线功能增加labelText自定义显示文字增加labelAlign标签显示位置左侧或右侧增加标签显示位置微调labelOffsetX、labelOffsetY
- uCharts.js 修复条状图当数值很小时开启圆角后样式错误的bug
- uCharts.js 修复X轴开启disabled后X轴仍占用空间的bug
- uCharts.js 修复X轴开启滚动条并且开启rotateLabel后X轴文字与滚动条重叠的bug
- uCharts.js 增加X轴rotateAngle文字旋转自定义角度取值范围(-90至90)
- uCharts.js 修复地图文字标签层级显示不正确的bug
- uCharts.js 修复饼图、圆环图、玫瑰图当数据全部为0的时候不显示数据标签的bug
- uCharts.js 修复当opts.padding上边距为0时Y轴顶部刻度标签位置不正确的bug
## 另外我们还开发了各大原生小程序组件已发布至码云和npm
[https://gitee.com/uCharts/uCharts](https://gitee.com/uCharts/uCharts)
[https://www.npmjs.com/~qiun](https://www.npmjs.com/~qiun)
## 对于原生uCharts文档我们已上线新版官方网站详情点击下面链接进入官网
[https://www.uCharts.cn/v2/](https://www.ucharts.cn/v2/)
## 2.3.7-202201222022-01-22
## 重要使用vue3编译请使用cli模式并升级至最新依赖HbuilderX编译需要使用3.3.8以上版本
- uCharts.js 修复uni-app平台组件模式使用vue3编译到小程序报错的bug。
## 2.3.7-202201182022-01-18
## 注意使用vue3的前提是需要3.3.8.20220114-alpha版本的HBuilder
## 2.3.67-202201182022-01-18
- 秋云图表组件 组件初步支持vue3全端编译会有些问题具体详见下面修改
1. 小程序端运行时在uni_modules文件夹的qiun-data-charts.js中搜索 new uni_modules_qiunDataCharts_js_sdk_uCharts_uCharts.uCharts将.uCharts去掉。
2. 小程序端发行时在uni_modules文件夹的qiun-data-charts.js中搜索 new e.uCharts将.uCharts去掉变为 new e。
3. 如果觉得上述步骤比较麻烦如果您的项目只编译到小程序端可以修改u-charts.js最后一行导出方式将 export default uCharts;变更为 export default { uCharts: uCharts }; 这样变更后H5和App端的renderjs会有问题请开发者自行选择。此问题非组件问题请等待DC官方修复Vue3的小程序端
## 2.3.6-202201112022-01-11
- 秋云图表组件 修改组件 props 属性中的 background 默认值为 rgba(0,0,0,0)
## 2.3.6-202112012021-12-01
- uCharts.js 修复bar条状图开启圆角模式时值很小时圆角渲染错误的bug
## 2.3.5-202110142021-10-15
- uCharts.js 增加vue3的编译支持仅原生uChartsqiun-data-charts组件后续会支持请关注更新
## 2.3.4-202110122021-10-12
- 秋云图表组件 修复 mac os x 系统 mouseover 事件丢失的 bug
## 2.3.3-202107062021-07-06
- uCharts.js 增加雷达图开启数据点值opts.dataLabel的显示
## 2.3.2-202106272021-06-27
- 秋云图表组件 修复tooltipCustom个别情况下传值不正确报错TypeError: Cannot read property 'name' of undefined的bug
## 2.3.1-202106162021-06-16
- uCharts.js 修复圆角柱状图使用4角圆角时当数值过大时不正确的bug
## 2.3.0-202106122021-06-12
- uCharts.js 【重要】uCharts增加nvue兼容可在nvue项目中使用gcanvas组件渲染uCharts[详见码云uCharts-demo-nvue](https://gitee.com/uCharts/uCharts)
- 秋云图表组件 增加tapLegend属性是否开启图例点击交互事件
- 秋云图表组件 getIndex事件中增加返回uCharts实例中的opts参数以便在页面中调用参数
- 示例项目 pages/other/other.vue增加app端自定义tooltip的方法详见showOptsTooltip方法
## 2.2.1-202106032021-06-03
- uCharts.js 修复饼图、圆环图、玫瑰图当起始角度不为0时tooltip位置不准确的bug
- uCharts.js 增加温度计式柱状图开启顶部半圆形的配置
## 2.2.0-202105292021-05-29
- uCharts.js 增加条状图type="bar"
- 示例项目 pages/ucharts/ucharts.vue增加条状图的demo
## 2.1.7-202105242021-05-24
- uCharts.js 修复大数据量模式下曲线图不平滑的bug
## 2.1.6-202105232021-05-23
- 秋云图表组件 修复小程序端开启滚动条更新数据后滚动条位置不符合预期的bug
## 2.1.5-20210517022021-05-17
- uCharts.js 修复自定义Y轴min和max值为0时不能正确显示的bug
## 2.1.5-202105172021-05-17
- uCharts.js 修复Y轴自定义min和max时未按指定的最大值最小值显示坐标轴刻度的bug
## 2.1.4-202105162021-05-16
- 秋云图表组件 优化onWindowResize防抖方法
- 秋云图表组件 修复APP端uCharts更新数据时清空series显示loading图标后再显示图表图表抖动的bug
- uCharts.js 修复开启canvas2d后x轴、y轴、series自定义字体大小未按比例缩放的bug
- 示例项目 修复format-e.vue拼写错误导致app端使用uCharts渲染图表
## 2.1.3-202105132021-05-13
- 秋云图表组件 修改uCharts变更chartData数据为updateData方法支持带滚动条的数据动态打点
- 秋云图表组件 增加onWindowResize防抖方法 fix by ど誓言,如尘般染指流年づ
- 秋云图表组件 H5或者APP变更chartData数据显示loading图表时原数据闪现的bug
- 秋云图表组件 props增加errorReload禁用错误点击重新加载的方法
- uCharts.js 增加tooltip显示categoryx轴对应点位标题的功能opts.extra.tooltip.showCategory默认为false
- uCharts.js 修复mix混合图只有柱状图时tooltip的分割线显示位置不正确的bug
- uCharts.js 修复开启滚动条图表在拖动中动态打点滚动条位置不正确的bug
- uCharts.js 修复饼图类数据格式为echarts数据格式series为空数组报错的bug
- 示例项目 修改uCharts.js更新到v2.1.2版本后,@getIndex方法获取索引值变更为e.currentIndex.index
- 示例项目 pages/updata/updata.vue增加滚动条拖动更新数据动态打点的demo
- 示例项目 pages/other/other.vue增加errorReload禁用错误点击重新加载的demo
## 2.1.2-202105092021-05-09
秋云图表组件 修复APP端初始化时就传入chartData或lacaldata不显示图表的bug
## 2.1.1-202105092021-05-09
- 秋云图表组件 变更ECharts的eopts配置在renderjs内执行支持在config-echarts.js配置文件内写function配置。
- 秋云图表组件 修复APP端报错Prop being mutated: "onmouse"错误的bug。
- 秋云图表组件 修复APP端报错Error: Not FoundPage[6][-1,27] at view.umd.min.js:1的bug。
## 2.1.0-202105072021-05-07
- 秋云图表组件 修复初始化时就有数据或者数据更新的时候loading加载动画闪动的bug
- uCharts.js 修复x轴format方法categories为字符串类型时返回NaN的bug
- uCharts.js 修复series.textColor、legend.fontColor未执行全局默认颜色的bug
## 2.1.0-202105062021-05-06
- 秋云图表组件 修复极个别情况下报错item.properties undefined的bug
- 秋云图表组件 修复极个别情况下关闭加载动画reshow不起作用无法显示图表的bug
- 示例项目 pages/ucharts/ucharts.vue 增加时间轴折线图type="tline"、时间轴区域图type="tarea"、散点图type="scatter"、气泡图demotype="bubble"、倒三角形漏斗图opts.extra.funnel.type="triangle"、金字塔形漏斗图opts.extra.funnel.type="pyramid"
- 示例项目 pages/format-u/format-u.vue 增加X轴format格式化示例
- uCharts.js 升级至v2.1.0版本
- uCharts.js 修复 玫瑰图面积模式点击tooltip位置不正确的bug
- uCharts.js 修复 玫瑰图点击图例只剩一个类别显示空白的bug
- uCharts.js 修复 饼图类图点击图例其他图表tooltip位置某些情况下不准的bug
- uCharts.js 修复 x轴为矢量轴时间轴情况下点击tooltip位置不正确的bug
- uCharts.js 修复 词云图获取点击索引偶尔不准的bug
- uCharts.js 增加 直角坐标系图表X轴format格式化方法原生uCharts.js用法请使用formatter
- uCharts.js 增加 漏斗图扩展配置倒三角形opts.extra.funnel.type="triangle"金字塔形opts.extra.funnel.type="pyramid"
- uCharts.js 增加 散点图opts.type="scatter"、气泡图opts.type="bubble"
- 后期计划 完善散点图、气泡图增加markPoints标记点增加横向条状图。
## 2.0.0-202105022021-05-02
- uCharts.js 修复词云图获取点击索引不正确的bug
## 2.0.0-202105012021-05-01
- 秋云图表组件 修复QQ小程序、百度小程序在关闭动画效果情况下v-for循环使用图表显示不正确的bug
## 2.0.0-202104262021-04-26
- 秋云图表组件 修复QQ小程序不支持canvas2d的bug
- 秋云图表组件 修复钉钉小程序某些情况点击坐标计算错误的bug
- uCharts.js 增加 extra.column.categoryGap 参数柱状图类每个category点位X轴点柱子组之间的间距
- uCharts.js 增加 yAxis.data[i].titleOffsetY 参数,标题纵向偏移距离,负数为向上偏移,正数向下偏移
- uCharts.js 增加 yAxis.data[i].titleOffsetX 参数,标题横向偏移距离,负数为向左偏移,正数向右偏移
- uCharts.js 增加 extra.gauge.labelOffset 参数仪表盘标签文字径向便宜距离默认13px
## 2.0.0-20210422-22021-04-22
秋云图表组件 修复 formatterAssign 未判断 args[key] == null 的情况导致栈溢出的 bug
## 2.0.0-202104222021-04-22
- 秋云图表组件 修复H5、APP、支付宝小程序、微信小程序canvas2d模式下横屏模式的bug
## 2.0.0-202104212021-04-21
- uCharts.js 修复多行图例的情况下图例在上方或者下方时图例float为左侧或者右侧时第二行及以后的图例对齐方式不正确的bug
## 2.0.0-202104202021-04-20
- 秋云图表组件 修复微信小程序开启canvas2d模式后windows版微信小程序不支持canvas2d模式的bug
- 秋云图表组件 修改非uni_modules版本为v2.0版本qiun-data-charts组件
## 2.0.0-202104192021-04-19
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册&lt;qiun-data-charts&gt;组件请重启HBuilderX如仍不好用请重启电脑
## 如果是cli项目请尝试清理node_modules重新install还不行就删除项目再重新install。
## 此问题已于DCloud官方确认HBuilderX下个版本会修复。
## 其他图表不显示问题详见[常见问题选项卡](https://demo.ucharts.cn)
## <font color=#FF0000> 新手请先完整阅读帮助文档及常见问题3遍右侧蓝色按钮示例项目请看2遍 </font>
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在项目中的应用参见 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- uCharts.js 修复混合图中柱状图单独设置颜色不生效的bug
- uCharts.js 修复多Y轴单独设置fontSize时开启canvas2d后未对应放大字体的bug
## 2.0.0-202104182021-04-18
- 秋云图表组件 增加directory配置修复H5端history模式下如果发布到二级目录无法正确加载echarts.min.js的bug
## 2.0.0-202104162021-04-16
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册&lt;qiun-data-charts&gt;组件请重启HBuilderX如仍不好用请重启电脑
## 如果是cli项目请尝试清理node_modules重新install还不行就删除项目再重新install。
## 此问题已于DCloud官方确认HBuilderX下个版本会修复。
## 其他图表不显示问题详见[常见问题选项卡](https://demo.ucharts.cn)
## <font color=#FF0000> 新手请先完整阅读帮助文档及常见问题3遍右侧蓝色按钮示例项目请看2遍 </font>
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在项目中的应用参见 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- 秋云图表组件 修复APP端某些情况下报错`Not Found Page`的bugfix by 高级bug开发技术员
- 示例项目 修复APP端v-for循环某些情况下报错`Not Found Page`的bugfix by 高级bug开发技术员
- uCharts.js 修复非直角坐标系tooltip提示窗右侧超出未变换方向显示的bug
## 2.0.0-202104152021-04-15
- 秋云图表组件 修复H5端发布到二级目录下echarts无法加载的bug
- 秋云图表组件 修复某些情况下echarts.off('finished')移除监听事件报错的bug
## 2.0.0-202104142021-04-14
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册&lt;qiun-data-charts&gt;组件请重启HBuilderX如仍不好用请重启电脑
## 如果是cli项目请尝试清理node_modules重新install还不行就删除项目再重新install。
## 此问题已于DCloud官方确认HBuilderX下个版本会修复。
## 其他图表不显示问题详见[常见问题选项卡](https://demo.ucharts.cn)
## <font color=#FF0000> 新手请先完整阅读帮助文档及常见问题3遍右侧蓝色按钮示例项目请看2遍 </font>
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在项目中的应用参见 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- 秋云图表组件 修复H5端在cli项目下ECharts引用地址错误的bug
- 示例项目 增加ECharts的formatter用法的示例(详见示例项目format-e.vue)
- uCharts.js 增加圆环图中心背景色的配置extra.ring.centerColor
- uCharts.js 修复微信小程序安卓端柱状图开启透明色后显示不正确的bug
## 2.0.0-202104132021-04-13
- 秋云图表组件 修复百度小程序多个图表真机未能正确获取根元素dom尺寸的bug
- 秋云图表组件 修复百度小程序横屏模式方向不正确的bug
- 秋云图表组件 修改ontouch时@getTouchStart@getTouchMove@getTouchEnd的触发条件
- uCharts.js 修复饼图类数据格式series属性不生效的bug
- uCharts.js 增加时序区域图 详见示例项目中ucharts.vue
## 2.0.0-20210412-22021-04-12
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册&lt;qiun-data-charts&gt;组件请重启HBuilderX。如仍不好用请重启电脑此问题已于DCloud官方确认HBuilderX下个版本会修复。
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在uniCloudAdmin中的应用 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- 秋云图表组件 修复uCharts在APP端横屏模式下不能正确渲染的bug
- 示例项目 增加ECharts柱状图渐变色、圆角柱状图、横向柱状图条状图的示例
## 2.0.0-202104122021-04-12
- 秋云图表组件 修复created中判断echarts导致APP端无法识别改回mounted中判断echarts初始化
- uCharts.js 修复2d模式下series.textOffset未乘像素比的bug
## 2.0.0-202104112021-04-11
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册<qiun-data-charts>组件请重启HBuilderX并清空小程序开发者工具缓存。
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在uniCloudAdmin中的应用 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- uCharts.js 折线图区域图增加connectNulls断点续连的功能详见示例项目中ucharts.vue
- 秋云图表组件 变更初始化方法为created变更type2d默认值为true优化2d模式下组件初始化后dom获取不到的bug
- 秋云图表组件 修复左右布局时右侧图表点击坐标错误的bug修复tooltip柱状图自定义颜色显示object的bug
## 2.0.0-202104102021-04-10
- 修复左右布局时右侧图表点击坐标错误的bug修复柱状图自定义颜色tooltip显示object的bug
- 增加标记线及柱状图自定义颜色的demo
## 2.0.0-202104092021-04-08
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧【使用HBuilderX导入插件】即可体验DEMO演示及在线生成工具v2.0文档)[https://demo.ucharts.cn](https://demo.ucharts.cn)
## 图表组件在uniCloudAdmin中的应用 [UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- uCharts.js 修复钉钉小程序百度小程序measureText不准确的bug修复2d模式下饼图类activeRadius为按比例放大的bug
- 修复组件在支付宝小程序端点击位置不准确的bug
## 2.0.0-202104082021-04-07
- 修复组件在支付宝小程序端不能显示的bug目前支付宝小程不能点击交互后续修复
- uCharts.js 修复高分屏下柱状图类,圆弧进度条 自定义宽度不能按比例放大的bug
## 2.0.0-202104072021-04-06
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧【使用HBuilderX导入插件】即可体验DEMO演示及在线生成工具v2.0文档)[https://demo.ucharts.cn](https://demo.ucharts.cn)
## 增加 通过tofix和unit快速格式化y轴的demo add by `howcode`
## 增加 图表组件在uniCloudAdmin中的应用 [UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
## 2.0.0-202104062021-04-05
# 秋云图表组件+uCharts v2.0版本同步上线使用方法详见https://demo.ucharts.cn帮助页
## 2.0.02021-04-05
# 秋云图表组件+uCharts v2.0版本同步上线使用方法详见https://demo.ucharts.cn帮助页

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,162 @@
<template>
<view class="container loading1">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading1',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading1 {
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading1 .shape1 {
-webkit-animation: animation1shape1 0.5s ease 0s infinite alternate;
animation: animation1shape1 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, 16px);
transform: translate(16px, 16px);
}
}
@keyframes animation1shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, 16px);
transform: translate(16px, 16px);
}
}
.loading1 .shape2 {
-webkit-animation: animation1shape2 0.5s ease 0s infinite alternate;
animation: animation1shape2 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, 16px);
transform: translate(-16px, 16px);
}
}
@keyframes animation1shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, 16px);
transform: translate(-16px, 16px);
}
}
.loading1 .shape3 {
-webkit-animation: animation1shape3 0.5s ease 0s infinite alternate;
animation: animation1shape3 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, -16px);
transform: translate(16px, -16px);
}
}
@keyframes animation1shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, -16px);
transform: translate(16px, -16px);
}
}
.loading1 .shape4 {
-webkit-animation: animation1shape4 0.5s ease 0s infinite alternate;
animation: animation1shape4 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, -16px);
transform: translate(-16px, -16px);
}
}
@keyframes animation1shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, -16px);
transform: translate(-16px, -16px);
}
}
</style>

View File

@ -0,0 +1,170 @@
<template>
<view class="container loading2">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading2',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading2 {
-webkit-transform: rotate(10deg);
transform: rotate(10deg);
}
.container.loading2 .shape {
border-radius: 5px;
}
.container.loading2{
-webkit-animation: rotation 1s infinite;
animation: rotation 1s infinite;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading2 .shape1 {
-webkit-animation: animation2shape1 0.5s ease 0s infinite alternate;
animation: animation2shape1 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, 20px);
transform: translate(20px, 20px);
}
}
@keyframes animation2shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, 20px);
transform: translate(20px, 20px);
}
}
.loading2 .shape2 {
-webkit-animation: animation2shape2 0.5s ease 0s infinite alternate;
animation: animation2shape2 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, 20px);
transform: translate(-20px, 20px);
}
}
@keyframes animation2shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, 20px);
transform: translate(-20px, 20px);
}
}
.loading2 .shape3 {
-webkit-animation: animation2shape3 0.5s ease 0s infinite alternate;
animation: animation2shape3 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, -20px);
transform: translate(20px, -20px);
}
}
@keyframes animation2shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, -20px);
transform: translate(20px, -20px);
}
}
.loading2 .shape4 {
-webkit-animation: animation2shape4 0.5s ease 0s infinite alternate;
animation: animation2shape4 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, -20px);
transform: translate(-20px, -20px);
}
}
@keyframes animation2shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, -20px);
transform: translate(-20px, -20px);
}
}
</style>

View File

@ -0,0 +1,173 @@
<template>
<view class="container loading3">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading3',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading3 {
-webkit-animation: rotation 1s infinite;
animation: rotation 1s infinite;
}
.container.loading3 .shape1 {
border-top-left-radius: 10px;
}
.container.loading3 .shape2 {
border-top-right-radius: 10px;
}
.container.loading3 .shape3 {
border-bottom-left-radius: 10px;
}
.container.loading3 .shape4 {
border-bottom-right-radius: 10px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading3 .shape1 {
-webkit-animation: animation3shape1 0.5s ease 0s infinite alternate;
animation: animation3shape1 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, 5px);
transform: translate(5px, 5px);
}
}
@keyframes animation3shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, 5px);
transform: translate(5px, 5px);
}
}
.loading3 .shape2 {
-webkit-animation: animation3shape2 0.5s ease 0s infinite alternate;
animation: animation3shape2 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, 5px);
transform: translate(-5px, 5px);
}
}
@keyframes animation3shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, 5px);
transform: translate(-5px, 5px);
}
}
.loading3 .shape3 {
-webkit-animation: animation3shape3 0.5s ease 0s infinite alternate;
animation: animation3shape3 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, -5px);
transform: translate(5px, -5px);
}
}
@keyframes animation3shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, -5px);
transform: translate(5px, -5px);
}
}
.loading3 .shape4 {
-webkit-animation: animation3shape4 0.5s ease 0s infinite alternate;
animation: animation3shape4 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, -5px);
transform: translate(-5px, -5px);
}
}
@keyframes animation3shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, -5px);
transform: translate(-5px, -5px);
}
}
</style>

View File

@ -0,0 +1,222 @@
<template>
<view class="container loading5">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading5',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading5 .shape {
width: 15px;
height: 15px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading5 .shape1 {
animation: animation5shape1 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
50% {
-webkit-transform: translate(15px, 15px);
transform: translate(15px, 15px);
}
75% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
}
@keyframes animation5shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
50% {
-webkit-transform: translate(15px, 15px);
transform: translate(15px, 15px);
}
75% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
}
.loading5 .shape2 {
animation: animation5shape2 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
50% {
-webkit-transform: translate(-15px, 15px);
transform: translate(-15px, 15px);
}
75% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
}
@keyframes animation5shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
50% {
-webkit-transform: translate(-15px, 15px);
transform: translate(-15px, 15px);
}
75% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
}
.loading5 .shape3 {
animation: animation5shape3 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
50% {
-webkit-transform: translate(15px, -15px);
transform: translate(15px, -15px);
}
75% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
}
@keyframes animation5shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
50% {
-webkit-transform: translate(15px, -15px);
transform: translate(15px, -15px);
}
75% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
}
.loading5 .shape4 {
animation: animation5shape4 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
50% {
-webkit-transform: translate(-15px, -15px);
transform: translate(-15px, -15px);
}
75% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
}
@keyframes animation5shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
50% {
-webkit-transform: translate(-15px, -15px);
transform: translate(-15px, -15px);
}
75% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
}
</style>

View File

@ -0,0 +1,229 @@
<template>
<view class="container loading6">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading6',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading6 {
-webkit-animation: rotation 1s infinite;
animation: rotation 1s infinite;
}
.container.loading6 .shape {
width: 12px;
height: 12px;
border-radius: 2px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading6 .shape1 {
-webkit-animation: animation6shape1 2s linear 0s infinite normal;
animation: animation6shape1 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
50% {
-webkit-transform: translate(18px, 18px);
transform: translate(18px, 18px);
}
75% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
}
@keyframes animation6shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
50% {
-webkit-transform: translate(18px, 18px);
transform: translate(18px, 18px);
}
75% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
}
.loading6 .shape2 {
-webkit-animation: animation6shape2 2s linear 0s infinite normal;
animation: animation6shape2 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
50% {
-webkit-transform: translate(-18px, 18px);
transform: translate(-18px, 18px);
}
75% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
}
@keyframes animation6shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
50% {
-webkit-transform: translate(-18px, 18px);
transform: translate(-18px, 18px);
}
75% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
}
.loading6 .shape3 {
-webkit-animation: animation6shape3 2s linear 0s infinite normal;
animation: animation6shape3 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
50% {
-webkit-transform: translate(18px, -18px);
transform: translate(18px, -18px);
}
75% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
}
@keyframes animation6shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
50% {
-webkit-transform: translate(18px, -18px);
transform: translate(18px, -18px);
}
75% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
}
.loading6 .shape4 {
-webkit-animation: animation6shape4 2s linear 0s infinite normal;
animation: animation6shape4 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
50% {
-webkit-transform: translate(-18px, -18px);
transform: translate(-18px, -18px);
}
75% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
}
@keyframes animation6shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
50% {
-webkit-transform: translate(-18px, -18px);
transform: translate(-18px, -18px);
}
75% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
}
</style>

View File

@ -0,0 +1,36 @@
<template>
<view>
<Loading1 v-if="loadingType==1"/>
<Loading2 v-if="loadingType==2"/>
<Loading3 v-if="loadingType==3"/>
<Loading4 v-if="loadingType==4"/>
<Loading5 v-if="loadingType==5"/>
</view>
</template>
<script>
import Loading1 from "./loading1.vue";
import Loading2 from "./loading2.vue";
import Loading3 from "./loading3.vue";
import Loading4 from "./loading4.vue";
import Loading5 from "./loading5.vue";
export default {
components:{Loading1,Loading2,Loading3,Loading4,Loading5},
name: 'qiun-loading',
props: {
loadingType: {
type: Number,
default: 2
},
},
data() {
return {
};
},
}
</script>
<style>
</style>

View File

@ -0,0 +1,422 @@
/*
* uCharts®
* 高性能跨平台图表库支持H5APP小程序微信/支付宝/百度/头条/QQ/360VueTaro等支持canvas的框架平台
* Copyright (c) 2021 QIUN®秋云 https://www.ucharts.cn All rights reserved.
* Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
* 复制使用请保留本段注释感谢支持开源
*
* uCharts®官方网站
* https://www.uCharts.cn
*
* 开源地址:
* https://gitee.com/uCharts/uCharts
*
* uni-app插件市场地址
* http://ext.dcloud.net.cn/plugin?id=271
*
*/
// 通用配置项
// 主题颜色配置如每个图表类型需要不同主题请在对应图表类型上更改color属性
const color = ['#1890FF', '#91CB74', '#FAC858', '#EE6666', '#73C0DE', '#3CA272', '#FC8452', '#9A60B4', '#ea7ccc'];
const cfe = {
//demotype为自定义图表类型
"type": ["pie", "ring", "rose", "funnel", "line", "column", "area", "radar", "gauge","candle","demotype"],
//增加自定义图表类型如果需要categories请在这里加入您的图表类型例如最后的"demotype"
"categories": ["line", "column", "area", "radar", "gauge", "candle","demotype"],
//instance为实例变量承载属性option为eopts承载属性不要删除
"instance": {},
"option": {},
//下面是自定义format配置因除H5端外的其他端无法通过props传递函数只能通过此属性对应下标的方式来替换
"formatter":{
"tooltipDemo1":function(res){
let result = ''
for (let i in res) {
if (i == 0) {
result += res[i].axisValueLabel + '年销售额'
}
let value = '--'
if (res[i].data !== null) {
value = res[i].data
}
// #ifdef H5
result += '\n' + res[i].seriesName + '' + value + ' 万元'
// #endif
// #ifdef APP-PLUS
result += '<br/>' + res[i].marker + res[i].seriesName + '' + value + ' 万元'
// #endif
}
return result;
},
legendFormat:function(name){
return "自定义图例+"+name;
},
yAxisFormatDemo:function (value, index) {
return value + '元';
},
seriesFormatDemo:function(res){
return res.name + '年' + res.value + '元';
}
},
//这里演示了自定义您的图表类型的option可以随意命名之后在组件上 type="demotype" 后组件会调用这个花括号里的option如果组件上还存在eopts参数会将demotype与eopts中option合并后渲染图表。
"demotype":{
"color": color,
//在这里填写echarts的option即可
},
//下面是自定义配置,请添加项目所需的通用配置
"column": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'axis'
},
"grid": {
"top": 30,
"bottom": 50,
"right": 15,
"left": 40
},
"legend": {
"bottom": 'left',
},
"toolbox": {
"show": false,
},
"xAxis": {
"type": 'category',
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
"boundaryGap": true,
"data": []
},
"yAxis": {
"type": 'value',
"axisTick": {
"show": false,
},
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
},
"seriesTemplate": {
"name": '',
"type": 'bar',
"data": [],
"barwidth": 20,
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
},
},
"line": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'axis'
},
"grid": {
"top": 30,
"bottom": 50,
"right": 15,
"left": 40
},
"legend": {
"bottom": 'left',
},
"toolbox": {
"show": false,
},
"xAxis": {
"type": 'category',
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
"boundaryGap": true,
"data": []
},
"yAxis": {
"type": 'value',
"axisTick": {
"show": false,
},
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
},
"seriesTemplate": {
"name": '',
"type": 'line',
"data": [],
"barwidth": 20,
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
},
},
"area": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'axis'
},
"grid": {
"top": 30,
"bottom": 50,
"right": 15,
"left": 40
},
"legend": {
"bottom": 'left',
},
"toolbox": {
"show": false,
},
"xAxis": {
"type": 'category',
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
"boundaryGap": true,
"data": []
},
"yAxis": {
"type": 'value',
"axisTick": {
"show": false,
},
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
},
"seriesTemplate": {
"name": '',
"type": 'line',
"data": [],
"areaStyle": {},
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
},
},
"pie": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'item'
},
"grid": {
"top": 40,
"bottom": 30,
"right": 15,
"left": 15
},
"legend": {
"bottom": 'left',
},
"seriesTemplate": {
"name": '',
"type": 'pie',
"data": [],
"radius": '50%',
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
},
},
"ring": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'item'
},
"grid": {
"top": 40,
"bottom": 30,
"right": 15,
"left": 15
},
"legend": {
"bottom": 'left',
},
"seriesTemplate": {
"name": '',
"type": 'pie',
"data": [],
"radius": ['40%', '70%'],
"avoidLabelOverlap": false,
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
"labelLine": {
"show": true
},
},
},
"rose": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'item'
},
"legend": {
"top": 'bottom'
},
"seriesTemplate": {
"name": '',
"type": 'pie',
"data": [],
"radius": "55%",
"center": ['50%', '50%'],
"roseType": 'area',
},
},
"funnel": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'item',
"formatter": "{b} : {c}%"
},
"legend": {
"top": 'bottom'
},
"seriesTemplate": {
"name": '',
"type": 'funnel',
"left": '10%',
"top": 60,
"bottom": 60,
"width": '80%',
"min": 0,
"max": 100,
"minSize": '0%',
"maxSize": '100%',
"sort": 'descending',
"gap": 2,
"label": {
"show": true,
"position": 'inside'
},
"labelLine": {
"length": 10,
"lineStyle": {
"width": 1,
"type": 'solid'
}
},
"itemStyle": {
"bordercolor": '#fff',
"borderwidth": 1
},
"emphasis": {
"label": {
"fontSize": 20
}
},
"data": [],
},
},
"gauge": {
"color": color,
"tooltip": {
"formatter": '{a} <br/>{b} : {c}%'
},
"seriesTemplate": {
"name": '业务指标',
"type": 'gauge',
"detail": {"formatter": '{value}%'},
"data": [{"value": 50, "name": '完成率'}]
},
},
"candle": {
"xAxis": {
"data": []
},
"yAxis": {},
"color": color,
"title": {
"text": ''
},
"dataZoom": [{
"type": 'inside',
"xAxisIndex": [0, 1],
"start": 10,
"end": 100
},
{
"show": true,
"xAxisIndex": [0, 1],
"type": 'slider',
"bottom": 10,
"start": 10,
"end": 100
}
],
"seriesTemplate": {
"name": '',
"type": 'k',
"data": [],
},
}
}
export default cfe;

View File

@ -0,0 +1,606 @@
/*
* uCharts®
* 高性能跨平台图表库支持H5APP小程序微信/支付宝/百度/头条/QQ/360VueTaro等支持canvas的框架平台
* Copyright (c) 2021 QIUN®秋云 https://www.ucharts.cn All rights reserved.
* Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
* 复制使用请保留本段注释感谢支持开源
*
* uCharts®官方网站
* https://www.uCharts.cn
*
* 开源地址:
* https://gitee.com/uCharts/uCharts
*
* uni-app插件市场地址
* http://ext.dcloud.net.cn/plugin?id=271
*
*/
// 主题颜色配置如每个图表类型需要不同主题请在对应图表类型上更改color属性
const color = ['#1890FF', '#91CB74', '#FAC858', '#EE6666', '#73C0DE', '#3CA272', '#FC8452', '#9A60B4', '#ea7ccc'];
//事件转换函数主要用作格式化x轴为时间轴根据需求自行修改
const formatDateTime = (timeStamp, returnType)=>{
var date = new Date();
date.setTime(timeStamp * 1000);
var y = date.getFullYear();
var m = date.getMonth() + 1;
m = m < 10 ? ('0' + m) : m;
var d = date.getDate();
d = d < 10 ? ('0' + d) : d;
var h = date.getHours();
h = h < 10 ? ('0' + h) : h;
var minute = date.getMinutes();
var second = date.getSeconds();
minute = minute < 10 ? ('0' + minute) : minute;
second = second < 10 ? ('0' + second) : second;
if(returnType == 'full'){return y + '-' + m + '-' + d + ' '+ h +':' + minute + ':' + second;}
if(returnType == 'y-m-d'){return y + '-' + m + '-' + d;}
if(returnType == 'h:m'){return h +':' + minute;}
if(returnType == 'h:m:s'){return h +':' + minute +':' + second;}
return [y, m, d, h, minute, second];
}
const cfu = {
//demotype为自定义图表类型一般不需要自定义图表类型只需要改根节点上对应的类型即可
"type":["pie","ring","rose","word","funnel","map","arcbar","line","column","mount","bar","area","radar","gauge","candle","mix","tline","tarea","scatter","bubble","demotype"],
"range":["饼状图","圆环图","玫瑰图","词云图","漏斗图","地图","圆弧进度条","折线图","柱状图","山峰图","条状图","区域图","雷达图","仪表盘","K线图","混合图","时间轴折线","时间轴区域","散点图","气泡图","自定义类型"],
//增加自定义图表类型如果需要categories请在这里加入您的图表类型例如最后的"demotype"
//自定义类型时需要注意"tline","tarea","scatter","bubble"等时间轴矢量x轴类图表没有categories不需要加入categories
"categories":["line","column","mount","bar","area","radar","gauge","candle","mix","demotype"],
//instance为实例变量承载属性不要删除
"instance":{},
//option为opts及eopts承载属性不要删除
"option":{},
//下面是自定义format配置因除H5端外的其他端无法通过props传递函数只能通过此属性对应下标的方式来替换
"formatter":{
"yAxisDemo1":function(val, index, opts){return val+'元'},
"yAxisDemo2":function(val, index, opts){return val.toFixed(2)},
"xAxisDemo1":function(val, index, opts){return val+'年';},
"xAxisDemo2":function(val, index, opts){return formatDateTime(val,'h:m')},
"seriesDemo1":function(val, index, series, opts){return val+'元'},
"tooltipDemo1":function(item, category, index, opts){
if(index==0){
return '随便用'+item.data+'年'
}else{
return '其他我没改'+item.data+'天'
}
},
"pieDemo":function(val, index, series, opts){
if(index !== undefined){
return series[index].name+''+series[index].data+'元'
}
},
},
//这里演示了自定义您的图表类型的option可以随意命名之后在组件上 type="demotype" 后组件会调用这个花括号里的option如果组件上还存在opts参数会将demotype与opts中option合并后渲染图表。
"demotype":{
//我这里把曲线图当做了自定义图表类型,您可以根据需要随意指定类型或配置
"type": "line",
"color": color,
"padding": [15,10,0,15],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
},
"legend": {
},
"extra": {
"line": {
"type": "curve",
"width": 2
},
}
},
//下面是自定义配置,请添加项目所需的通用配置
"pie":{
"type": "pie",
"color": color,
"padding": [5,5,5,5],
"extra": {
"pie": {
"activeOpacity": 0.5,
"activeRadius": 10,
"offsetAngle": 0,
"labelWidth": 15,
"border": true,
"borderWidth": 3,
"borderColor": "#FFFFFF"
},
}
},
"ring":{
"type": "ring",
"color": color,
"padding": [5,5,5,5],
"rotate": false,
"dataLabel": true,
"legend": {
"show": true,
"position": "right",
"lineHeight": 25,
},
"title": {
"name": "收益率",
"fontSize": 15,
"color": "#666666"
},
"subtitle": {
"name": "70%",
"fontSize": 25,
"color": "#7cb5ec"
},
"extra": {
"ring": {
"ringWidth":30,
"activeOpacity": 0.5,
"activeRadius": 10,
"offsetAngle": 0,
"labelWidth": 15,
"border": true,
"borderWidth": 3,
"borderColor": "#FFFFFF"
},
},
},
"rose":{
"type": "rose",
"color": color,
"padding": [5,5,5,5],
"legend": {
"show": true,
"position": "left",
"lineHeight": 25,
},
"extra": {
"rose": {
"type": "area",
"minRadius": 50,
"activeOpacity": 0.5,
"activeRadius": 10,
"offsetAngle": 0,
"labelWidth": 15,
"border": false,
"borderWidth": 2,
"borderColor": "#FFFFFF"
},
}
},
"word":{
"type": "word",
"color": color,
"extra": {
"word": {
"type": "normal",
"autoColors": false
}
}
},
"funnel":{
"type": "funnel",
"color": color,
"padding": [15,15,0,15],
"extra": {
"funnel": {
"activeOpacity": 0.3,
"activeWidth": 10,
"border": true,
"borderWidth": 2,
"borderColor": "#FFFFFF",
"fillOpacity": 1,
"labelAlign": "right"
},
}
},
"map":{
"type": "map",
"color": color,
"padding": [0,0,0,0],
"dataLabel": true,
"extra": {
"map": {
"border": true,
"borderWidth": 1,
"borderColor": "#666666",
"fillOpacity": 0.6,
"activeBorderColor": "#F04864",
"activeFillColor": "#FACC14",
"activeFillOpacity": 1
},
}
},
"arcbar":{
"type": "arcbar",
"color": color,
"title": {
"name": "百分比",
"fontSize": 25,
"color": "#00FF00"
},
"subtitle": {
"name": "默认标题",
"fontSize": 15,
"color": "#666666"
},
"extra": {
"arcbar": {
"type": "default",
"width": 12,
"backgroundColor": "#E9E9E9",
"startAngle": 0.75,
"endAngle": 0.25,
"gap": 2
}
}
},
"line":{
"type": "line",
"color": color,
"padding": [15,10,0,15],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
},
"legend": {
},
"extra": {
"line": {
"type": "straight",
"width": 2,
"activeType": "hollow"
},
}
},
"tline":{
"type": "line",
"color": color,
"padding": [15,10,0,15],
"xAxis": {
"disableGrid": false,
"boundaryGap":"justify",
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
"data":[
{
"min":0,
"max":80
}
]
},
"legend": {
},
"extra": {
"line": {
"type": "curve",
"width": 2,
"activeType": "hollow"
},
}
},
"tarea":{
"type": "area",
"color": color,
"padding": [15,10,0,15],
"xAxis": {
"disableGrid": true,
"boundaryGap":"justify",
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
"data":[
{
"min":0,
"max":80
}
]
},
"legend": {
},
"extra": {
"area": {
"type": "curve",
"opacity": 0.2,
"addLine": true,
"width": 2,
"gradient": true,
"activeType": "hollow"
},
}
},
"column":{
"type": "column",
"color": color,
"padding": [15,15,0,5],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"data":[{"min":0}]
},
"legend": {
},
"extra": {
"column": {
"type": "group",
"width": 30,
"activeBgColor": "#000000",
"activeBgOpacity": 0.08
},
}
},
"mount":{
"type": "mount",
"color": color,
"padding": [15,15,0,5],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"data":[{"min":0}]
},
"legend": {
},
"extra": {
"mount": {
"type": "mount",
"widthRatio": 1.5,
},
}
},
"bar":{
"type": "bar",
"color": color,
"padding": [15,30,0,5],
"xAxis": {
"boundaryGap":"justify",
"disableGrid":false,
"min":0,
"axisLine":false
},
"yAxis": {
},
"legend": {
},
"extra": {
"bar": {
"type": "group",
"width": 30,
"meterBorde": 1,
"meterFillColor": "#FFFFFF",
"activeBgColor": "#000000",
"activeBgOpacity": 0.08
},
}
},
"area":{
"type": "area",
"color": color,
"padding": [15,15,0,15],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
},
"legend": {
},
"extra": {
"area": {
"type": "straight",
"opacity": 0.2,
"addLine": true,
"width": 2,
"gradient": false,
"activeType": "hollow"
},
}
},
"radar":{
"type": "radar",
"color": color,
"padding": [5,5,5,5],
"dataLabel": false,
"legend": {
"show": true,
"position": "right",
"lineHeight": 25,
},
"extra": {
"radar": {
"gridType": "radar",
"gridColor": "#CCCCCC",
"gridCount": 3,
"opacity": 0.2,
"max": 200,
"labelShow": true
},
}
},
"gauge":{
"type": "gauge",
"color": color,
"title": {
"name": "66Km/H",
"fontSize": 25,
"color": "#2fc25b",
"offsetY": 50
},
"subtitle": {
"name": "实时速度",
"fontSize": 15,
"color": "#1890ff",
"offsetY": -50
},
"extra": {
"gauge": {
"type": "default",
"width": 30,
"labelColor": "#666666",
"startAngle": 0.75,
"endAngle": 0.25,
"startNumber": 0,
"endNumber": 100,
"labelFormat": "",
"splitLine": {
"fixRadius": 0,
"splitNumber": 10,
"width": 30,
"color": "#FFFFFF",
"childNumber": 5,
"childWidth": 12
},
"pointer": {
"width": 24,
"color": "auto"
}
}
}
},
"candle":{
"type": "candle",
"color": color,
"padding": [15,15,0,15],
"enableScroll": true,
"enableMarkLine": true,
"dataLabel": false,
"xAxis": {
"labelCount": 4,
"itemCount": 40,
"disableGrid": true,
"gridColor": "#CCCCCC",
"gridType": "solid",
"dashLength": 4,
"scrollShow": true,
"scrollAlign": "left",
"scrollColor": "#A6A6A6",
"scrollBackgroundColor": "#EFEBEF"
},
"yAxis": {
},
"legend": {
},
"extra": {
"candle": {
"color": {
"upLine": "#f04864",
"upFill": "#f04864",
"downLine": "#2fc25b",
"downFill": "#2fc25b"
},
"average": {
"show": true,
"name": ["MA5","MA10","MA30"],
"day": [5,10,20],
"color": ["#1890ff","#2fc25b","#facc14"]
}
},
"markLine": {
"type": "dash",
"dashLength": 5,
"data": [
{
"value": 2150,
"lineColor": "#f04864",
"showLabel": true
},
{
"value": 2350,
"lineColor": "#f04864",
"showLabel": true
}
]
}
}
},
"mix":{
"type": "mix",
"color": color,
"padding": [15,15,0,15],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"disabled": false,
"disableGrid": false,
"splitNumber": 5,
"gridType": "dash",
"dashLength": 4,
"gridColor": "#CCCCCC",
"padding": 10,
"showTitle": true,
"data": []
},
"legend": {
},
"extra": {
"mix": {
"column": {
"width": 20
}
},
}
},
"scatter":{
"type": "scatter",
"color":color,
"padding":[15,15,0,15],
"dataLabel":false,
"xAxis": {
"disableGrid": false,
"gridType":"dash",
"splitNumber":5,
"boundaryGap":"justify",
"min":0
},
"yAxis": {
"disableGrid": false,
"gridType":"dash",
},
"legend": {
},
"extra": {
"scatter": {
},
}
},
"bubble":{
"type": "bubble",
"color":color,
"padding":[15,15,0,15],
"xAxis": {
"disableGrid": false,
"gridType":"dash",
"splitNumber":5,
"boundaryGap":"justify",
"min":0,
"max":250
},
"yAxis": {
"disableGrid": false,
"gridType":"dash",
"data":[{
"min":0,
"max":150
}]
},
"legend": {
},
"extra": {
"bubble": {
"border":2,
"opacity": 0.5,
},
}
}
}
export default cfu;

View File

@ -0,0 +1,5 @@
# uCharts JSSDK说明
1、如不使用uCharts组件可直接引用u-charts.js打包编译后会`自动压缩`,压缩后体积约为`120kb`
2、如果120kb的体积仍需压缩请手到uCharts官网通过在线定制选择您需要的图表。
3、config-ucharts.js为uCharts组件的用户配置文件升级前请`自行备份config-ucharts.js`文件,以免被强制覆盖。
4、config-echarts.js为ECharts组件的用户配置文件升级前请`自行备份config-echarts.js`文件,以免被强制覆盖。

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,80 @@
{
"id": "qiun-data-charts",
"displayName": "秋云 ucharts echarts 高性能跨全端图表组件",
"version": "2.5.0-20230101",
"description": "uCharts 新增正负柱状图支持H5及APP用 ucharts echarts 渲染图表uniapp可视化首选组件",
"keywords": [
"ucharts",
"echarts",
"f2",
"图表",
"可视化"
],
"repository": "https://gitee.com/uCharts/uCharts",
"engines": {
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "474119"
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/~qiun",
"type": "component-vue"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y"
},
"快应用": {
"华为": "y",
"联盟": "y"
},
"Vue": {
"vue2": "y",
"vue3": "y"
}
}
}
}
}

View File

@ -0,0 +1,84 @@
![logo](https://img-blog.csdnimg.cn/4a276226973841468c1be356f8d9438b.png)
[![star](https://gitee.com/uCharts/uCharts/badge/star.svg?theme=gvp)](https://gitee.com/uCharts/uCharts/stargazers)
[![fork](https://gitee.com/uCharts/uCharts/badge/fork.svg?theme=gvp)](https://gitee.com/uCharts/uCharts/members)
[![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg)](https://www.apache.org/licenses/LICENSE-2.0.html)
[![npm package](https://img.shields.io/npm/v/@qiun/ucharts.svg?style=flat-square)](https://www.npmjs.com/~qiun)
## uCharts简介
`uCharts`是一款基于`canvas API`开发的适用于所有前端应用的图表库,开发者编写一套代码,可运行到 Web、iOS、Android基于 uni-app / taro )、以及各种小程序(微信/支付宝/百度/头条/飞书/QQ/快手/钉钉/淘宝)、快应用等更多支持 canvas API 的平台。
## 官方网站
## [https://www.ucharts.cn](https://www.ucharts.cn)
## 快速体验
一套代码编到多个平台依次扫描二维码亲自体验uCharts图表跨平台效果其他平台请自行编译。
![](https://www.ucharts.cn/images/web/guide/qrcode20220224.png)
![](https://img-blog.csdnimg.cn/7d0115593ff24ac39a224fb7c6ed72a4.png)
## 致开发者
感谢各位开发者`五年`来对秋云及uCharts的支持uCharts的进步离不开各位开发者的鼓励与贡献。为更好的帮助各位开发者使用图表工具我们推出了新版官网增加了在线定制、问答社区、在线配置等一些增值服务为确保您能更好的应用图表组件建议您先`仔细阅读官网指南`以及`常见问题`,而不是下载下来`直接使用`。如仍然不能解决,请到`官网社区`或开通会员后加入`专属VIP会员群`提问将会很快得到回答。
## 视频教程
## [uCharts新手入门教程](https://www.bilibili.com/video/BV1qA411Q7se/?share_source=copy_web&vd_source=42a1242f9aaade6427736af69eb2e1d9)
## 社群支持
uCharts官方拥有5个2000人的QQ群及专属VIP会员群支持庞大的用户量证明我们一直在努力请各位放心使用uCharts的开源图表组件的开发团队付出了大量的时间与精力经过四来的考验不会有比较明显的bug请各位放心使用。如果您有更好的想法可以在`码云提交Pull Requests`以帮助更多开发者完成需求再次感谢各位对uCharts的鼓励与支持
#### 官方交流群
- 交流群1371774600已满
- 交流群2619841586已满
- 交流群3955340127已满
- 交流群4641669795已满
- 交流群5236294809只能扫码加入
![](https://www.ucharts.cn/images/web/qq5.jpg)
- 口令`uniapp`
#### 专属VIP会员群
- 开通会员后详见【账号详情】页面中顶部的滚动通知
- 口令`您的用户ID`
## 版权信息
uCharts始终坚持开源遵循 [Apache Licence 2.0](https://www.apache.org/licenses/LICENSE-2.0.html) 开源协议意味着您无需支付任何费用即可将uCharts应用到您的产品中。
注意这并不意味着您可以将uCharts应用到非法的领域比如涉及赌博暴力等方面。如因此产生纠纷或法律问题uCharts相关方及秋云科技不承担任何责任。
## 合作伙伴
[![DIY官网](https://www.ucharts.cn/images/web/guide/links/diy-gw.png)](https://www.diygw.com/)
[![HasChat](https://www.ucharts.cn/images/web/guide/links/haschat.png)](https://gitee.com/howcode/has-chat)
[![uViewUI](https://www.ucharts.cn/images/web/guide/links/uView.png)](https://www.uviewui.com/)
[![图鸟UI](https://www.ucharts.cn/images/web/guide/links/tuniao.png)](https://ext.dcloud.net.cn/plugin?id=7088)
[![thorui](https://www.ucharts.cn/images/web/guide/links/thorui.png)](https://ext.dcloud.net.cn/publisher?id=202)
[![FirstUI](https://www.ucharts.cn/images/web/guide/links/first.png)](https://www.firstui.cn/)
[![nProUI](https://www.ucharts.cn/images/web/guide/links/nPro.png)](https://ext.dcloud.net.cn/plugin?id=5169)
[![GraceUI](https://www.ucharts.cn/images/web/guide/links/grace.png)](https://www.graceui.com/)
## 更新记录
详见官网指南中说明,[点击此处查看](https://www.ucharts.cn/v2/#/guide/index?id=100)
## 相关链接
- [uCharts官网](https://www.ucharts.cn)
- [DCloud插件市场地址](https://ext.dcloud.net.cn/plugin?id=271)
- [uCharts码云开源托管地址](https://gitee.com/uCharts/uCharts) [![star](https://gitee.com/uCharts/uCharts/badge/star.svg?theme=gvp)](https://gitee.com/uCharts/uCharts/stargazers)
- [uCharts npm开源地址](https://www.ucharts.cn)
- [ECharts官网](https://echarts.apache.org/zh/index.html)
- [ECharts配置手册](https://echarts.apache.org/zh/option.html)
- [图表组件在项目中的应用 ReportPlus数据报表](https://www.ucharts.cn/v2/#/layout/info?id=1)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

32
src/utils/cache.ts Normal file
View File

@ -0,0 +1,32 @@
/**
*
* key QY_
*/
const PREFIX = 'QY_';
export const setCache = (key: string, value: any) => {
if (!key) throw new Error('cache key is required');
uni.setStorageSync(`${PREFIX}${key.toUpperCase()}`, value);
};
export const getCache = (key: string) => {
if (!key) throw new Error('cache key is required');
return uni.getStorageSync(`${PREFIX}${key.toUpperCase()}`);
};
export const removeCache = (key: string) => {
if (!key) throw new Error('cache key is required');
uni.removeStorageSync(`${PREFIX}${key.toUpperCase()}`);
};
export const clearCache = () => {
uni.clearStorageSync();
};
// 常用 key 常量
export const CACHE_KEYS = {
TOKEN: 'token',
USER_INFO: 'userInfo',
ROLE: 'role',
} as const;

1
src/utils/cache/index.ts vendored Normal file
View File

@ -0,0 +1 @@
export function ces() {}

74
src/utils/format.ts Normal file
View File

@ -0,0 +1,74 @@
/**
*
*/
const pad = (n: number) => (n < 10 ? `0${n}` : String(n));
/**
* YYYY/MM/DD HH:mm:ss
*/
export const formatTime = (
time: string | number | Date | undefined | null,
template = 'YYYY/MM/DD HH:mm',
): string => {
if (!time) return '';
let d: Date;
if (typeof time === 'string') {
const fixed = time.includes('T') ? time : time.replace(/-/g, '/');
d = new Date(fixed);
} else {
d = new Date(time);
}
if (isNaN(d.getTime())) return '';
return template
.replace('YYYY', String(d.getFullYear()))
.replace('MM', pad(d.getMonth() + 1))
.replace('DD', pad(d.getDate()))
.replace('HH', pad(d.getHours()))
.replace('mm', pad(d.getMinutes()))
.replace('ss', pad(d.getSeconds()));
};
/**
* 123
*/
export const formatCostTime = (ms: number): string => {
const total = Math.floor((Number(ms) || 0) / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}${m}${s}`;
if (m > 0) return `${m}${s}`;
return `${s}`;
};
/**
* mm:ss / HH:mm:ss
*/
export const formatDuration = (sec: number): string => {
const total = Math.max(0, Number(sec) || 0);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${pad(m)}:${pad(s)}`;
};
/**
* promise
*/
export const sleep = (ms: number) =>
new Promise<void>((resolve) => {
const t = setTimeout(() => {
resolve();
clearTimeout(t);
}, ms);
});
/**
* toast
*/
export const showToast = (title: string, icon: 'none' | 'success' | 'error' | 'loading' = 'none') => {
uni.showToast({ title, icon, duration: 2000 });
};

27
src/utils/layout.ts Normal file
View File

@ -0,0 +1,27 @@
/**
* @author zhouyankai
* @description utils方法
* @Date: 2022-07-31
*/
/**
* url是否是http或https
* @param {string} path
* @returns {Boolean}
*/
export function isHttp(url: string) {
return url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1;
}
/**
* true false
*/
export function validatenull(val: null | undefined | string) {
return (
val === null ||
val === undefined ||
val === '' ||
(Array.isArray(val) && val.length === 0) ||
(typeof val === 'object' && Object.keys(val).length === 0)
);
}

153
src/utils/request.ts Normal file
View File

@ -0,0 +1,153 @@
import Request from 'luch-request';
import { getCache, removeCache } from '@/utils/cache';
/**
* AI HTTP luch-request
*
* - Bearer Token
* - x-tenant-id
* - code !== 200 toast
* - 401
* -
*/
const HOST = (import.meta.env.VITE_BASE_URL as string) || '';
const BASE_URL = `${HOST}/api/main`;
const TENANT_ID = '1966391196339204098';
let isReloginHandling = false;
interface BackendResp<T = any> {
code: number;
data: T;
message?: string;
msg?: string;
[key: string]: any;
}
export class HttpError extends Error {
code: number;
data: any;
constructor(message: string, code: number, data?: any) {
super(message);
this.code = code;
this.data = data;
}
}
const http = new Request({
baseURL: BASE_URL,
timeout: 30000,
header: {
'Content-Type': 'application/json;charset=UTF-8',
'x-tenant-id': TENANT_ID,
},
});
const handleUnauthorized = (message = '登录已失效,请重新登录') => {
if (isReloginHandling) return;
isReloginHandling = true;
removeCache('token');
removeCache('userInfo');
removeCache('teacherInfo');
removeCache('role');
uni.showToast({ title: message, icon: 'none', duration: 2500 });
// 避免并发 401 触发多次跳转与多次弹窗
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/index' });
}, 200);
};
// ----------- 请求拦截 -----------
http.interceptors.request.use(
(config) => {
const token = getCache('token');
if (token && config.header) {
// 默认带上 Authorization接口可显式覆盖传空字符串以匿名调用
if (!('Authorization' in config.header)) {
(config.header as any).Authorization = `Bearer ${token}`;
}
}
// 始终携带租户 ID
if (config.header && !('x-tenant-id' in config.header)) {
(config.header as any)['x-tenant-id'] = TENANT_ID;
}
return config;
},
(err) => Promise.reject(err),
);
// ----------- 响应拦截 -----------
http.interceptors.response.use(
(response: any) => {
const data = response.data as BackendResp;
if (!data || typeof data !== 'object') {
return response;
}
if (data.code === 200) {
return data;
}
if (data.code === 401) {
const msg401 = data.message || data.msg || '登录已失效,请重新登录';
handleUnauthorized(msg401);
return Promise.reject(new HttpError(msg401, 401, data));
}
// 业务错误
const msg = data.message || data.msg || '请求失败';
if (!(response.config as any)?.silent) {
uni.showToast({ title: msg, icon: 'none', duration: 2500 });
}
return Promise.reject(new HttpError(msg, data.code, data));
},
(err: any) => {
const status = err?.statusCode || err?.status;
let msg = err?.errMsg || err?.message || '网络异常';
if (status === 401) {
msg = '登录已失效,请重新登录';
handleUnauthorized(msg);
} else if (status === 500) {
msg = '服务异常,请稍后重试';
}
if (status !== 401) {
uni.showToast({ title: msg, icon: 'none', duration: 2500 });
}
return Promise.reject(new HttpError(msg, status || -1, err));
},
);
/**
* luch-request
* `request({ url, method, data, params, headers })`
*/
export interface RequestOptions {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: anyObj;
params?: anyObj;
headers?: anyObj;
silent?: boolean;
}
export const request = <T = any>(opt: RequestOptions): Promise<BackendResp<T>> => {
const cfg: any = {
url: opt.url,
method: opt.method || 'GET',
custom: { silent: opt.silent },
};
if (opt.headers) cfg.header = opt.headers;
if (cfg.method === 'GET') {
cfg.params = opt.params || opt.data;
} else {
cfg.data = opt.data;
if (opt.params) cfg.params = opt.params;
}
if (opt.silent) cfg.silent = true;
return http.request(cfg) as Promise<BackendResp<T>>;
};
export default request;
export { http, HOST, BASE_URL, TENANT_ID };

114
stylelint.config.js Normal file
View File

@ -0,0 +1,114 @@
module.exports = {
extends: [
'stylelint-config-standard',
'stylelint-config-prettier',
'stylelint-config-recommended-vue',
'stylelint-config-recommended-scss',
],
plugins: ['stylelint-order'],
overrides: [
{
files: ['**/*.vue'],
customSyntax: 'postcss-html',
},
],
rules: {
'at-rule-no-unknown': [
true,
{
ignoreAtRules: ['plugin'],
},
],
'unit-no-unknown': [true, { ignoreUnits: ['rpx'] }],
'rule-empty-line-before': [
'always',
{
except: ['after-single-line-comment', 'first-nested'],
},
],
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['deep'],
},
],
'order/properties-order': [
'position',
'top',
'right',
'bottom',
'left',
'z-index',
'display',
'justify-content',
'align-items',
'float',
'clear',
'overflow',
'overflow-x',
'overflow-y',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'font-size',
'font-family',
'font-weight',
'border',
'border-style',
'border-width',
'border-color',
'border-top',
'border-top-style',
'border-top-width',
'border-top-color',
'border-right',
'border-right-style',
'border-right-width',
'border-right-color',
'border-bottom',
'border-bottom-style',
'border-bottom-width',
'border-bottom-color',
'border-left',
'border-left-style',
'border-left-width',
'border-left-color',
'border-radius',
'text-align',
'text-justify',
'text-indent',
'text-overflow',
'text-decoration',
'white-space',
'color',
'background',
'background-position',
'background-repeat',
'background-size',
'background-color',
'background-clip',
'opacity',
'filter',
'list-style',
'outline',
'visibility',
'box-shadow',
'text-shadow',
'resize',
'transition',
],
},
};

13
tailwind.config.js Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./public/index.html', './src/**/*.{html,js,ts,jsx,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
corePlugins: {
// 不需要 preflight因为这主要是给 h5 的,如果你要同时开发小程序和 h5 端,你应该使用环境变量来控制它
preflight: false
}
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"sourceMap": true,
"baseUrl": ".",
"strict": true,
"isolatedModules": false,
"paths": {
"@/*": ["./src/*"]
},
"lib": ["esnext", "dom"],
"types": [
"@dcloudio/types"
]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["src/uni_modules/**"]
}

87
vite.config.ts Normal file
View File

@ -0,0 +1,87 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import uni from '@dcloudio/vite-plugin-uni';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
import rems from 'postcss-rem-to-responsive-pixel';
import { UnifiedViteWeappTailwindcssPlugin as uvwt } from 'weapp-tailwindcss/vite';
import TransformPages from 'uni-read-pages-vite';
// import eslintPlugin from 'vite-plugin-eslint'; //eslint自动检查插件
const isH5 = process.env.UNI_PLATFORM === 'h5';
const isApp = process.env.UNI_PLATFORM === 'app-plus';
const WeappTailwindcssDisabled = isH5 || isApp;
const postcssPlugins = [tailwindcss(), autoprefixer()];
if (!WeappTailwindcssDisabled) {
postcssPlugins.push(
rems({
// 32 意味着 1rem = 32rpx
rootValue: 32,
// 默认所有属性都转化
propList: ['*'],
// 转化的单位,可以变成 px / rpx
transformUnit: 'rpx',
}),
);
}
// https://vitejs.dev/config/
export default defineConfig({
server: {
//跨域配置
host: '0.0.0.0',
port: 5173,
open: true, //是否自动打开浏览器
},
build: {
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
},
},
rollupOptions: {
output: {
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
},
},
// 关闭文件计算
reportCompressedSize: false,
// 关闭生成map文件 可以达到缩小打包体积
sourcemap: false, // 这个生产环境一定要关闭,不然打包的产物会很大
},
// 路径配置
resolve: {
alias: [
{
find: '@',
replacement: resolve(__dirname, 'src'), //配置@ 路径
},
],
},
define: {
ROUTES: new TransformPages().routes, // 注入路由表
},
plugins: [
uni(),
uvwt({
disabled: WeappTailwindcssDisabled,
}),
// eslintPlugin({
// include: ['src/**/*.ts', 'src/**/*.vue', 'src/*.ts', 'src/*.vue'],
// }),
],
css: {
postcss: {
plugins: postcssPlugins,
},
preprocessorOptions: {
scss: {
additionalData: '@import "uniapp-nutui/styles/variables.scss";',
},
},
},
});