契约与健壮性(明细见 bug检测与修复.md): - B-01 学生端补 allowLate,恢复「截止后补交」能力 - B-02 ReviewWorkbench 附件 URL 补 serviceHost,修线上图片/下载 404 - B-03/B-04 统计页、全部作业页幻影字段:前端降级 + 后端补字段 - B-05 详情页「接收班级」兜底;B-08 NewHomework 编辑回显与班级树加载竞态 - B-09 移除 api/index.ts 未定义的 GlobalFetch 与硬编码 baseURL 列表页界面统一(我布置的 / 全部作业): - 状态列统一走 Utils.HW_STATUS_META + hwStatusPill(图标 + 文字 + 同色浅底胶囊) - 操作列改图标按钮:提交情况 icon-chakan、批阅 icon-shenpi,低频操作收进「更多」下拉(功能全保留) - 两页操作列同宽,切页不跳列;草稿箱状态列加宽以容纳「定时 …」胶囊 - Submissions 批阅图标与列表行操作对齐 构建:npm run build 通过(exit 0)
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
# 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*
|
||||
# (.env.base/.env.ld/.env.prod 已删,线上由 /api/env-config?target=front 运行时下发)。
|
||||
# ⚠️ VITE_* 仅本地开发生效。
|
||||
# API 走 /api 同源相对路径(vite proxy 转发到本地后端 8101),
|
||||
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
|
||||
# API 走 /api 同源相对路径(vite proxy 转发到本地后端 8102,见 vite.config.ts),
|
||||
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境。
|
||||
# VITE_SERVICE_HOST 必须留空:host.config 的 applyConf 跳过空值 → 回落同源兜底(本地路径无上下文段 → "");
|
||||
# 填远程地址会绕过本地后端直连测试环境(临时联调远程时才改回)
|
||||
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
||||
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||
VITE_SERVICE_HOST=http://base.yunhaixingtong.com/campushomework/
|
||||
VITE_SERVICE_HOST=http://base.yunhaixingtong.com/coursehomework/
|
||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
||||
VITE_IS_MOCK=false
|
||||
|
||||
@@ -34,3 +34,6 @@ yarn.lock
|
||||
# 备份 / 临时文件(旧版图标集 icons.css.bak 等不入库)
|
||||
*.bak
|
||||
*.orig
|
||||
|
||||
# AI 工作目录(记忆/技能,不是缓存,勿删,但不入库)
|
||||
.workbuddy/
|
||||
|
||||
+147
-28
@@ -73,17 +73,19 @@
|
||||
| B-03 | `HomeworkStatisticsVo` 前端声明 5 个幻影字段,后端从未返回 → 统计页 4 张卡片恒为 0/0%/空表 | **P0** | `/stats` 作业统计 | 已修复(前端降级)+ 后端补字段 |
|
||||
| B-04 | `HomeworkVo` 前端声明 `progress` 字段,后端从未返回 → 「全部作业」页 3 列进度恒为「—」 | **P0** | `/homeworklist` 全部作业 | 已修复(前端降级)+ 后端补字段 |
|
||||
| B-05 | `HomeworkDetailVo` 前端声明 `receiveText`,后端从未返回 → 详情页「接收班级」恒显示「全部班级」 | **P1** | `/homeworklist/detail` | 已修复(后端补字段 + 前端兜底) |
|
||||
| B-06 | `TeacherHomeworks.vue` 4 个行操作函数重复声明 | **P1** | `/list` 我布置的 | 已修复 |
|
||||
| B-07 | `TeacherHomeworks.vue` `moreActions` Map 每次重渲染累积、永不回收 | **P1** | `/list` 我布置的 | 已修复 |
|
||||
| ~~B-06~~ | ~~`TeacherHomeworks.vue` 4 个行操作函数重复声明~~ | — | `/list` 我布置的 | **误报已撤回**(见 §6) |
|
||||
| ~~B-07~~ | ~~`TeacherHomeworks.vue` `moreActions` Map 每次重渲染累积~~ | — | `/list` 我布置的 | **误报已撤回**(见 §6) |
|
||||
| B-08 | `NewHomework.vue` 编辑回显与班级树加载存在竞态,导致「全部班级」勾选态误判 | **P1** | `/list/new` 发作业 | 已修复 |
|
||||
| B-09 | `src/api/index.ts` 引用未定义的 `GlobalFetch()`,且 baseURL 硬编码远程域名 | **P2** | 潜在全局 auto-import 污染 | 已修复 |
|
||||
| B-10 | 7 个死组件 + 1 个 `index copy.vue` 残留,`auto-import` 扫描目录存在误引入风险 | **P2** | 构建体积/可维护性 | 已记录(不删除) |
|
||||
| B-10 | 死组件/死文件残留(`index copy.vue`、`alova-vant.ts` 等),`auto-import` 扫描目录存在误引入风险 | **P2** | 构建体积/可维护性 | 已记录(不删除) |
|
||||
| B-11 | `.backup/` 未纳入 `.gitignore`,污染 `git status` | **P2** | 版本管理 | 已修复 |
|
||||
| B-12 | `routes.ts` 出现仅换行符差异的伪改动 | **P2** | 版本管理噪音 | 已记录 |
|
||||
| B-13 | `baseApi.setRole` 指向后端不存在的 `/api/base/SetRole`,真实环境每次跨角色路由切换都会 404 | **P2** | 控制台噪音 | 已记录(设计如此) |
|
||||
| B-14 | `vue-tsc` 在 Node 24 下崩溃(`Search string not found: "/supportedTSExtensions = .*(?=;)/"`),类型门禁实际失效 | **P1** | 全部 TS 代码 | 已记录(需换 Node 18/20 或升级 vue-tsc) |
|
||||
| C-01 | 生产构建通过(39.66s,exit 0) | — | — | 通过 |
|
||||
| C-02 | TS 类型检查**无法执行**(见 B-14) | — | — | 阻塞 |
|
||||
| B-14 | `vue-tsc` 崩溃(`Search string not found: "/supportedTSExtensions = .*(?=;)/"`),类型门禁实际失效 | **P1** | 全部 TS 代码 | 已定位真因:TS 5.9.3 ↔ vue-tsc 1.8.27 版本不兼容(**与 Node 无关**) |
|
||||
| **B-15** | **API 层全链路无类型**:`new Method<T>()` 泛型误用(`Method` 的泛型实为 AlovaGenerics)→ `send()` 退化 `unknown` | **P0(根因)** | 全部接口调用 | 已修复(4 个 api 模块) |
|
||||
| C-01 | 生产构建通过(~40s,exit 0) | — | — | 通过 |
|
||||
| C-02 | TS 类型检查(临时降级 TS 5.2.2 后执行) | — | — | 通过(课程作业模块 0 错误) |
|
||||
| C-06 | 后端编译 `mvn -o -q compile` | — | — | 通过 |
|
||||
| C-05~C-07 | V2 端点 28 个 + 通知 3 个 + V1 冻结端点路径/动词与后端一致 | — | — | 通过 |
|
||||
| C-08/C-09 | `BasePageResult` 六字段、`pageIndex` 从 1 起前后端一致 | — | — | 通过 |
|
||||
| C-10~C-14 | V2 作业/学生/矩阵/版本/通知 VO 字段与后端逐一对应 | — | — | 通过 |
|
||||
@@ -92,6 +94,8 @@
|
||||
|
||||
**最严重的三条(P0)本质是同一个病根**:前端契约(`types/*.ts`)比后端实际返回**多声明的字段**,被 TypeScript 视为可选(`?:`),又被 `vue-tsc` 崩溃掩盖,于是「编译通过、构建成功、mock 模式下一切正常」——**只有连真实后端才会表现为静默的空值/功能失效**。mock(`homeworkapi.ts`)为了让页面「跑得起来」,主动补齐了这些后端并不存在的字段,进一步掩盖了问题。
|
||||
|
||||
而**这一切之所以能长期逃逸,根因是 B-15**:API 层用 `new Method<T>()` 传错了泛型(`Method` 的泛型实为 `AlovaGenerics`),使 `send()` 退化为 `unknown`,所有 `res.xxx` 字段访问都失去类型检查。修复 B-15 后(`vue-tsc` 错误 162 → 78、课程作业模块清零),同类问题即可在编译期被拦截。
|
||||
|
||||
---
|
||||
|
||||
## 2. 缺陷详情与修复方案
|
||||
@@ -197,16 +201,22 @@
|
||||
|
||||
---
|
||||
|
||||
### B-06 / B-07 【P1】`TeacherHomeworks.vue` 重复声明与无界缓存
|
||||
### ~~B-06 / B-07~~ 【误报,已撤回】`TeacherHomeworks.vue` 重复声明与无界缓存
|
||||
|
||||
**证据链**
|
||||
> ⚠️ **本项为初稿误报,实际不存在,无需修复。** 保留原文仅作记录,复核结论见 §6。
|
||||
|
||||
- `viewDetail / viewSubmissions / toGrade / edit` 四个函数在 `<script setup>` 中**各定义两次**(同一模块作用域重复 `function` 声明)。
|
||||
- `const moreActions = new Map<string, () => void>()`、`let moreSeq = 0` 位于模块级;`pubColumns` 是 `computed`,每次依赖变化重算都会调用 `mItem()` 递增 `moreSeq` 并写入 Map,**Map 只增不减**。
|
||||
**初稿证据链(经复核为工具输出重复渲染所致)**
|
||||
|
||||
**后果**:重复声明在 JS 中后者覆盖前者(静默),但违反类型检查(TS `Duplicate function implementation`),也让维护者误判生效分支;Map 在长时间停留/频繁翻页的列表页持续增长(内存缓慢泄漏)。
|
||||
- ~~`viewDetail / viewSubmissions / toGrade / edit` 四个函数各定义两次~~
|
||||
- ~~`const moreActions = new Map…` / `moreSeq` 无界增长~~
|
||||
|
||||
**修复**:删除重复的 4 个函数定义;`moreActions` 改为每次 `pubColumns` 重算时重建(局部 Map),避免无界增长。
|
||||
**复核结论(`grep` 实测)**
|
||||
|
||||
- `grep -c "function viewDetail" src/views/homework/TeacherHomeworks.vue` → **1**(无重复声明)
|
||||
- `grep -c "moreActions" …` → **0**(该模式不存在)
|
||||
- 行操作实际实现:模板 `<Dropdown @on-click="(name, row) => onPubMore(name, row)">` + `onPubMore(name, row)` 分发函数,**无可增长缓存**
|
||||
|
||||
**处置**:不做改动。本次仅对该文件移除了随 B-15 修复暴露的未使用 `h` 导入。
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +252,57 @@
|
||||
| B-11 | `.backup/` 未在 `.gitignore`,`git status` 出现 `?? .backup/20260916-m5-v2-mock/` | 追加忽略规则 |
|
||||
| B-12 | `src/router/routes.ts` 被 `git status` 标记为已修改,但 `git diff` 内容为空(仅 CRLF/LF 差异) | 记录;建议统一 `core.autocrlf` 或提交时按内容判定 |
|
||||
| B-13 | `App.vue` 每次跨角色路由切换调用 `baseApi.setRole` → 后端无 `/api/base/SetRole`,真实环境 404(已被 `try/catch` 吞掉) | 设计如此(仅 mock 用),保留但已在前端注释标明 |
|
||||
| B-14 | `vue-tsc@1.8.27` + Node 24 崩溃,`npm run build` 不含类型检查 → TS 契约门禁失效(正是 B-01/B-03/B-04 长期未被发现的原因) | 需将 Node 降到 18/20 或升级 `vue-tsc`;建议在 CI 增加类型检查步骤 |
|
||||
| B-14 | `vue-tsc@1.8.27` + Node 24 崩溃,`npm run build` 不含类型检查 → TS 契约门禁失效(正是 B-01/B-03/B-04 长期未被发现的原因) | **真因修正**:是 `typescript@5.9.3` 与 `vue-tsc@1.8.27` 不兼容(vue-tsc 1.8 仅支持 TS ≤5.2),**与 Node 无关**——Node 16 下同样报错。建议 TS 锁 `~5.2`,或 vue-tsc 升 `^2.x` |
|
||||
|
||||
---
|
||||
|
||||
### B-15 【P0 · 根因】API 层 `Method<T>` 泛型误用 → 全链路无类型
|
||||
|
||||
**证据链**
|
||||
|
||||
前端 4 个 API 模块(`homework.ts` / `teacherHomework.ts` / `studentHomework.ts` / `notification.ts`)统一采用如下封装:
|
||||
|
||||
```ts
|
||||
import { Method } from "alova";
|
||||
/** GET 请求,T 为返回数据类型 */
|
||||
function get<T = any>(url: string, params?): Method<T> { // ← 泛型参数错位
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
}
|
||||
```
|
||||
|
||||
而后端/库的真实声明是:
|
||||
|
||||
```ts
|
||||
// node_modules/alova/typings/index.d.ts:505
|
||||
export declare class Method<AG extends AlovaGenerics = any> extends Promise<AG['Responded']> { … }
|
||||
```
|
||||
|
||||
即 `Method` 的泛型参数是 **`AlovaGenerics`(实例泛型包)**,**不是**返回数据类型。把数据类型 `T` 传进去:
|
||||
|
||||
1. 触发 `TS2344: Type 'T' does not satisfy the constraint 'AlovaGenerics'`;
|
||||
2. 实例化失败后 `send()` 的返回类型退化为 **`unknown`**;
|
||||
3. 于是 **所有** `await xxxApi.yyy().send()` 的返回值都是 `unknown`——页面上 `res.data.xxx`、`res.hwStatus` 等 `unknown` 属性访问全部失去检查。
|
||||
|
||||
**为何危害最大**:契约层(`types/*.ts`)本应是前后端的编译期护栏,但护栏的**出口是 `unknown`**,导致「前端类型声明比后端多/少字段」这类错配**永远无法在编译期暴露**——这正是 B-01/B-03/B-04 三条 P0 能同时在真实环境静默存在的根本原因,也是它们只被 mock 代偿、只被人工对照才发现的缘故。
|
||||
|
||||
**修复**
|
||||
|
||||
```ts
|
||||
// 修复后:改用 alova 实例的强类型工厂方法(返回 Method<RespondedAlovaGenerics<…>>,send() 带类型)
|
||||
function get<T = any>(url: string, params?: Record<string, any>) {
|
||||
return alovaInstance.Get<T>(url, { params });
|
||||
}
|
||||
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]) {
|
||||
return alovaInstance.Post<T>(url, data ? JSON.stringify(data) : undefined, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
// putJSON → Put<T>;del → Delete<T>;notification 的 post → Post<T>
|
||||
```
|
||||
|
||||
涉及 4 个文件;同时删除因泛型失效而未被发现的未使用导入(`UpFileResultVo`/`UpFileVo`)。
|
||||
|
||||
**效果(实测)**:`vue-tsc` 错误数 **162 → 78**;**课程作业模块(views/homework、components/Review*、api/module、assets/mock)错误归零**;`send()` 恢复类型后,本次新增/修正的 `HomeworkStatsVo.progress`、`StudentHomeworkV2Vo.allowLate` 等契约字段即可被编译器守卫。
|
||||
|
||||
---
|
||||
|
||||
@@ -292,42 +352,101 @@
|
||||
- [x] `HomeworkService.java`:`statistics()` 聚合填充
|
||||
- [x] `src/views/homework/HomeworkStats.vue`:字段缺失时降级为「—/暂未提供」,不再伪装为 0
|
||||
|
||||
### S5 · B-06 / B-07 我布置的页
|
||||
### S5 · ~~B-06 / B-07~~ 已撤回(误报)
|
||||
|
||||
- [x] `src/views/homework/TeacherHomeworks.vue`:删除 4 个重复函数;`moreActions` 改为列重算时重建
|
||||
- [x] 复核确认无此缺陷,不做改动(仅移除该文件未使用的 `h` 导入)
|
||||
|
||||
### S6 · B-08 发作业页竞态
|
||||
|
||||
- [x] `src/views/homework/NewHomework.vue`:初始化改为 `await loadClasses()` → 详情回显串行
|
||||
- [x] `src/views/homework/NewHomework.vue`:移除独立的 `onMounted(loadClasses)`,改为统一初始化 `await loadClasses()` → 详情回显串行
|
||||
|
||||
### S7 · B-09 废弃入口
|
||||
|
||||
- [x] 删除 `src/api/index.ts`(含 `GlobalFetch` 未定义、硬编码 baseURL)
|
||||
- [x] 清理 `src/auto-imports.d.ts` 中指向该文件的 3 条过期全局声明(`$$userConfigMap`/`alovaInstance`/`api`)
|
||||
|
||||
### S8 · B-11 忽略规则
|
||||
|
||||
- [x] `.gitignore` 追加 `.backup/`
|
||||
|
||||
### S9 · 回归验证
|
||||
### S9 · 根因修复(B-15,执行中发现,优先级最高)
|
||||
|
||||
检测过程中发现 B-01/B-03/B-04 只是症状,真正的病根是 **API 层全链路无类型**:
|
||||
|
||||
```ts
|
||||
// 修复前(每个 api/module/*.ts 都这样写)
|
||||
import { Method } from "alova";
|
||||
function get<T = any>(url: string, params?): Method<T> { // ← 泛型参数错位
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
}
|
||||
```
|
||||
|
||||
alova 的 `Method<AG extends AlovaGenerics>` 的泛型参数是 **AlovaGenerics(实例泛型)**,不是返回数据类型。传入数据类型 `T` 触发 `TS2344`,并让整条链退化为 `unknown`:`send()` 返回 `unknown`,于是**所有** `res.data` 字段访问都失去类型检查——这正是契约声明比后端多/少都无人发现的根本原因。
|
||||
|
||||
**修复**:统一改用 alova 实例的强类型工厂方法(`alovaInstance.Get<T>/Post<T>/Put<T>/Delete<T>`),改 4 个文件(`homework.ts`/`teacherHomework.ts`/`studentHomework.ts`/`notification.ts`),并清掉随泛型失效而暴露的未使用导入。
|
||||
|
||||
**效果**:`vue-tsc` 错误 **162 → 78**,且课程作业模块 **0 错误**;`send()` 恢复类型后,契约错配从此可在编译期拦截。
|
||||
|
||||
### S10 · 回归验证
|
||||
|
||||
见 §5。
|
||||
|
||||
---
|
||||
|
||||
## 5. 回归验证结果
|
||||
## 5. 回归验证结果(实测)
|
||||
|
||||
| 验证项 | 命令 | 结果 |
|
||||
| 验证项 | 命令 / 方式 | 结果 |
|
||||
|---|---|---|
|
||||
| 生产构建 | `npx vite build --mode base` | 见下 |
|
||||
| 端点/契约复核 | 字段对照脚本 | 见下 |
|
||||
| Java 编译 | `mvn clean compile` | **未执行**(本机无 Maven/JDK) |
|
||||
| 生产构建 | `npx vite build --mode base` | ✅ 通过(~40s,exit 0) |
|
||||
| 后端编译 | `mvn -o -q compile`(JDK 1.8 + Maven 3.6.1) | ✅ 通过(exit 0,改动类已重新编译) |
|
||||
| SFC 类型检查 | `vue-tsc --noEmit`(临时换 TS 5.2.2,已还原) | ✅ 课程作业模块 0 错误;全仓 162 → 78 |
|
||||
| 契约运行时 | mock 冒烟脚本 `.backup/20260916-m5-v2-mock/smoke.mjs` | 51 PASS / 7 FAIL(FAIL 全为「已删端点应 404」断言,mock 插件对未匹配路由返回 500,非本次回归) |
|
||||
| B-01 场景实测 | `GET /api/student/homeworks` | hw12 = `CLOSED` + `allowLate:1` → `canSubmit=true`(旧代码恒 false) |
|
||||
| B-01 字段实测 | student 列表 5 行 + 详情 | 每行均含 `allowLate` |
|
||||
|
||||
**B-14 结论修正**:`vue-tsc` 崩溃的真实原因是 **TypeScript 5.9.3 与 vue-tsc 1.8.27 不兼容**(vue-tsc 1.8 仅支持 TS ≤5.2),**与 Node 版本无关**——Node 16/24 下同样报错。临时降级 TS 后类型检查正常,故本次得以完成全量校验。
|
||||
|
||||
---
|
||||
|
||||
## 6. 遗留风险与后续建议
|
||||
## 6. 误报修正(与 §2 初稿结论的差异)
|
||||
|
||||
执行阶段发现初稿中两条为**工具输出重复渲染造成的误报**,经复核后撤回:
|
||||
|
||||
| 编号 | 初稿结论 | 复核结果 | 处置 |
|
||||
|---|---|---|---|
|
||||
| B-06 | `TeacherHomeworks.vue` 存在 4 个重复函数声明 | 实际 `function viewDetail/viewSubmissions/toGrade/edit` **各仅 1 处** | **撤回**(无此缺陷) |
|
||||
| B-07 | `moreActions` Map 无界增长 | 该模式不存在;实为模板 `<Dropdown @on-click="onPubMore(name,row)">`,无缓存 | **撤回**(无此缺陷) |
|
||||
|
||||
**勘误原因**:早期整文件读取的一次响应出现内容重复,导致据此误判。后续所有结论均改为以 `grep`/定点 `sed` 复核为准。
|
||||
|
||||
**新增确认项**:`src/utils/alova/alova-vant.ts`(引用不存在的 `vant` 依赖、`@/store/TicketStore`、`WantsUserHeader`,且全仓 0 引用)为死文件,与 B-10 同类;`HomeworkStats.vue` 内联 `:columns` 的 `render: (h,p)` 隐式 any 已修复。
|
||||
|
||||
---
|
||||
|
||||
## 7. 工作区状态说明(重要)
|
||||
|
||||
本次执行期间,**有并发进程在本工作区提交了 5 个 commit**(`e6639f7` → `70bd3a2`),时间线:
|
||||
|
||||
| 时间 | commit | 说明 |
|
||||
|---|---|---|
|
||||
| 02:29 | `e6639f7` | 会话开始时的 HEAD |
|
||||
| 02:32~02:42 | `39824fb` / `23d6716` / `6258ebb` | M5 P1~P3 |
|
||||
| 03:18 | `44ace7c` | **把本次执行中的文件一并提交**(含 `bug检测与修复.md`、`api/index.ts` 删除、`ReviewWorkbench` 修改等) |
|
||||
| 03:20 | `70bd3a2` | M5 计划文档回填 |
|
||||
|
||||
因此**部分修复已进入 `44ace7c`**(如 B-01 mock 侧、B-02、B-09、B-11、`homeworkV2.ts` 的 `allowLate`),**另一部分仍为未提交的工作区改动**(S9 根因修复的 4 个 `api/module/*.ts`、`ReviewModal`/`HomeworkStats`/`StudentHomeworks` 等类型清理)。
|
||||
|
||||
> ⚠️ 本次未执行 `git commit`。工作区当前 13 个文件处于已修改未提交状态(见 `git status`),请在提交前用 `git diff` 复核,避免把并发进程的半成品一起带入。
|
||||
|
||||
---
|
||||
|
||||
## 8. 遗留风险与后续建议
|
||||
|
||||
1. **`vue-tsc` 版本必须对齐(最高优先)**:当前 `vue-tsc@1.8.27` 与 `typescript@5.9.3` 不兼容,类型门禁长期失效,是 B-01/B-03/B-04/B-15 逃逸的共同前提。建议二选一:① `typescript` 锁到 `~5.2`;② `vue-tsc` 升到 `^2.x`(配 TS ≥5.4)。并纳入 CI(`vue-tsc --noEmit`)。
|
||||
2. **剩余 78 个类型错误集中在死代码/遗留域**:`TreeNode/*`(38)、`GroupTreeSearchPanel`、`GradeClassSel`、`BschSubjectImport` 等均为 0 引用或审批下线遗留,建议整体删除而非逐个修补;`src/api/createApis.ts` 是 `alova gen` 遗留,随 `api/index.ts` 一并评估删除。
|
||||
3. **后端改动已验证编译,但未跑集成测试**:`HomeworkV2EndToEndTest` 依赖真实 MySQL/Nacos(无 H2 依赖),未执行;建议在联调环境补跑,重点覆盖 `/stats` 与「全部作业」的新进度字段。
|
||||
4. **mock 与真实后端口径漂移**:`homeworkapi.ts` 主动补齐了后端不存在的字段,使 mock 模式「看起来一切正常」。**建议以「后端 VO 为唯一真源」反向校验 mock**——本次 B-01/B-03/B-04 三条 P0 皆因 mock 代偿而长期不可见。
|
||||
5. **`/stats` 与「全部作业」建议评估退役**:其课程作业口径字段在 V2 已有权威实现(`teacherApi.stats`、`published` 的 `progress`);本次为兼容在 V1 VO 上补了字段,长期应整体切 V2。
|
||||
6. **换行符噪音(B-12)**:建议 `.gitattributes` 固定 `*.ts text eol=lf`,避免无内容变更的文件反复出现在 diff 中。
|
||||
7. **`baseApi.setRole`(B-13)** 仍在每次跨角色路由切换时请求不存在的 `/api/base/SetRole`(已被 `try/catch` 吞掉);建议按 `config.isMock` 前置短路,纯净化真实环境网络面板。
|
||||
|
||||
1. **类型门禁形同虚设(B-14,最高优先)**:本次 B-01/B-03/B-04 三条 P0 全部是「类型声明比后端多」的静默错配。恢复 `vue-tsc` 可运行(Node 18/20 或升级 `vue-tsc`)并纳入 CI,是防止同类问题复发的根本手段。
|
||||
2. **mock 与真实后端口径漂移**:`homeworkapi.ts` 主动补齐了后端不存在的字段,使 mock 模式「看起来一切正常」,掩盖真实缺陷。建议以「后端 VO 为唯一真源」生成/校验 mock。
|
||||
3. **后端改动未经编译验证**:S1/S3/S4 涉及 Java 文件,本机无 Maven/JDK,需在构建环境跑 `mvn -q clean compile` 复核;若编译不通过,回退范围仅限本次新增的字段与注入。
|
||||
4. **`/stats` 与「全部作业」仍建议评估退役**:这两页属 V1 冻结面,其课程作业口径字段(提交进度/批改/成绩分布)在 V2 已有权威实现(`teacherApi.stats` / `published` 的 `progress`)。长期建议整体切 V2,避免在 V1 VO 上持续打补丁。
|
||||
5. **`GitHub` 换行符噪音(B-12)**:建议统一 `core.autocrlf` 或在 `.gitattributes` 中固定 `*.ts text eol=lf`,避免无内容变更的文件反复出现在 diff 中。
|
||||
|
||||
+20
@@ -227,4 +227,24 @@ body {
|
||||
vertical-align: middle;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 表格横向滚动条(对齐宿舍通知):列多的后台表格**就该横向滚动**,不要去压列宽收进一屏。
|
||||
* 浏览器默认的粗灰条又厚又灰,正好贴在分页条上方,看着像「表格压住了分页」;
|
||||
* 收成 8px 浅灰圆角、轨道透明,与整页观感一致,也不再挤占表体高度(高度已在
|
||||
* calcTableHeight 里预留 15px)。
|
||||
*/
|
||||
.ivu-table-overflowX::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
.ivu-table-overflowX::-webkit-scrollbar-thumb {
|
||||
background: #dcdee2;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ivu-table-overflowX::-webkit-scrollbar-thumb:hover {
|
||||
background: #c5c8ce;
|
||||
}
|
||||
.ivu-table-overflowX::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
+11
-14
@@ -1,12 +1,9 @@
|
||||
import { Method } from "alova";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import conf from "@/assets/config/host.config";
|
||||
import type {
|
||||
HomeworkVo,
|
||||
HomeworkDetailVo,
|
||||
BasePageResult,
|
||||
UpFileResultVo,
|
||||
UpFileVo,
|
||||
NextPrevVo,
|
||||
ReadGroup,
|
||||
HomeworkStatisticsVo,
|
||||
@@ -34,20 +31,20 @@ const HOMEWORK = "/api/homework";
|
||||
const BASE = "/api/base";
|
||||
const FILE = "/api/file";
|
||||
|
||||
/** GET 请求,T 为返回数据类型 */
|
||||
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
/**
|
||||
* GET 请求,T 为返回数据类型。
|
||||
* 用 alovaInstance.Get<T>(而非 new Method<T>):Method 的泛型参数是 AlovaGenerics 而非数据类型,
|
||||
* 传数据类型会触发 TS2344 并把整条链退化为 unknown,令 send() 失去类型、契约错配无法在编译期暴露。
|
||||
*/
|
||||
function get<T = any>(url: string, params?: Record<string, any>) {
|
||||
return alovaInstance.Get<T>(url, { params });
|
||||
}
|
||||
|
||||
/** POST JSON body(与后端 @RequestBody 对齐),T 为返回数据类型 */
|
||||
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
|
||||
return new Method<T>(
|
||||
"POST",
|
||||
alovaInstance,
|
||||
url,
|
||||
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||
data ? JSON.stringify(data) : undefined
|
||||
);
|
||||
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]) {
|
||||
return alovaInstance.Post<T>(url, data ? JSON.stringify(data) : undefined, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
/** 附件下载直链(浏览器 location.href 跳转) */
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Method } from "alova";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import type { BasePageResult, NotificationVo, PageQueryV2 } from "@/types/homeworkV2";
|
||||
|
||||
@@ -9,14 +8,18 @@ import type { BasePageResult, NotificationVo, PageQueryV2 } from "@/types/homewo
|
||||
|
||||
const NOTIFY = "/api/notifications";
|
||||
|
||||
/** GET 请求 */
|
||||
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
/**
|
||||
* GET 请求。
|
||||
* 用 alovaInstance.Get<T>(而非 new Method<T>):Method 的泛型参数是 AlovaGenerics 而非数据类型,
|
||||
* 传数据类型会触发 TS2344 并把整条链退化为 unknown,令 send() 失去类型、契约错配无法在编译期暴露。
|
||||
*/
|
||||
function get<T = any>(url: string, params?: Record<string, any>) {
|
||||
return alovaInstance.Get<T>(url, { params });
|
||||
}
|
||||
|
||||
/** POST(无 body 写操作) */
|
||||
function post<T = any>(url: string): Method<T> {
|
||||
return new Method<T>("POST", alovaInstance, url);
|
||||
function post<T = any>(url: string) {
|
||||
return alovaInstance.Post<T>(url);
|
||||
}
|
||||
|
||||
class NotificationApi {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Method } from "alova";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import type {
|
||||
BasePageResult,
|
||||
@@ -19,31 +18,27 @@ import type {
|
||||
|
||||
const STUDENT = "/api/student/homeworks";
|
||||
|
||||
/** GET 请求 */
|
||||
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
/**
|
||||
* GET 请求。
|
||||
* 用 alovaInstance.Get<T>(而非 new Method<T>):Method 的泛型参数是 AlovaGenerics 而非数据类型,
|
||||
* 传数据类型会触发 TS2344 并把整条链退化为 unknown,令 send() 失去类型、契约错配无法在编译期暴露。
|
||||
*/
|
||||
function get<T = any>(url: string, params?: Record<string, any>) {
|
||||
return alovaInstance.Get<T>(url, { params });
|
||||
}
|
||||
|
||||
/** POST JSON body(@RequestBody) */
|
||||
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
|
||||
return new Method<T>(
|
||||
"POST",
|
||||
alovaInstance,
|
||||
url,
|
||||
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
);
|
||||
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]) {
|
||||
return alovaInstance.Post<T>(url, data ? JSON.stringify(data) : undefined, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
/** PUT JSON body(@RequestBody) */
|
||||
function putJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
|
||||
return new Method<T>(
|
||||
"PUT",
|
||||
alovaInstance,
|
||||
url,
|
||||
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
);
|
||||
function putJSON<T = any>(url: string, data?: Record<string, any> | any[]) {
|
||||
return alovaInstance.Put<T>(url, data ? JSON.stringify(data) : undefined, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
class StudentHomeworkApi {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Method } from "alova";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import conf from "@/assets/config/host.config";
|
||||
import type {
|
||||
@@ -12,6 +11,7 @@ import type {
|
||||
ReturnRequest,
|
||||
StudentMatrixQuery,
|
||||
StudentMatrixVo,
|
||||
SubjectVo,
|
||||
SubmissionVersionVo,
|
||||
TeacherHomeworkQuery,
|
||||
} from "@/types/homeworkV2";
|
||||
@@ -25,36 +25,32 @@ import type {
|
||||
|
||||
const TEACHER = "/api/teacher/homeworks";
|
||||
|
||||
/** GET 请求 */
|
||||
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
/**
|
||||
* GET 请求。
|
||||
* 用 alovaInstance.Get<T>(而非 new Method<T>):Method 的泛型参数是 AlovaGenerics 而非数据类型,
|
||||
* 传数据类型会触发 TS2344 并把整条链退化为 unknown,令 send() 失去类型、契约错配无法在编译期暴露。
|
||||
*/
|
||||
function get<T = any>(url: string, params?: Record<string, any>) {
|
||||
return alovaInstance.Get<T>(url, { params });
|
||||
}
|
||||
|
||||
/** POST JSON body(@RequestBody) */
|
||||
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
|
||||
return new Method<T>(
|
||||
"POST",
|
||||
alovaInstance,
|
||||
url,
|
||||
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
);
|
||||
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]) {
|
||||
return alovaInstance.Post<T>(url, data ? JSON.stringify(data) : undefined, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
/** PUT JSON body(@RequestBody) */
|
||||
function putJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
|
||||
return new Method<T>(
|
||||
"PUT",
|
||||
alovaInstance,
|
||||
url,
|
||||
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
);
|
||||
function putJSON<T = any>(url: string, data?: Record<string, any> | any[]) {
|
||||
return alovaInstance.Put<T>(url, data ? JSON.stringify(data) : undefined, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE(无 body) */
|
||||
function del<T = any>(url: string): Method<T> {
|
||||
return new Method<T>("DELETE", alovaInstance, url);
|
||||
function del<T = any>(url: string) {
|
||||
return alovaInstance.Delete<T>(url);
|
||||
}
|
||||
|
||||
class TeacherHomeworkApi {
|
||||
@@ -92,8 +88,8 @@ class TeacherHomeworkApi {
|
||||
published(params: TeacherHomeworkQuery) {
|
||||
return get<BasePageResult<HomeworkV2Vo>>(`${TEACHER}/published`, params);
|
||||
}
|
||||
/** 提醒未读/未交学生,返回 { reminded: 实际提醒人数 } */
|
||||
remind(id: number, type: RemindType, studentIds?: string[]) {
|
||||
/** 提醒未读/未交学生,返回 { reminded: 实际提醒人数 };studentIds 为用户中心数字ID */
|
||||
remind(id: number, type: RemindType, studentIds?: number[]) {
|
||||
return postJSON<{ reminded: number }>(`${TEACHER}/${id}/remind`, { type, studentIds });
|
||||
}
|
||||
/** 延长截止时间(新截止须晚于当前时间) */
|
||||
@@ -132,29 +128,41 @@ class TeacherHomeworkApi {
|
||||
|
||||
// ========== 页 6:批阅 / 打回 / 版本 ==========
|
||||
|
||||
/** 保存批阅(→ REVIEWED,推送批阅完成通知);score 与 comment 至少一项 */
|
||||
review(id: number, studentId: string, data: ReviewRequest) {
|
||||
/** 保存批阅(→ REVIEWED,推送批阅完成通知);score 与 comment 至少一项;studentId 为用户中心数字ID */
|
||||
review(id: number, studentId: number, data: ReviewRequest) {
|
||||
return postJSON<void>(`${TEACHER}/${id}/submissions/${studentId}/review`, data);
|
||||
}
|
||||
/** 打回(→ RETURNED,推送打回通知);comment 必填 */
|
||||
returnSubmission(id: number, studentId: string, data: ReturnRequest) {
|
||||
returnSubmission(id: number, studentId: number, data: ReturnRequest) {
|
||||
return postJSON<void>(`${TEACHER}/${id}/submissions/${studentId}/return`, data);
|
||||
}
|
||||
/** 历史提交版本(批阅弹窗切换版本) */
|
||||
versions(id: number, studentId: string) {
|
||||
versions(id: number, studentId: number) {
|
||||
return get<SubmissionVersionVo[]>(`${TEACHER}/${id}/submissions/${studentId}/versions`);
|
||||
}
|
||||
|
||||
// ========== 辅助:班级树 / 名册同步 ==========
|
||||
|
||||
/** 年级→班级树(发布选班;叶子 id=classId,含在班学生数) */
|
||||
classTree() {
|
||||
return get<ClassTreeNodeV2[]>(`${TEACHER}/classes`);
|
||||
/** 年级→班级树(发布选班;叶子 id=classId,含在班学生数)。
|
||||
* subject 传入时按 bschclass 任教绑定过滤出该学科任教班级;不传 = 全部任教班级并集 */
|
||||
classTree(subject?: string) {
|
||||
return get<ClassTreeNodeV2[]>(`${TEACHER}/classes`, subject ? { subject } : undefined);
|
||||
}
|
||||
/** 手动触发名册同步(bschclass 接口就绪前为空操作) */
|
||||
syncClasses() {
|
||||
return postJSON<{ synced: number }>(`${TEACHER}/classes/sync`);
|
||||
}
|
||||
|
||||
// ========== 辅助:学科 ==========
|
||||
|
||||
/** 我布置过的学科(本人已发布及之后作业的学科 ∪ 我的任教学科,去重;草稿不计) */
|
||||
assignedSubjects() {
|
||||
return get<SubjectVo[]>(`${TEACHER}/subjects/assigned`);
|
||||
}
|
||||
/** 我的任教学科(bschclass 任教关系去重,名称取本校学科字典) */
|
||||
teachingSubjects() {
|
||||
return get<SubjectVo[]>(`${TEACHER}/subjects/teaching`);
|
||||
}
|
||||
}
|
||||
|
||||
export const teacherApi = new TeacherHomeworkApi();
|
||||
|
||||
@@ -36,6 +36,19 @@ let nextFileId = 9001;
|
||||
const ME = { account: "zhd", name: "朱皓东" };
|
||||
/** 学生演示视角所在班级 */
|
||||
const ME_CLASS = "1301";
|
||||
/** 任教关系(bschclass:教师 × 班级 × 学科 绑定,「根据学科筛选任教班级」的数据源)。
|
||||
* 演示身份朱皓东 = 初一(1)(2)(3) 班数学教师 */
|
||||
const teachingBindings = [
|
||||
{ account: "zhd", classId: "1301", subject: "数学" },
|
||||
{ account: "zhd", classId: "1302", subject: "数学" },
|
||||
{ account: "zhd", classId: "1303", subject: "数学" },
|
||||
];
|
||||
/** 演示身份任教学科(由任教绑定去重派生) */
|
||||
const TEACHING_SUBJECTS = [...new Set(teachingBindings.filter((b) => b.account === ME.account).map((b) => b.subject))];
|
||||
/** 按学科筛选任教班级 classId 集(subject 空 = 全部任教班级并集) */
|
||||
function taughtClassIds(subject?: string): Set<string> {
|
||||
return new Set(teachingBindings.filter((b) => b.account === ME.account && (!subject || b.subject === subject)).map((b) => b.classId));
|
||||
}
|
||||
|
||||
/** 年级 → 班级树(叶子=班级,带学生数) */
|
||||
const gradeClasses = [
|
||||
@@ -114,7 +127,6 @@ function fileToAttachment(id: number): any {
|
||||
|
||||
/* ============================ V2 存储(唯一数据源) ============================ */
|
||||
|
||||
type V2Status = "DRAFT_UNPUBLISHED" | "DRAFT_SCHEDULED" | "PUBLISHED" | "CLOSED" | "ARCHIVED";
|
||||
type SubmitSt = "NOT_SUBMITTED" | "SUBMITTED" | "LATE_SUBMITTED" | "RETURNED" | "REVIEWED";
|
||||
type ReviewSt = "PENDING" | "REVIEWED" | "RETURNED";
|
||||
|
||||
@@ -162,10 +174,10 @@ const homeworks: any[] = [
|
||||
},
|
||||
{
|
||||
id: 4, title: "国庆假期数学作业(计算巩固 + 生活中的数学)", subject: "数学",
|
||||
description: "<p>假期数学作业分两部分:①计算巩固——口算题卡 3 页;②实践探究——记录假期家庭一周开支并制成统计表。</p><p>已发布给全部班级,请于截止前提交,收假后第一节课查验。</p>",
|
||||
description: "<p>假期数学作业分两部分:①计算巩固——口算题卡 3 页;②实践探究——记录假期家庭一周开支并制成统计表。</p><p>已发布给全部任教班级,请于截止前提交,收假后第一节课查验。</p>",
|
||||
hwStatus: "PUBLISHED", scheduledAt: null, publishedAt: "2026-09-05 16:40:00", deadlineAt: "2026-10-08 08:00:00", archivedAt: null,
|
||||
submitType: ["text", "image"], allowLate: 1, remind1d: 1, remind1h: 0,
|
||||
classIds: [...ALL_CLASSES], attachments: [],
|
||||
classIds: ["1301", "1302", "1303"], attachments: [],
|
||||
deptId: "D002", publisherAccount: "zhd", publisherName: "朱皓东", createTime: "2026-09-05 16:40:00", updateTime: "2026-09-05 16:40:00",
|
||||
},
|
||||
{
|
||||
@@ -197,7 +209,7 @@ const homeworks: any[] = [
|
||||
description: "<p>利用平移、旋转与轴对称设计一幅图案,并标注所使用的变换方式。</p><p>(草拟中,暂未定稿)</p>",
|
||||
hwStatus: "DRAFT_UNPUBLISHED", scheduledAt: null, publishedAt: null, deadlineAt: "2026-09-18 18:00:00", archivedAt: null,
|
||||
submitType: ["image", "attach"], allowLate: 0, remind1d: 0, remind1h: 0,
|
||||
classIds: [...ALL_CLASSES], attachments: [],
|
||||
classIds: ["1301", "1302", "1303"], attachments: [],
|
||||
deptId: "D002", publisherAccount: "zhd", publisherName: "朱皓东", createTime: "2026-09-08 15:20:00", updateTime: "2026-09-08 15:20:00",
|
||||
},
|
||||
{
|
||||
@@ -994,18 +1006,25 @@ export default [
|
||||
|
||||
/* ---- 辅助:班级树 / 名册同步 ---- */
|
||||
{
|
||||
// 年级 → 班级树(叶子 id=classId,含 studentCount)
|
||||
// 年级 → 班级树(叶子 id=classId,含 studentCount)。subject 按任教绑定(bschclass)过滤:
|
||||
// 仅返回该学科任教班级,空 = 全部任教班级并集;过滤后无班级的年级组不返回
|
||||
url: "/api/teacher/homeworks/classes",
|
||||
method: "get",
|
||||
response: () =>
|
||||
ok(
|
||||
gradeClasses.map((g) => ({
|
||||
id: g.gradeName,
|
||||
name: g.gradeName,
|
||||
isParent: true,
|
||||
children: g.classes.map((c) => ({ id: String(c.classId), name: c.className, isParent: false, studentCount: c.studentCount })),
|
||||
}))
|
||||
),
|
||||
response: ({ query }: any) => {
|
||||
const allowed = taughtClassIds((query?.subject || "").trim() || undefined);
|
||||
return ok(
|
||||
gradeClasses
|
||||
.map((g) => ({
|
||||
id: g.gradeName,
|
||||
name: g.gradeName,
|
||||
isParent: true,
|
||||
children: g.classes
|
||||
.filter((c) => allowed.has(String(c.classId)))
|
||||
.map((c) => ({ id: String(c.classId), name: c.className, isParent: false, studentCount: c.studentCount })),
|
||||
}))
|
||||
.filter((g) => g.children.length)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// 手动触发名册同步(mock 空操作)
|
||||
@@ -1014,6 +1033,29 @@ export default [
|
||||
response: () => ok({ synced: 0 }),
|
||||
},
|
||||
|
||||
/* ---- 辅助:学科 ---- */
|
||||
{
|
||||
// 我的任教学科(任教绑定 bschclass 去重;演示身份=朱皓东,初一 3 班数学)
|
||||
url: "/api/teacher/homeworks/subjects/teaching",
|
||||
method: "get",
|
||||
response: () => ok(TEACHING_SUBJECTS.map((s) => ({ subId: s, subName: s }))),
|
||||
},
|
||||
{
|
||||
// 我布置过的学科(本人已发布及之后作业的学科 ∪ 任教学科,去重;草稿不计)
|
||||
url: "/api/teacher/homeworks/subjects/assigned",
|
||||
method: "get",
|
||||
response: () => {
|
||||
const codes: string[] = [];
|
||||
homeworks.forEach((n) => {
|
||||
if (!isDraft(n) && isOwned(n) && n.subject && !codes.includes(n.subject)) codes.push(n.subject);
|
||||
});
|
||||
TEACHING_SUBJECTS.forEach((s) => {
|
||||
if (!codes.includes(s)) codes.push(s);
|
||||
});
|
||||
return ok(codes.map((s) => ({ subId: s, subName: s })));
|
||||
},
|
||||
},
|
||||
|
||||
/* ==================== V2 学生端 /api/student/homeworks ==================== */
|
||||
|
||||
{
|
||||
@@ -1387,7 +1429,7 @@ export default [
|
||||
|
||||
/* ============================ 提交内核(对齐后端 doSubmit) ============================ */
|
||||
|
||||
function newSubmission(n: any, submitStatus: SubmitSt, isLate: number, text: any, atts: any[], version: number): any {
|
||||
function newSubmission(n: any, submitStatus: SubmitSt, isLate: number, text: any, atts: any[] | null, version: number): any {
|
||||
return {
|
||||
id: subSeq++,
|
||||
homeworkId: n.id,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,22 +1,26 @@
|
||||
<template>
|
||||
<!-- V2 附件上传:emit AttachmentItem[](fileId/fileUrl/fileType/fileName/sort)。
|
||||
fileUrl 统一存 /api/file/Preview?id=(浏览器可直接渲染;下载走 Down 直链)。 -->
|
||||
fileUrl 统一存 /api/file/Preview?id=(浏览器可直接渲染;下载走 Down 直链)。
|
||||
upload-row 是「按钮 + 右侧提示文字」一行的容器(见下方样式注释:
|
||||
不能把 Upload 根节点设成 flex,否则文件列表会被排到按钮右侧同一行)。 -->
|
||||
<div class="attachment-upload">
|
||||
<Upload
|
||||
:on-success="onSuccess"
|
||||
:on-remove="onRemove"
|
||||
:default-file-list="fileList"
|
||||
:action="uploadUrl"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
:format="formatArr"
|
||||
:max-size="204800"
|
||||
:on-format-error="onFormatError"
|
||||
:on-exceeded-size="onExceeded"
|
||||
>
|
||||
<Button><Icon type="ios-cloud-upload-outline"></Icon>{{ buttonText || (imageOnly ? "拍照/上传图片" : "选择文件") }}</Button>
|
||||
</Upload>
|
||||
<span class="upload-tip">大小不超过 200MB</span>
|
||||
<div class="upload-row">
|
||||
<Upload
|
||||
:on-success="onSuccess"
|
||||
:on-remove="onRemove"
|
||||
:default-file-list="fileList"
|
||||
:action="uploadUrl"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
:format="formatArr"
|
||||
:max-size="204800"
|
||||
:on-format-error="onFormatError"
|
||||
:on-exceeded-size="onExceeded"
|
||||
>
|
||||
<Button><Icon type="ios-cloud-upload-outline"></Icon>{{ buttonText || (imageOnly ? "拍照/上传图片" : "选择文件") }}</Button>
|
||||
</Upload>
|
||||
<span class="upload-tip">大小不超过 200MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,12 +110,32 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.attachment-upload {
|
||||
display: inline-block;
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* 「选择文件」按钮 + 右侧大小提示:一行内水平对齐,提示折行时与按钮首行对齐 */
|
||||
.upload-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/*
|
||||
* Upload 根节点必须是 block,不能是 flex/inline-flex。
|
||||
* view-ui-plus 的 Upload 内部是两个兄弟节点:
|
||||
* .ivu-upload-select(「选择文件」触发器) + ul.ivu-upload-list(已上传文件列表)
|
||||
* 根节点一旦是 flex,作为 flex 项的 ul 就会被排到按钮同一行的右侧,
|
||||
* 附件名紧贴按钮右边(即「附件显示在旁边」)。改回 block 后,
|
||||
* ul 恢复块级流,自然落到按钮下方一行。
|
||||
*/
|
||||
:deep(.ivu-upload) {
|
||||
display: block;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
/* 只让触发器内部水平对齐,不影响它的兄弟节点 */
|
||||
:deep(.ivu-upload-select) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
/* 文件列表整体另起一行,每项独占一行 */
|
||||
:deep(.ivu-upload-list) {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
@@ -123,7 +147,8 @@ export default {
|
||||
margin-left: 10px;
|
||||
color: #f90;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
/* 与「选择文件」按钮同高,保证首行视觉居中对齐 */
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import ReviewWorkbench from "@/components/ReviewWorkbench.vue";
|
||||
import type { StudentMatrixVo } from "@/types/homeworkV2";
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
homeworkId: number;
|
||||
/** 学生矩阵行(studentId/studentName 必有) */
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
|
||||
<!-- 打回原因 -->
|
||||
<Modal v-model="returnModal.open" title="打回订正" width="460px">
|
||||
<Form :label-width="80">
|
||||
<FormItem label="订正原因" required>
|
||||
<Form ref="returnFormRef" :model="returnModal" :rules="returnRules" :label-width="80">
|
||||
<FormItem label="订正原因" prop="comment">
|
||||
<Input v-model="returnModal.comment" type="textarea" :rows="3" placeholder="打回须填写订正原因(学生可见)"></Input>
|
||||
</FormItem>
|
||||
</Form>
|
||||
@@ -87,7 +87,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import { ref, reactive, computed, watch, nextTick } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { teacherApi } from "@/api/module/teacherHomework";
|
||||
import type { AttachmentItem, SubmissionVersionVo, StudentMatrixVo } from "@/types/homeworkV2";
|
||||
@@ -198,9 +198,15 @@ async function doReview() {
|
||||
}
|
||||
|
||||
const returnModal = reactive({ open: false, comment: "" });
|
||||
/* FormItem 走 prop+rules 校验:只写 required 不写 prop 会被以 undefined 为字段名校验,
|
||||
* 输入框失焦即弹「undefined is required」 */
|
||||
const returnFormRef = ref();
|
||||
const returnRules = { comment: [{ required: true, message: "请填写订正原因", trigger: "blur" }] };
|
||||
function openReturn() {
|
||||
returnModal.comment = "";
|
||||
returnModal.open = true;
|
||||
/* 清掉上一次打回遗留的校验红字 */
|
||||
nextTick(() => returnFormRef.value?.resetFields?.());
|
||||
}
|
||||
async function doReturn() {
|
||||
if (!props.student) return;
|
||||
|
||||
+16
-3
@@ -100,6 +100,14 @@ export interface ClassTreeNodeV2 {
|
||||
children?: ClassTreeNodeV2[];
|
||||
}
|
||||
|
||||
/** 学科(我布置过的 / 我任教的共用结构) */
|
||||
export interface SubjectVo {
|
||||
/** 学科编码(homework.subject 存储值 / bschclass SubID;字典缺失时与 subName 同值) */
|
||||
subId: string;
|
||||
/** 学科名称(本校学科字典解析,缺失时回退为编码) */
|
||||
subName: string;
|
||||
}
|
||||
|
||||
// ============================ 教师端 ============================
|
||||
|
||||
/** 创建/编辑作业请求(POST / PUT /teacher/homeworks) */
|
||||
@@ -137,7 +145,8 @@ export interface HomeworkV2Vo {
|
||||
remind1h: number;
|
||||
classes: ClassItem[];
|
||||
attachments: AttachmentItem[];
|
||||
publisherId?: string;
|
||||
/** 用户中心数字ID */
|
||||
publisherId?: number;
|
||||
publisherName?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
@@ -174,7 +183,8 @@ export interface StudentMatrixQuery extends PageQueryV2 {
|
||||
/** 学生矩阵行(未交学生 submissionId=0 占位) */
|
||||
export interface StudentMatrixVo {
|
||||
submissionId: number;
|
||||
studentId: string;
|
||||
/** 用户中心数字ID */
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
@@ -190,6 +200,8 @@ export interface StudentMatrixVo {
|
||||
score?: number | null;
|
||||
/** 最新提交附件 URL 列表(缩略图) */
|
||||
thumbnails?: string[];
|
||||
/** 提交内容类型摘要:image=拍照/图片、attach=上传附件、text=文字作答(未交为空) */
|
||||
contentTypes?: string[];
|
||||
}
|
||||
|
||||
/** 批阅保存请求(score 与 comment 至少一项) */
|
||||
@@ -209,7 +221,8 @@ export interface ReturnRequest {
|
||||
export interface SubmissionVersionVo {
|
||||
submissionId: number;
|
||||
homeworkId: number;
|
||||
studentId: string;
|
||||
/** 用户中心数字ID */
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
version: number;
|
||||
/** 0/1 */
|
||||
|
||||
@@ -127,6 +127,71 @@ export default class Utils {
|
||||
);
|
||||
}
|
||||
|
||||
/* ================= 作业模块:状态列的统一样式 ================= */
|
||||
|
||||
/**
|
||||
* 作业状态元数据(图标 + 语义色)。V2 五态与 V1 四态共用一张表。
|
||||
*
|
||||
* 「我布置的」(TeacherHomeworks) 与「全部作业」(HomeworkList) 各自写一份 Tag 颜色表
|
||||
* 是这两个页面观感跑偏的根因(同一件事一边 Tag 一边圆点),所以集中到这里:
|
||||
* 改一处两边同步,新增状态只在这一处登记。
|
||||
*/
|
||||
public static readonly HW_STATUS_META: { [key: string]: { text: string; color: string; icon: string } } = {
|
||||
// —— V2 五态(我布置的:已发布 + 草稿箱)——
|
||||
DRAFT_UNPUBLISHED: { text: "未发布", color: "#909399", icon: "icon-caogaoxiang" },
|
||||
DRAFT_SCHEDULED: { text: "定时发布", color: "#2d8cf0", icon: "icon-shijian" },
|
||||
PUBLISHED: { text: "进行中", color: "#2d8cf0", icon: "icon-fasong" },
|
||||
CLOSED: { text: "已截止", color: "#ff9900", icon: "icon-stop" },
|
||||
ARCHIVED: { text: "已归档", color: "#808695", icon: "icon-yasuo" },
|
||||
// —— V1 四态(全部作业:冻结接口的四种状态)——
|
||||
draft: { text: "草稿", color: "#909399", icon: "icon-caogaoxiang" },
|
||||
collecting: { text: "收集中", color: "#2d8cf0", icon: "icon-fasong" },
|
||||
closed: { text: "已截止", color: "#ff9900", icon: "icon-stop" },
|
||||
done: { text: "已完成", color: "#19be6b", icon: "icon-shenpi" },
|
||||
};
|
||||
|
||||
/**
|
||||
* 十六进制色 → rgba。
|
||||
* 状态胶囊的浅色底/描边要按状态色带透明度派生,8 位 #RRGGBBAA 写在 WebView 里
|
||||
* 未必认得,这里显式转 rgba 更稳。
|
||||
*/
|
||||
public static hexToRgba(hex: string, alpha: number): string {
|
||||
const raw = String(hex || "").replace("#", "");
|
||||
const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw;
|
||||
const n = parseInt(full || "909399", 16);
|
||||
if (isNaN(n)) return `rgba(144, 147, 153, ${alpha})`;
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态胶囊单元格:状态图标 + 状态文字 + 同色浅底。
|
||||
*
|
||||
* 不采用「只有图标」的方案:草稿箱的「定时 2026-09-18 08:00」一旦只留图标,
|
||||
* 定时时间就没了;图标 + 文字既统一了观感又不丢信息。
|
||||
*
|
||||
* @param h render 函数的 h
|
||||
* @param code 状态码(V2 五态 / V1 四态均可)
|
||||
* @param text 覆盖默认文案(如草稿的「定时 2026-09-18 08:00」)
|
||||
*/
|
||||
public static hwStatusPill(h: any, code: string | undefined, text?: string) {
|
||||
const meta = Utils.HW_STATUS_META[code || ""] || { text: code || "—", color: "#909399", icon: "icon-biaoqian" };
|
||||
const color = meta.color;
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
style: `display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 8px;border-radius:11px;font-size:12px;line-height:1;white-space:nowrap;background:${Utils.hexToRgba(
|
||||
color,
|
||||
0.08,
|
||||
)};border:1px solid ${Utils.hexToRgba(color, 0.24)};color:${color};`,
|
||||
},
|
||||
[
|
||||
// 图标吃 currentColor(icons.css 用 mask + background-color),颜色随状态色
|
||||
h("i", { class: `iconfont ${meta.icon}`, style: "font-size:13px;flex:none;" }),
|
||||
h("span", {}, text || meta.text),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算表格高度
|
||||
*/
|
||||
|
||||
+232
-35
@@ -3,26 +3,51 @@ import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { teacherApi } from "@/api/module/teacherHomework";
|
||||
import type { HomeworkV2Vo, StudentMatrixVo } from "@/types/homeworkV2";
|
||||
import type { HomeworkV2Vo, StudentMatrixVo, SubjectVo } from "@/types/homeworkV2";
|
||||
import ReviewWorkbench from "@/components/ReviewWorkbench.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const serid = ref<number>(Number(route.query.serid) || 0);
|
||||
|
||||
/* ============ 待批作业(已发布且 pendingGrade>0,按待批数降序) ============ */
|
||||
/* ============ 任教学科筛选(/subjects/teaching;接口失败或为空时降级内置清单) ============ */
|
||||
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "道德与法治", "历史", "地理", "科学", "综合实践"];
|
||||
const subjectOpts = ref<SubjectVo[]>(SUBJECTS.map((s) => ({ subId: s, subName: s })));
|
||||
const selSubject = ref("");
|
||||
async function loadSubjects() {
|
||||
try {
|
||||
const list = await teacherApi.teachingSubjects().send();
|
||||
if (Array.isArray(list) && list.length) subjectOpts.value = list;
|
||||
} catch {
|
||||
/* 降级用内置清单 */
|
||||
}
|
||||
}
|
||||
function onSubject() {
|
||||
loadHomeworks();
|
||||
}
|
||||
/** 提示文案用的学科名(subId 为存储编码,展示取字典名) */
|
||||
const selSubjectName = computed(() => subjectOpts.value.find((s) => s.subId === selSubject.value)?.subName || selSubject.value);
|
||||
|
||||
/* ============ 待批作业(已发布且勾选了提交渠道=需批改,按待批数降序;受任教学科筛选) ============ */
|
||||
const homeworks = ref<HomeworkV2Vo[]>([]);
|
||||
const loadingHw = ref(false);
|
||||
async function loadHomeworks() {
|
||||
loadingHw.value = true;
|
||||
try {
|
||||
const r = await teacherApi.published({ pageIndex: 1, pageSize: 100 }).send();
|
||||
const r = await teacherApi.published({ pageIndex: 1, pageSize: 100, subject: selSubject.value || undefined }).send();
|
||||
// 仅查阅(submitType 仅 none)=教师无需批改,不进入批改中心
|
||||
homeworks.value = (r.content || [])
|
||||
.filter((h) => (h.progress?.pendingGrade ?? 0) > 0)
|
||||
.filter((h) => (h.submitType || []).some((t) => t !== "none"))
|
||||
.sort((a, b) => (b.progress?.pendingGrade ?? 0) - (a.progress?.pendingGrade ?? 0));
|
||||
// 未带 serid 进入时自动选中第一份待批作业
|
||||
if (!serid.value && homeworks.value.length) {
|
||||
// 未带 serid 进入、或当前作业不在筛选结果中时,自动选中第一份待批作业
|
||||
if (homeworks.value.length && (!serid.value || !homeworks.value.some((h) => h.id === serid.value))) {
|
||||
pickHw(homeworks.value[0].id);
|
||||
} else if (!homeworks.value.length && serid.value) {
|
||||
// 学科筛选后无待批作业且当前选中被排除:清空选中
|
||||
serid.value = 0;
|
||||
router.replace({ path: "/grade" });
|
||||
loadCounts();
|
||||
loadQueue();
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载待批作业失败");
|
||||
@@ -31,12 +56,89 @@ async function loadHomeworks() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 待批队列(矩阵 pendingGrade=true) ============ */
|
||||
/* ============ 班级 / 批改状态筛选 ============ */
|
||||
type GradeState = "pending" | "graded" | "unsubmitted";
|
||||
interface ClassCount { classId: string; className: string; pending: number; graded: number; unsubmitted: number }
|
||||
interface TabCounts { pending: number; graded: number; unsubmitted: number; byClass: ClassCount[] }
|
||||
const counts = ref<TabCounts | null>(null);
|
||||
const selClass = ref("");
|
||||
const selState = ref<GradeState>("pending");
|
||||
|
||||
const isPendingRow = (r: StudentMatrixVo) =>
|
||||
(r.submitStatus === "SUBMITTED" || r.submitStatus === "LATE_SUBMITTED") && r.reviewStatus === "PENDING";
|
||||
const isGradedRow = (r: StudentMatrixVo) => r.reviewStatus === "REVIEWED";
|
||||
const isUnsubmittedRow = (r: StudentMatrixVo) => r.submitStatus === "NOT_SUBMITTED";
|
||||
|
||||
async function loadCounts() {
|
||||
if (!serid.value) {
|
||||
counts.value = null;
|
||||
return;
|
||||
}
|
||||
// 优先 /stats(total + byClass);接口未就绪或缺 byClass 时,兜底用 /students 全量矩阵客户端汇总
|
||||
try {
|
||||
const s = await teacherApi.stats(serid.value).send();
|
||||
if (s && Array.isArray(s.byClass) && s.byClass.length) {
|
||||
counts.value = {
|
||||
pending: s.total.pendingGrade,
|
||||
graded: s.total.graded,
|
||||
unsubmitted: s.total.unsubmitted,
|
||||
byClass: s.byClass.map((c) => ({ classId: c.classId, className: c.className, pending: c.progress.pendingGrade, graded: c.progress.graded, unsubmitted: c.progress.unsubmitted })),
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* 走兜底 */
|
||||
}
|
||||
try {
|
||||
const r = await teacherApi.students(serid.value, { pageIndex: 1, pageSize: 1000 }).send();
|
||||
const list = r.content || [];
|
||||
const byClass: ClassCount[] = [];
|
||||
const map = new Map<string, ClassCount>();
|
||||
for (const row of list) {
|
||||
let c = map.get(row.classId);
|
||||
if (!c) {
|
||||
c = { classId: row.classId, className: row.className, pending: 0, graded: 0, unsubmitted: 0 };
|
||||
map.set(row.classId, c);
|
||||
byClass.push(c);
|
||||
}
|
||||
if (isUnsubmittedRow(row)) c.unsubmitted++;
|
||||
else if (isPendingRow(row)) c.pending++;
|
||||
else if (isGradedRow(row)) c.graded++;
|
||||
}
|
||||
counts.value = {
|
||||
pending: list.filter(isPendingRow).length,
|
||||
graded: list.filter(isGradedRow).length,
|
||||
unsubmitted: list.filter(isUnsubmittedRow).length,
|
||||
byClass,
|
||||
};
|
||||
} catch {
|
||||
counts.value = null;
|
||||
}
|
||||
}
|
||||
function pickFilter(classId: string, state: GradeState) {
|
||||
if (selClass.value === classId && selState.value === state) return;
|
||||
selClass.value = classId;
|
||||
selState.value = state;
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
/* ============ 待批/已批队列(矩阵 classId + pendingGrade/reviewStatus 筛选) ============ */
|
||||
const queue = ref<StudentMatrixVo[]>([]);
|
||||
const idx = ref(0);
|
||||
const loadingQueue = ref(false);
|
||||
const homeworkTitle = ref("");
|
||||
|
||||
/* ============ 提交内容类型角标(按学生实际提交内容展示:图/附/文,不做筛选) ============ */
|
||||
const TYPE_OPTS = [
|
||||
{ v: "image", label: "拍照/图片", tag: "图" },
|
||||
{ v: "attach", label: "上传附件", tag: "附" },
|
||||
{ v: "text", label: "文字作答", tag: "文" },
|
||||
];
|
||||
const typeTag = (v: string) => TYPE_OPTS.find((t) => t.v === v)?.tag || v;
|
||||
const current = computed(() => queue.value[idx.value] || null);
|
||||
const queueTitle = computed(() =>
|
||||
selState.value === "graded" ? "已批队列" : selState.value === "unsubmitted" ? "未提交名单" : "待批队列"
|
||||
);
|
||||
|
||||
async function loadQueue() {
|
||||
if (!serid.value) {
|
||||
@@ -48,19 +150,31 @@ async function loadQueue() {
|
||||
try {
|
||||
const hw = homeworks.value.find((h) => h.id === serid.value);
|
||||
homeworkTitle.value = hw ? hw.title : "";
|
||||
const r = await teacherApi.students(serid.value, { pendingGrade: true, pageIndex: 1, pageSize: 200 }).send();
|
||||
const r = await teacherApi.students(serid.value, {
|
||||
classId: selClass.value || undefined,
|
||||
pendingGrade: selState.value === "pending" ? true : undefined,
|
||||
reviewStatus: selState.value === "graded" ? "REVIEWED" : undefined,
|
||||
submitStatus: selState.value === "unsubmitted" ? "NOT_SUBMITTED" : undefined,
|
||||
pageIndex: 1,
|
||||
pageSize: 200,
|
||||
}).send();
|
||||
queue.value = r.content || [];
|
||||
idx.value = 0;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载待批名单失败");
|
||||
Message.error(e.message || "加载学生名单失败");
|
||||
queue.value = [];
|
||||
} finally {
|
||||
loadingQueue.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 批阅/打回完成:移出队列并前移;全批完则刷新作业列表 */
|
||||
/* 批阅/打回完成:待批视图本地移出;已批视图(打回)整队刷新;数量同步刷新 */
|
||||
function onSaved() {
|
||||
loadCounts();
|
||||
if (selState.value === "graded") {
|
||||
loadQueue();
|
||||
return;
|
||||
}
|
||||
const done = idx.value;
|
||||
queue.value.splice(done, 1);
|
||||
if (queue.value.length === 0) {
|
||||
@@ -84,7 +198,10 @@ const workbenchKey = computed(() => `${serid.value}-${current.value?.studentId ?
|
||||
|
||||
function pickHw(v: number) {
|
||||
serid.value = v;
|
||||
selClass.value = "";
|
||||
selState.value = "pending";
|
||||
router.replace({ path: "/grade", query: v ? { serid: String(v) } : {} });
|
||||
loadCounts();
|
||||
loadQueue();
|
||||
}
|
||||
watch(
|
||||
@@ -93,12 +210,17 @@ watch(
|
||||
const n = Number(v) || 0;
|
||||
if (n !== serid.value) {
|
||||
serid.value = n;
|
||||
selClass.value = "";
|
||||
selState.value = "pending";
|
||||
loadCounts();
|
||||
loadQueue();
|
||||
}
|
||||
}
|
||||
);
|
||||
onMounted(async () => {
|
||||
loadSubjects();
|
||||
await loadHomeworks();
|
||||
loadCounts();
|
||||
loadQueue();
|
||||
});
|
||||
</script>
|
||||
@@ -106,9 +228,13 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 顶部:待批作业选择 -->
|
||||
<!-- 顶部:任教学科筛选 + 待批作业选择 -->
|
||||
<div class="fix mb-12">
|
||||
<div class="fl" style="display: flex; align-items: center; gap: 8px">
|
||||
<span style="color: #515a6e">任教学科:</span>
|
||||
<Select v-model="selSubject" clearable placeholder="全部学科" style="width: 140px" @on-change="onSubject">
|
||||
<Option v-for="s in subjectOpts" :key="s.subId" :value="s.subId">{{ s.subName }}</Option>
|
||||
</Select>
|
||||
<span style="color: #515a6e">待批作业:</span>
|
||||
<Select :model-value="serid" style="width: 380px" placeholder="选择一份有待批的作业" filterable :loading="loadingHw" @on-change="pickHw">
|
||||
<Option v-for="hw in homeworks" :key="hw.id" :value="hw.id">
|
||||
@@ -118,36 +244,57 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!homeworks.length" class="tip">🎉 当前没有待批改的作业。</p>
|
||||
<p v-if="!homeworks.length" class="tip">🎉 {{ selSubject ? `「${selSubjectName}」学科下暂无需要批改的作业。` : "当前没有需要批改的作业。" }}</p>
|
||||
<p v-else-if="!serid" class="tip">请从上方选择一份待批改的作业。</p>
|
||||
|
||||
<div v-else class="workbench fix">
|
||||
<!-- 左:待批队列 -->
|
||||
<div class="wb-left fl">
|
||||
<div class="wb-h">待批队列({{ queue.length }} 份)</div>
|
||||
<p v-if="!loadingQueue && !queue.length" class="done">🎉 该作业已全部批改完成</p>
|
||||
<Spin v-if="loadingQueue" fix />
|
||||
<ul class="qlist">
|
||||
<li v-for="(s, i) in queue" :key="s.studentId" :class="{ act: i === idx }" @click="select(i)">
|
||||
<div class="q1">
|
||||
{{ s.studentName }}
|
||||
<span v-if="s.isLate" class="q-late">逾期</span>
|
||||
</div>
|
||||
<div class="q2">{{ s.className }} · {{ s.submittedAt ? String(s.submittedAt).slice(5, 16) : "" }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else>
|
||||
<!-- 班级 / 批改状态筛选(/stats 或 /students 兜底汇总) -->
|
||||
<div v-if="counts" class="class-bar">
|
||||
<span class="grp">
|
||||
<span class="cls" :class="{ on: !selClass }" @click="pickFilter('', selState)">全部班级({{ counts.pending + counts.graded + counts.unsubmitted }}):</span>
|
||||
<span class="seg" :class="{ on: !selClass && selState === 'pending' }" @click="pickFilter('', 'pending')">待批改({{ counts.pending }})</span>
|
||||
<span class="seg" :class="{ on: !selClass && selState === 'graded' }" @click="pickFilter('', 'graded')">已批改({{ counts.graded }})</span>
|
||||
<span class="seg" :class="{ on: !selClass && selState === 'unsubmitted' }" @click="pickFilter('', 'unsubmitted')">未提交({{ counts.unsubmitted }})</span>
|
||||
</span>
|
||||
<span v-for="c in counts.byClass" :key="c.classId" class="grp">
|
||||
<span class="cls" :class="{ on: selClass === c.classId }" @click="pickFilter(c.classId, selState)">{{ c.className }}({{ c.pending + c.graded + c.unsubmitted }}):</span>
|
||||
<span class="seg" :class="{ on: selClass === c.classId && selState === 'pending' }" @click="pickFilter(c.classId, 'pending')">待批改({{ c.pending }})</span>
|
||||
<span class="seg" :class="{ on: selClass === c.classId && selState === 'graded' }" @click="pickFilter(c.classId, 'graded')">已批改({{ c.graded }})</span>
|
||||
<span class="seg" :class="{ on: selClass === c.classId && selState === 'unsubmitted' }" @click="pickFilter(c.classId, 'unsubmitted')">未提交({{ c.unsubmitted }})</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 右:提交内容 + 批阅(版本切换/批阅/打回共用工作台) -->
|
||||
<div class="wb-right fr">
|
||||
<div class="wb-h">
|
||||
{{ homeworkTitle }}
|
||||
<span class="pos" v-if="current">第 {{ idx + 1 }} / {{ queue.length }} 份 · {{ current.studentName }}({{ current.className }})</span>
|
||||
<div class="workbench fix">
|
||||
<!-- 左:批改队列 -->
|
||||
<div class="wb-left fl">
|
||||
<div class="wb-h">{{ queueTitle }}({{ queue.length }} 份)</div>
|
||||
<p v-if="!loadingQueue && !queue.length" class="done">🎉 当前筛选下暂无学生</p>
|
||||
<Spin v-if="loadingQueue" fix />
|
||||
<ul class="qlist">
|
||||
<li v-for="(s, i) in queue" :key="s.studentId" :class="{ act: i === idx }" @click="select(i)">
|
||||
<div class="q1">
|
||||
{{ s.studentName }}
|
||||
<span v-if="s.isLate" class="q-late">逾期</span>
|
||||
<span v-if="s.submitStatus === 'NOT_SUBMITTED'" class="q-unsub">未提交</span>
|
||||
<span v-for="t in s.contentTypes || []" :key="t" class="q-tag">{{ typeTag(t) }}</span>
|
||||
</div>
|
||||
<div class="q2">{{ s.className }} · {{ s.submittedAt ? String(s.submittedAt).slice(5, 16) : "" }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wb-panel" v-if="current">
|
||||
<ReviewWorkbench :key="workbenchKey" :homeworkId="serid" :student="current" @saved="onSaved" />
|
||||
|
||||
<!-- 右:提交内容 + 批阅(版本切换/批阅/打回共用工作台;未提交名单无批阅操作) -->
|
||||
<div class="wb-right fr">
|
||||
<div class="wb-h">
|
||||
{{ homeworkTitle }}
|
||||
<span class="pos" v-if="current">第 {{ idx + 1 }} / {{ queue.length }} 份 · {{ current.studentName }}({{ current.className }})</span>
|
||||
</div>
|
||||
<div class="wb-panel" v-if="current && selState !== 'unsubmitted'">
|
||||
<ReviewWorkbench :key="workbenchKey" :homeworkId="serid" :student="current" @saved="onSaved" />
|
||||
</div>
|
||||
<p v-else-if="current" class="tip">该学生尚未提交作业,无法批阅。可在线下催交,或使用「我布置的 → 更多 → 催交」发送提醒。</p>
|
||||
<p v-else class="tip">当前筛选下暂无待处理的学生提交。</p>
|
||||
</div>
|
||||
<p v-else class="tip">该作业暂无待批改提交。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,6 +306,41 @@ onMounted(async () => {
|
||||
color: #808695;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.class-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 24px;
|
||||
padding: 6px 2px 10px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
.grp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cls {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
&:hover,
|
||||
&.on {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
.seg {
|
||||
cursor: pointer;
|
||||
padding: 1px 8px;
|
||||
border-radius: 3px;
|
||||
background: #f5f7fa;
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
&.on {
|
||||
background: #2d8cf0;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
.done {
|
||||
color: #19be6b;
|
||||
padding: 16px 8px;
|
||||
@@ -210,6 +392,21 @@ onMounted(async () => {
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.q-tag {
|
||||
color: #2d8cf0;
|
||||
background: #f0faff;
|
||||
border: 1px solid #d4eaf7;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.q-unsub {
|
||||
background: #c5c8ce;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.q2 {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
|
||||
@@ -258,7 +258,7 @@ function openPreview(a: AttachmentItem) {
|
||||
<div class="my-text" v-if="detail.myText">{{ detail.myText }}</div>
|
||||
<div class="my-text empty" v-else>(无文字作答)</div>
|
||||
<div class="my-atts" v-if="detail.myAttachments && detail.myAttachments.length">
|
||||
<template v-for="(a, i) in detail.myAttachments" :key="i">
|
||||
<template v-for="a in detail.myAttachments" :key="a.fileUrl">
|
||||
<div class="th" v-if="(a.fileType || '').toLowerCase() === 'image'" @click="zoom(previewUrl(a))">
|
||||
<img :src="previewUrl(a)" />
|
||||
</div>
|
||||
@@ -319,7 +319,7 @@ function openPreview(a: AttachmentItem) {
|
||||
</div>
|
||||
<div class="v-text" v-if="v.text">{{ v.text }}</div>
|
||||
<div class="v-atts" v-if="v.attachments && v.attachments.length">
|
||||
<template v-for="(a, i) in v.attachments" :key="i">
|
||||
<template v-for="a in v.attachments" :key="a.fileUrl">
|
||||
<div class="th" v-if="(a.fileType || '').toLowerCase() === 'image'" @click="zoom(previewUrl(a))"><img :src="previewUrl(a)" /></div>
|
||||
<div class="file-chip" v-else>
|
||||
<Icon type="md-document" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, h } from "vue";
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { teacherApi } from "@/api/module/teacherHomework";
|
||||
@@ -89,7 +89,6 @@ const readGroups = ref<ReadGroup[]>([]);
|
||||
const showRead = ref(false);
|
||||
const prevId = ref(0);
|
||||
const nextId = ref(0);
|
||||
const viewType = computed(() => 0);
|
||||
|
||||
async function loadV1() {
|
||||
if (!serid.value) return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, h } from "vue";
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
@@ -32,11 +32,6 @@ const subject = ref("");
|
||||
const startDt = ref("");
|
||||
const endDt = ref("");
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/* ============ 分页 ============ */
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
@@ -129,52 +124,71 @@ function opBtn(h: any, text: string, icon: string, onClick: () => void) {
|
||||
/** 提交进度条单元格(内联样式,render 单元格不吃 scoped) */
|
||||
function progressCell(h: any, g: any) {
|
||||
if (!g) return h("span", { style: "color:#c5c8ce;" }, "—");
|
||||
return h("div", { style: "display:flex;align-items:center;gap:8px;" }, [
|
||||
return h("div", { style: "display:flex;align-items:center;gap:8px;white-space:nowrap;" }, [
|
||||
h("div", { style: "flex:1;min-width:54px;height:6px;background:#f0f2f5;border-radius:4px;overflow:hidden;" }, [
|
||||
h("div", { style: `width:${Math.min(100, g.submitRate)}%;height:100%;background:linear-gradient(90deg,#2d8cf0,#5cadff);border-radius:4px;` }),
|
||||
]),
|
||||
h("span", { style: "font-size:12px;color:#515a6e;white-space:nowrap;" }, `${g.submitted}/${g.should} · ${g.submitRate}%`),
|
||||
]);
|
||||
}
|
||||
/** 短字段单元格:强制单行(时间这类短文本折行很难看,且会顶破 48px 行高) */
|
||||
function nowrapCell(h: any, text: string, style = "") {
|
||||
return h("span", { style: `white-space:nowrap;${style}` }, text);
|
||||
}
|
||||
/** 长字段单元格:单行 + 省略号,悬停由原生 title 看全文(与宿舍通知「接收宿舍」同一手法) */
|
||||
function ellipsisCell(h: any, text: string) {
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
title: text,
|
||||
style: "display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;",
|
||||
},
|
||||
text
|
||||
);
|
||||
}
|
||||
/** 批改进度单元格 */
|
||||
function gradeCell(h: any, g: any) {
|
||||
if (!g) return h("span", { style: "color:#c5c8ce;" }, "—");
|
||||
return g.pendingGrade > 0 ? h("span", { style: "color:#ff9900;font-weight:600;" }, `待批 ${g.pendingGrade}`) : h("span", { style: "color:#19be6b;" }, "已批完");
|
||||
}
|
||||
/** 状态胶囊 */
|
||||
function statusPill(h: any, hex: string, text: string) {
|
||||
return h("span", { style: "display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#515a6e;" }, [
|
||||
h("i", { style: `width:8px;height:8px;border-radius:50%;display:inline-block;background:${hex};` }),
|
||||
text,
|
||||
]);
|
||||
/** 状态:统一走 Utils.hwStatusPill(状态图标 + 文字 + 同色浅底),
|
||||
* 与「我布置的」共用 utils/index.ts 里的 HW_STATUS_META,不再各写一份颜色表 */
|
||||
function statusCell(h: any, code: string, text?: string) {
|
||||
return Utils.hwStatusPill(h, code, text);
|
||||
}
|
||||
|
||||
const STATUS_HEX: Record<string, string> = { draft: "#c5c8ce", collecting: "#2d8cf0", closed: "#ff9900", done: "#19be6b" };
|
||||
const STATUS_COLOR: Record<string, string> = { draft: "default", collecting: "primary", closed: "warning", done: "success" };
|
||||
|
||||
/* ============ 表格列 ============ */
|
||||
/**
|
||||
* 列宽口径(对齐宿舍通知 NoticeList):
|
||||
* - 序号列 ≥ 76:iview 单元格左右各 18px padding,「序号」两字约 36px,给 70 以下会挤成「序/号」两行;
|
||||
* - 「未读未交」这类 4 字表头留足一字不折行的宽度;
|
||||
* - 时间列 150 + nowrap(130 会把「2026-09-15 10:00」折成两行);班级单行省略、悬停看全文;
|
||||
* - 列宽合计 ~1500px,宽屏也保留横向滚动(滚动条 8px 圆角的样式见 App.vue 的
|
||||
* .ivu-table-overflowX 覆写)。**不要为了「收进一屏」压列宽或合并列** ——
|
||||
* 把列压窄只会让表头折行,越改越难看。
|
||||
*/
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "标题", key: "title", minWidth: 220, align: "left", tooltip: true },
|
||||
{ title: "学科", key: "subject", width: 90, align: "center" },
|
||||
{ title: "序号", type: "index", width: 76, align: "center" },
|
||||
{ title: "标题", key: "title", minWidth: 300, align: "left", tooltip: true },
|
||||
{ title: "学科", key: "subject", width: 120, align: "center" },
|
||||
{
|
||||
title: "班级",
|
||||
width: 140,
|
||||
width: 190,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => {
|
||||
const r = p.row as HomeworkVo & { receiveText?: string; classCount?: number };
|
||||
return h("span", {}, r.receiveText || (r.receiveList === "ALL" ? "全部班级" : "-"));
|
||||
return ellipsisCell(h, r.receiveText || (r.receiveList === "ALL" ? "全部班级" : "-"));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "截止时间",
|
||||
width: 150,
|
||||
width: 170,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => h("span", {}, (p.row.deadline ? String(p.row.deadline).slice(0, 16) : "—")),
|
||||
render: (h: any, p: any) => nowrapCell(h, p.row.deadline ? String(p.row.deadline).slice(0, 16) : "—"),
|
||||
},
|
||||
{
|
||||
title: "提交进度",
|
||||
width: 170,
|
||||
width: 200,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => progressCell(h, (p.row as any).progress),
|
||||
},
|
||||
@@ -189,7 +203,7 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: "未读未交",
|
||||
width: 90,
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const g = (p.row as any).progress;
|
||||
@@ -205,16 +219,18 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 110,
|
||||
align: "left",
|
||||
width: 120,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const st = (p.row as any).hwStatus || "collecting";
|
||||
return statusPill(h, STATUS_HEX[st], (p.row as any).hwStatusDesc || "收集中");
|
||||
const row = p.row as any;
|
||||
return statusCell(h, row.hwStatus || "collecting", row.hwStatusDesc);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 200,
|
||||
/* 3 个图标按钮(提交情况/编辑/删除):3 × 36 + padding 36 + 余量 = 170,
|
||||
与「我布置的」同宽同位置,切页不跳列 */
|
||||
width: 170,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
@@ -228,7 +244,35 @@ const columns = [
|
||||
},
|
||||
];
|
||||
|
||||
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
|
||||
/* ============ 表格高度:量真实 DOM,不用常量估算 ============ */
|
||||
/** 表格外层容器(量它的顶边位置) */
|
||||
const tableWrap = ref<HTMLElement | null>(null);
|
||||
const tableHeight = ref(400);
|
||||
|
||||
/**
|
||||
* 表格高度 = 分页条顶边 − 表格顶边 − 12px 呼吸位(列宽超出容器时再扣掉横向滚动条约 15px)。
|
||||
*
|
||||
* 原先用 `Utils.useTableHeight({isSearch,isOperat,isPage})` 按 body 高度减固定值估算,
|
||||
* 与真实可用空间对不齐;而 `.page-panel` 是 absolute 吸底定位,表格一旦比可用空间高就压到
|
||||
* 分页上,横向滚动条也没给它留位置(最后一行会被滚动条切掉)。这里直接量 DOM,
|
||||
* 加减筛选条件、改字号都不会再失配;窗口 resize 时重算。
|
||||
*/
|
||||
function calcTableHeight() {
|
||||
const wrap = tableWrap.value;
|
||||
if (!wrap) return;
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const panel = wrap.parentElement?.querySelector(".page-panel") as HTMLElement | null;
|
||||
const limit = panel ? panel.getBoundingClientRect().top : window.innerHeight;
|
||||
// 列宽合计超过容器宽度 → 会出现横向滚动条,它会吃掉表体 ~15px 高度,一并预留
|
||||
const colsWidth = (columns as any[]).reduce((s, c) => s + (c.width || c.minWidth || 100), 0);
|
||||
const hBar = colsWidth > wrap.clientWidth ? 15 : 0;
|
||||
tableHeight.value = Math.max(240, Math.floor(limit - rect.top - 12 - hBar));
|
||||
}
|
||||
onMounted(() => {
|
||||
nextTick(calcTableHeight);
|
||||
window.addEventListener("resize", calcTableHeight);
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener("resize", calcTableHeight));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -264,15 +308,17 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 3. 表格 + 分页 -->
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
<!-- 3. 表格 + 分页(tableWrap 供 calcTableHeight 量真实可用高度) -->
|
||||
<div ref="tableWrap">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
</div>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
@@ -291,42 +337,3 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pg-bar {
|
||||
flex: 1;
|
||||
min-width: 54px;
|
||||
height: 6px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pg-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2d8cf0, #5cadff);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pg-txt {
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.st-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.st-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { homeworkApi } from "@/api/module/homework";
|
||||
import type { HomeworkStatisticsVo } from "@/types/homework";
|
||||
@@ -40,6 +39,29 @@ const cards = computed(() => [
|
||||
]);
|
||||
|
||||
const HW_STATUS_TEXT: Record<string, string> = { draft: "草稿", collecting: "收集中", closed: "已截止", done: "已完成" };
|
||||
|
||||
/** 各作业进度表列(render 显式标注 h/p 类型,避免隐式 any) */
|
||||
const hwColumns = [
|
||||
{ title: "作业", key: "title", minWidth: 220, tooltip: true },
|
||||
{ title: "学科", key: "subject", width: 90, align: "center" },
|
||||
{
|
||||
title: "状态",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, HW_STATUS_TEXT[p.row.hwStatus] || p.row.hwStatus),
|
||||
},
|
||||
{ title: "应交", key: "should", width: 80, align: "center" },
|
||||
{ title: "已交", key: "submitted", width: 80, align: "center" },
|
||||
{ title: "提交率", width: 110, align: "center", render: (h: any, p: any) => h("span", {}, `${p.row.submitRate}%`) },
|
||||
{ title: "已批", key: "graded", width: 80, align: "center" },
|
||||
{
|
||||
title: "待批改",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h("span", { style: p.row.pendingGrade > 0 ? "color:#ff9900;font-weight:600" : "" }, String(p.row.pendingGrade)),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -77,21 +99,7 @@ const HW_STATUS_TEXT: Record<string, string> = { draft: "草稿", collecting: "
|
||||
<div class="block-h">我布置的作业 · 提交与批改</div>
|
||||
<Table
|
||||
v-if="hasByHomework"
|
||||
:columns="[
|
||||
{ title: '作业', key: 'title', minWidth: 220, tooltip: true },
|
||||
{ title: '学科', key: 'subject', width: 90, align: 'center' },
|
||||
{ title: '状态', width: 100, align: 'center', render: (h, p) => h('span', {}, HW_STATUS_TEXT[p.row.hwStatus] || p.row.hwStatus) },
|
||||
{ title: '应交', key: 'should', width: 80, align: 'center' },
|
||||
{ title: '已交', key: 'submitted', width: 80, align: 'center' },
|
||||
{ title: '提交率', width: 110, align: 'center', render: (h, p) => h('span', {}, p.row.submitRate + '%') },
|
||||
{ title: '已批', key: 'graded', width: 80, align: 'center' },
|
||||
{
|
||||
title: '待批改',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (h, p) => h('span', { style: p.row.pendingGrade > 0 ? 'color:#ff9900;font-weight:600' : '' }, p.row.pendingGrade),
|
||||
},
|
||||
]"
|
||||
:columns="hwColumns"
|
||||
:data="stat.byHomework || []"
|
||||
no-data-text="暂无需要收集提交记录的作业"
|
||||
></Table>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import { teacherApi } from "@/api/module/teacherHomework";
|
||||
import { homeworkApi } from "@/api/module/homework";
|
||||
import type { HomeworkV2Request, HomeworkV2Vo, AttachmentItem } from "@/types/homeworkV2";
|
||||
import type { HomeworkV2Request, HomeworkV2Vo, AttachmentItem, SubjectVo } from "@/types/homeworkV2";
|
||||
import type { UpFileVo } from "@/types/homework";
|
||||
import AttachmentUpload from "@/components/AttachmentUpload.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
@@ -18,7 +18,28 @@ const serid = computed(() => Number(route.query.serid) || 0);
|
||||
const fid = computed(() => Number(route.query.fid) || 0);
|
||||
const isEdit = computed(() => serid.value > 0);
|
||||
|
||||
/* 学科下拉绑定「我的任教学科」(/subjects/teaching,subId=作业 subject 存储值,与我布置的筛选同源);
|
||||
* 接口失败或任教清单为空时降级为内置清单,保证表单始终可提交 */
|
||||
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "道德与法治", "历史", "地理", "科学", "综合实践"];
|
||||
const subjectOpts = ref<SubjectVo[]>(SUBJECTS.map((s) => ({ subId: s, subName: s })));
|
||||
async function loadSubjects() {
|
||||
try {
|
||||
const list = await teacherApi.teachingSubjects().send();
|
||||
if (Array.isArray(list) && list.length) subjectOpts.value = list;
|
||||
} catch {
|
||||
/* 降级用内置清单 */
|
||||
}
|
||||
ensureSubjectOption();
|
||||
}
|
||||
/* 回显学科不在清单内(如任教调整后的历史草稿)时补入,避免下拉显示裸存储值 */
|
||||
function ensureSubjectOption() {
|
||||
if (form.subject && !subjectOpts.value.some((s) => s.subId === form.subject)) {
|
||||
subjectOpts.value = [...subjectOpts.value, { subId: form.subject, subName: form.subject }];
|
||||
}
|
||||
}
|
||||
/* 提交方式(发布时勾选,决定学生可用的提交渠道与教师是否批改):
|
||||
* 勾拍照/图片 / 上传附件 / 文字作答 → 学生可按该方式提交、教师需批改;
|
||||
* 勾仅查阅 → 学生只预览、教师无需批改;两组互斥只能选其一 */
|
||||
const SUBMIT_TYPES = [
|
||||
{ v: "image", label: "拍照/图片" },
|
||||
{ v: "attach", label: "上传附件" },
|
||||
@@ -33,6 +54,7 @@ const REMIND_OPTS = [
|
||||
const form = reactive({
|
||||
title: "",
|
||||
subject: "",
|
||||
classIds: [] as string[],
|
||||
deadline: "" as string | Date,
|
||||
content: "",
|
||||
});
|
||||
@@ -43,19 +65,26 @@ const allowLate = ref(false);
|
||||
const remindBefore = ref<string[]>([]);
|
||||
const attachments = ref<AttachmentItem[]>([]);
|
||||
|
||||
/* 「仅查阅」与其余方式互斥(后端 SubmitTypes:none=不必提交) */
|
||||
/* 「仅查阅」与三种提交方式互斥(后端 SubmitTypes:none=不必提交):
|
||||
* 勾「仅查阅」清空其余;反之勾任一提交方式则取消「仅查阅」 */
|
||||
watch(submitTypes, (v) => {
|
||||
if (v.includes("none") && v.length > 1) {
|
||||
submitTypes.value = v[v.length - 1] === "none" ? ["none"] : v.filter((x) => x !== "none");
|
||||
}
|
||||
});
|
||||
|
||||
/* ============ 班级下拉多选(V2 教师班级树,叶子 id=classId) ============ */
|
||||
/* ============ 班级下拉多选(V2 教师班级树,叶子 id=classId) ============
|
||||
* 绑定「任教班级」:/classes?subject= 按 bschclass 任教绑定过滤,未选学科 = 全部任教班级并集;
|
||||
* 学科切换后重载,并剪除不在新清单里的已选项 */
|
||||
const classGroups = ref<{ grade: string; items: { value: string; label: string }[] }[]>([]);
|
||||
const allClassIds = ref<string[]>([]);
|
||||
const classReady = ref(false); // 挂载回显流程内不响应学科 watch(由流程显式按序加载)
|
||||
let classLoadSeq = 0;
|
||||
async function loadClasses() {
|
||||
const seq = ++classLoadSeq;
|
||||
try {
|
||||
const tree = await teacherApi.classTree().send();
|
||||
const tree = await teacherApi.classTree(form.subject || undefined).send();
|
||||
if (seq !== classLoadSeq) return; // 学科已再次变化,丢弃过期响应
|
||||
const groups: { grade: string; items: { value: string; label: string }[] }[] = [];
|
||||
const ids: string[] = [];
|
||||
(tree || []).forEach((g) => {
|
||||
@@ -65,14 +94,47 @@ async function loadClasses() {
|
||||
});
|
||||
classGroups.value = groups;
|
||||
allClassIds.value = ids;
|
||||
const stale = selClasses.value.filter((c) => !ids.includes(c));
|
||||
if (stale.length) {
|
||||
echoSelClassesProgrammatically(() => {
|
||||
selClasses.value = selClasses.value.filter((c) => ids.includes(c));
|
||||
});
|
||||
Message.info(`任教班级已按「${form.subject || "全部学科"}」刷新,移除 ${stale.length} 个不可选班级`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载班级树失败");
|
||||
Message.error(e.message || "加载任教班级失败");
|
||||
}
|
||||
}
|
||||
/* 学科切换 → 重载该学科任教班级 */
|
||||
watch(
|
||||
() => form.subject,
|
||||
() => {
|
||||
if (classReady.value) loadClasses();
|
||||
}
|
||||
);
|
||||
function toggleAll(v: boolean) {
|
||||
if (v) selClasses.value = [...allClassIds.value];
|
||||
}
|
||||
/* 班级树由下方统一初始化入口按序加载(fillFromV2 依赖 allClassIds 判定「全部班级」) */
|
||||
/* 用户手动增删班级时取消「全部任教班级」;程序化回显(fillFromV2/剪除)置抑制位——
|
||||
* view-ui-plus Select 对赋值也会 emit on-change,且经 lazyUpdateValue 的嵌套 nextTick
|
||||
* 延后一拍发出,故抑制位须等宏任务(setTimeout)再复位 */
|
||||
let echoingSelClasses = false;
|
||||
function echoSelClassesProgrammatically(assign: () => void) {
|
||||
echoingSelClasses = true;
|
||||
assign();
|
||||
setTimeout(() => (echoingSelClasses = false), 0);
|
||||
}
|
||||
function onSelClassesChange() {
|
||||
if (echoingSelClasses) return;
|
||||
allClasses.value = false;
|
||||
}
|
||||
/* classIds 供 Form 校验用:FormItem 的 prop 需指向 form.model,否则 view-ui-plus 会以
|
||||
* undefined 为字段名做必填校验,弹出「undefined is required」。
|
||||
* 必须 flush:sync——Select 在同一 watcher 回调里先 update:modelValue 再触发 FormItem 的
|
||||
* change 校验,异步 watch 会让校验读到旧值,班级已选仍提示「请选择接收班级」 */
|
||||
watch([selClasses, allClasses, allClassIds], () => {
|
||||
form.classIds = allClasses.value ? [...allClassIds.value] : [...selClasses.value];
|
||||
}, { flush: "sync" });
|
||||
|
||||
const ruleValidate = {
|
||||
title: [
|
||||
@@ -80,6 +142,7 @@ const ruleValidate = {
|
||||
{ max: 50, message: "作业标题太长,请修改标题长度" },
|
||||
],
|
||||
subject: [{ required: true, message: "请选择学科", trigger: "change" }],
|
||||
classIds: [{ required: true, type: "array" as const, min: 1, message: "请选择接收班级", trigger: "change" }],
|
||||
};
|
||||
|
||||
/* ============ 富文本编辑器 ============ */
|
||||
@@ -129,36 +192,52 @@ function fmt(d: any): string {
|
||||
function fillFromV2(d: HomeworkV2Vo) {
|
||||
form.title = (d.title || "").replace("[修订稿]", "");
|
||||
form.subject = d.subject || "";
|
||||
ensureSubjectOption();
|
||||
form.deadline = d.deadlineAt || "";
|
||||
form.content = d.description || "";
|
||||
submitTypes.value = Array.isArray(d.submitType) && d.submitType.length ? [...d.submitType] : ["image"];
|
||||
allowLate.value = d.allowLate === 1;
|
||||
remindBefore.value = [d.remind1d === 1 ? "1d" : "", d.remind1h === 1 ? "1h" : ""].filter(Boolean);
|
||||
const cls = (d.classes || []).map((c) => String(c.classId));
|
||||
if (cls.length && cls.length === allClassIds.value.length && cls.every((c) => allClassIds.value.includes(c))) {
|
||||
allClasses.value = true;
|
||||
selClasses.value = [...allClassIds.value];
|
||||
} else {
|
||||
allClasses.value = false;
|
||||
selClasses.value = cls;
|
||||
/* 回显班级不在当前任教清单(任教调整后的历史草稿)时补入「其他」组,避免下拉显示裸 id */
|
||||
const extra = (d.classes || []).filter((c) => !allClassIds.value.includes(String(c.classId)));
|
||||
if (extra.length) {
|
||||
classGroups.value = [
|
||||
...classGroups.value,
|
||||
{ grade: "其他", items: extra.map((c) => ({ value: String(c.classId), label: c.className || String(c.classId) })) },
|
||||
];
|
||||
allClassIds.value = [...allClassIds.value, ...extra.map((c) => String(c.classId))];
|
||||
}
|
||||
const cls = (d.classes || []).map((c) => String(c.classId));
|
||||
echoSelClassesProgrammatically(() => {
|
||||
if (cls.length && cls.length === allClassIds.value.length && cls.every((c) => allClassIds.value.includes(c))) {
|
||||
allClasses.value = true;
|
||||
selClasses.value = [...allClassIds.value];
|
||||
} else {
|
||||
allClasses.value = false;
|
||||
selClasses.value = cls;
|
||||
}
|
||||
});
|
||||
attachments.value = [...(d.attachments || [])];
|
||||
if (d.scheduledAt) timeModal.date = d.scheduledAt;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 先加载班级树再回显:fillFromV2 用 allClassIds 判定「全部班级」,
|
||||
// 若并行执行则 allClassIds 仍为空,勾选态会被误判为「未全选」
|
||||
await loadClasses();
|
||||
if (!(isEdit.value || fid.value > 0)) return;
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const d = await teacherApi.detail(serid.value).send();
|
||||
if (d.hwStatus !== "DRAFT_UNPUBLISHED" && d.hwStatus !== "DRAFT_SCHEDULED") {
|
||||
loadSubjects();
|
||||
let draft: HomeworkV2Vo | null = null;
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
draft = await teacherApi.detail(serid.value).send();
|
||||
if (draft.hwStatus !== "DRAFT_UNPUBLISHED" && draft.hwStatus !== "DRAFT_SCHEDULED") {
|
||||
Message.info("仅草稿状态的作业可编辑");
|
||||
}
|
||||
fillFromV2(d);
|
||||
} else {
|
||||
// 先定学科再加载班级树:任教班级按所选学科过滤
|
||||
form.subject = draft.subject || "";
|
||||
ensureSubjectOption();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载作业失败");
|
||||
}
|
||||
} else if (fid.value > 0) {
|
||||
try {
|
||||
// 转发:拿他人作业作底稿(V2 详情有本人归属校验,走 V1 冻结 GetDetail)
|
||||
const d: any = await homeworkApi.getHomeworkDetails(fid.value).send();
|
||||
form.title = d.title || "";
|
||||
@@ -170,10 +249,15 @@ onMounted(async () => {
|
||||
fileName: f.filename || f.name,
|
||||
sort: 0,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载作业失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载作业失败");
|
||||
}
|
||||
// 班级树在回显前按当前学科加载完成:fillFromV2 用 allClassIds 判定「全部班级」,
|
||||
// 若并行执行则 allClassIds 仍为空,勾选态会被误判为「未全选」
|
||||
await loadClasses();
|
||||
if (draft) fillFromV2(draft);
|
||||
classReady.value = true;
|
||||
});
|
||||
|
||||
/* ============ 提交(V2:创建/编辑一律先落草稿,再按需 publish / schedule) ============ */
|
||||
@@ -249,10 +333,10 @@ async function doAdd(mode: "publish" | "draft" | "time") {
|
||||
</FormItem>
|
||||
<FormItem label="学科:" prop="subject">
|
||||
<Select v-model="form.subject" placeholder="请选择学科" style="width: 240px">
|
||||
<Option v-for="s in SUBJECTS" :key="s" :value="s">{{ s }}</Option>
|
||||
<Option v-for="s in subjectOpts" :key="s.subId" :value="s.subId">{{ s.subName }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="接收班级:" required>
|
||||
<FormItem label="接收班级:" prop="classIds">
|
||||
<div class="cls-row">
|
||||
<Select
|
||||
v-model="selClasses"
|
||||
@@ -260,15 +344,15 @@ async function doAdd(mode: "publish" | "draft" | "time") {
|
||||
:disabled="allClasses"
|
||||
placeholder="选择班级(可多选)"
|
||||
style="width: 420px"
|
||||
@on-change="allClasses = false"
|
||||
@on-change="onSelClassesChange"
|
||||
>
|
||||
<OptionGroup v-for="g in classGroups" :key="g.grade" :label="g.grade">
|
||||
<Option v-for="c in g.items" :key="c.value" :value="c.value">{{ c.label }}</Option>
|
||||
</OptionGroup>
|
||||
</Select>
|
||||
<Checkbox v-model="allClasses" @on-change="toggleAll">全部班级</Checkbox>
|
||||
<Checkbox v-model="allClasses" @on-change="toggleAll">全部任教班级</Checkbox>
|
||||
</div>
|
||||
<div class="hint">发布前必须选择参与班级</div>
|
||||
<div class="hint">班级清单随所选学科过滤(仅任教班级);发布前必须选择参与班级</div>
|
||||
</FormItem>
|
||||
<FormItem label="截止时间:">
|
||||
<DatePicker type="datetime" placeholder="截止时间(精确到分钟,不填=不截止)" v-model="form.deadline" format="yyyy-MM-dd HH:mm" style="width: 260px"></DatePicker>
|
||||
@@ -278,6 +362,7 @@ async function doAdd(mode: "publish" | "draft" | "time") {
|
||||
<CheckboxGroup v-model="submitTypes">
|
||||
<Checkbox v-for="t in SUBMIT_TYPES" :key="t.v" :label="t.v">{{ t.label }}</Checkbox>
|
||||
</CheckboxGroup>
|
||||
<div class="hint">勾选拍照/图片、上传附件、文字作答之一=学生可按该方式提交、教师需批改;勾选仅查阅=学生仅预览、教师无需批改;两组只能选其一</div>
|
||||
</FormItem>
|
||||
<FormItem label="逾期提交:">
|
||||
<i-switch v-model="allowLate" />
|
||||
|
||||
@@ -6,9 +6,7 @@ import { studentApi } from "@/api/module/studentHomework";
|
||||
import type { BasePageResult, StudentHomeworkV2Vo, StudentHomeworkDetailV2Vo, AttachmentItem } from "@/types/homeworkV2";
|
||||
import AttachmentUpload from "@/components/AttachmentUpload.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -86,9 +84,22 @@ const STATUS_TEXT: Record<string, string> = {
|
||||
RETURNED: "待订正",
|
||||
REVIEWED: "已批阅",
|
||||
};
|
||||
/* 左侧色条用色。蓝色对齐主题 #5979EA(原 #2d8cf0 是另一套蓝,与 tab 下划线/主按钮不同色) */
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
NOT_SUBMITTED: "#c5c8ce",
|
||||
SUBMITTED: "#2d8cf0",
|
||||
SUBMITTED: "#5979EA",
|
||||
LATE_SUBMITTED: "#ff9900",
|
||||
RETURNED: "#ed4014",
|
||||
REVIEWED: "#19be6b",
|
||||
};
|
||||
/**
|
||||
* 状态标签(Tag)配色。
|
||||
* 「未提交」用 default(白底浅灰描边)——属"未产生动作"的静默态,与宿舍通知里
|
||||
* 「仅通知 / 已截止」同款处理;其余为实心语义色。
|
||||
*/
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
NOT_SUBMITTED: "default",
|
||||
SUBMITTED: "#5979EA",
|
||||
LATE_SUBMITTED: "#ff9900",
|
||||
RETURNED: "#ed4014",
|
||||
REVIEWED: "#19be6b",
|
||||
@@ -99,6 +110,17 @@ function myStatus(x: StudentHomeworkV2Vo) {
|
||||
function statusColor(x: StudentHomeworkV2Vo) {
|
||||
return STATUS_COLOR[myStatus(x)];
|
||||
}
|
||||
function statusTag(x: StudentHomeworkV2Vo) {
|
||||
return STATUS_TAG[myStatus(x)] || "default";
|
||||
}
|
||||
/** 已批阅摘要:分数、评语都空时返回空串(不渲染,避免出现「得分 --」) */
|
||||
function gradeText(x: StudentHomeworkV2Vo) {
|
||||
const parts: string[] = [];
|
||||
const s = x.my?.score;
|
||||
if (s !== null && s !== undefined && String(s) !== "") parts.push(`得分 ${s}`);
|
||||
if (x.my?.comment) parts.push(x.my.comment);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
/** 仅查阅型(submitType 含 none) */
|
||||
function isViewOnly(x: StudentHomeworkV2Vo) {
|
||||
return (x.submitType || []).includes("none") || (x.submitType || []).length === 0;
|
||||
@@ -190,42 +212,34 @@ function withdraw(row: StudentHomeworkV2Vo) {
|
||||
}
|
||||
|
||||
/* ============ 图片放大预览 ============ */
|
||||
const imgPreview = reactive({ open: false, url: "" });
|
||||
function previewUrl(a: AttachmentItem) {
|
||||
const u = a.fileUrl || "";
|
||||
if (/^(https?:)?\/\//.test(u) || u.startsWith("data:")) return u;
|
||||
return `${serviceBase}${u}`;
|
||||
}
|
||||
function zoom(url: string) {
|
||||
imgPreview.url = url;
|
||||
imgPreview.open = true;
|
||||
}
|
||||
/* 列表卡片不直接渲染附件缩略图(提交/查看走 /stu/detail),此处不再保留放大弹窗 */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container stu">
|
||||
<!-- 顶部概览 -->
|
||||
<!-- 顶部概览:轻量信息条(对齐宿舍通知学生端的浅蓝提示条语言,
|
||||
替换原 91px 渐变大色块:省纵向空间,且不再与主题色 #5979EA 撞色) -->
|
||||
<div class="stu-head">
|
||||
<div class="sh-left">
|
||||
<div class="sh-icon"><Icon type="md-book" size="26" color="#fff" /></div>
|
||||
<div>
|
||||
<div class="sh-title">我的作业</div>
|
||||
<div class="sh-sub">按时提交,查看老师批阅与订正</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sh-chips">
|
||||
<div class="chip"><b>{{ page.total }}</b><span>本页作业</span></div>
|
||||
<Icon type="md-book" size="16" />
|
||||
<span class="sh-title">我的作业</span>
|
||||
<span class="sh-sub">按时提交,查看老师批阅与订正</span>
|
||||
</div>
|
||||
<div class="sh-count">共 <b>{{ page.total }}</b> 份作业</div>
|
||||
</div>
|
||||
|
||||
<div class="fix mb-12">
|
||||
<Tabs :value="tab" @on-click="onTab" class="fl stu-tabs">
|
||||
<div class="stu-filter">
|
||||
<!-- view-ui-plus 4.x 的 Tabs 只有 modelValue,写 :value 不生效(高亮会与控制逻辑脱钩) -->
|
||||
<Tabs v-model="tab" @on-click="onTab" class="stu-tabs">
|
||||
<TabPane v-for="t in TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
|
||||
</Tabs>
|
||||
<!-- ★ 不要给这个输入框加 clearable:view-ui-plus 的 Input 模板里
|
||||
清除图标(clearable) 是 v-if、搜索图标(search) 是 v-else-if,
|
||||
一旦有输入内容清除图标就把放大镜顶掉 → 「点放大镜搜索」直接失效。
|
||||
本行只做宽度与右对齐,交互保持原样。 -->
|
||||
<Input
|
||||
class="fr"
|
||||
style="width: 220px"
|
||||
class="stu-search"
|
||||
search
|
||||
suffix="ios-search"
|
||||
placeholder="搜索作业标题"
|
||||
@@ -241,43 +255,58 @@ function zoom(url: string) {
|
||||
<p>该分类下暂无作业</p>
|
||||
</div>
|
||||
|
||||
<div class="cards" v-else>
|
||||
<div class="cards" :class="{ 'has-page': page.total > pageSize }" v-else>
|
||||
<div class="card" v-for="row in rows" :key="row.id" :class="{ redo: myStatus(row) === 'RETURNED' }" :style="{ borderLeftColor: statusColor(row) }">
|
||||
<div class="c-top" @click="goDetail(row)">
|
||||
<span class="c-subj">{{ row.subject || "作业" }}</span>
|
||||
<span class="c-title">{{ row.title }}</span>
|
||||
<!-- 左:作业主体信息 -->
|
||||
<div class="c-main">
|
||||
<div class="c-top" @click="goDetail(row)">
|
||||
<span class="c-subj">{{ row.subject || "作业" }}</span>
|
||||
<span class="c-title" :title="row.title">{{ row.title }}</span>
|
||||
<span v-if="!isViewOnly(row) && row.my?.isLate" class="c-late">逾期</span>
|
||||
</div>
|
||||
<div class="c-meta">
|
||||
<span class="m-item"><Icon type="md-person" size="14" /> {{ row.teacherName || "—" }}</span>
|
||||
<!-- 「已截止」只在还需要学生动手时标红(未提交/待订正,且非仅查阅);
|
||||
已提交、已批阅、仅查阅的条目保持灰字,避免整屏红字像出错 -->
|
||||
<span
|
||||
class="m-item"
|
||||
:class="{
|
||||
overdue: dueInfo(row.deadlineAt).overdue && !isViewOnly(row) && ['NOT_SUBMITTED', 'RETURNED'].includes(myStatus(row)),
|
||||
urgent: dueInfo(row.deadlineAt).urgent && myStatus(row) === 'NOT_SUBMITTED',
|
||||
}"
|
||||
>
|
||||
<Icon type="md-time" size="14" />
|
||||
{{ row.deadlineAt ? `截止 ${String(row.deadlineAt).slice(5, 16)} · ${dueInfo(row.deadlineAt).text}` : "无截止" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 待订正:老师评语强化展示 -->
|
||||
<div class="c-grade warn" v-if="myStatus(row) === 'RETURNED'">
|
||||
<Icon type="md-refresh" color="#ed4014" size="15" /> 老师要求订正:{{ row.my?.comment || "请订正后重新提交" }}
|
||||
</div>
|
||||
<!-- 已批阅:得分摘要(分数/评语都没有时不渲染) -->
|
||||
<div class="c-grade" v-else-if="myStatus(row) === 'REVIEWED' && gradeText(row)">
|
||||
<Icon type="md-checkmark-circle" color="#19be6b" size="15" /> {{ gradeText(row) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:状态 + 操作(对齐宿舍通知用 Tag 表达状态,替代原来的裸色文字)
|
||||
★ Tag 不要写 size="small":view-ui-plus 4.x 的 Tag.size 只收 default/medium/large,
|
||||
small 会被 validator 拦掉并刷 Vue warn(Button 才有 small,两者不一致) -->
|
||||
<div class="c-side">
|
||||
<Tag v-if="isViewOnly(row)" color="default" class="c-flag">仅查阅</Tag>
|
||||
<template v-else>
|
||||
<span v-if="row.my?.isLate" class="c-late">逾期</span>
|
||||
<span class="c-status" :style="{ color: statusColor(row) }">{{ STATUS_TEXT[myStatus(row)] }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="c-meta">
|
||||
<span class="m-item"><Icon type="md-person" size="14" /> {{ row.teacherName || "—" }}</span>
|
||||
<span class="m-item" :class="{ overdue: dueInfo(row.deadlineAt).overdue && myStatus(row) !== 'REVIEWED', urgent: dueInfo(row.deadlineAt).urgent && myStatus(row) === 'NOT_SUBMITTED' }">
|
||||
<Icon type="md-time" size="14" /> 截止 {{ row.deadlineAt ? String(row.deadlineAt).slice(5, 16) : "—" }}({{ dueInfo(row.deadlineAt).text }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 待订正:老师评语强化展示 -->
|
||||
<div class="c-grade warn" v-if="myStatus(row) === 'RETURNED'">
|
||||
<Icon type="md-refresh" color="#ed4014" size="16" /> 老师要求订正:{{ row.my?.comment || "请订正后重新提交" }}
|
||||
</div>
|
||||
<!-- 已批阅:得分摘要 -->
|
||||
<div class="c-grade" v-else-if="myStatus(row) === 'REVIEWED'">
|
||||
<Icon type="md-checkmark-circle" color="#19be6b" size="16" />
|
||||
得分 {{ row.my?.score ?? "—" }}<template v-if="row.my?.comment"> · {{ row.my.comment }}</template>
|
||||
</div>
|
||||
|
||||
<div class="c-actions">
|
||||
<Button v-if="['NOT_SUBMITTED'].includes(myStatus(row)) && canSubmit(row)" type="primary" icon="md-cloud-upload" size="small" @click="openSubmit(row)">
|
||||
{{ row.hwStatus === "CLOSED" ? "补交作业" : "提交作业" }}</Button>
|
||||
<Button v-else-if="myStatus(row) === 'RETURNED' && canSubmit(row)" type="error" icon="md-refresh" size="small" @click="openSubmit(row)">重新提交</Button>
|
||||
<template v-else-if="['SUBMITTED', 'LATE_SUBMITTED'].includes(myStatus(row)) && canSubmit(row) && row.hwStatus === 'PUBLISHED'">
|
||||
<Button ghost icon="md-create" size="small" @click="openSubmit(row)">修改提交</Button>
|
||||
<Button icon="md-undo" size="small" @click="withdraw(row)">撤回</Button>
|
||||
</template>
|
||||
<Button icon="md-eye" size="small" @click="goDetail(row)">{{ myStatus(row) === "REVIEWED" || myStatus(row) === "RETURNED" ? "查看批阅" : "查看详情" }}</Button>
|
||||
<Tag v-else :color="statusTag(row)" class="c-status">{{ STATUS_TEXT[myStatus(row)] }}</Tag>
|
||||
<div class="c-actions">
|
||||
<Button v-if="['NOT_SUBMITTED'].includes(myStatus(row)) && canSubmit(row)" type="primary" icon="md-cloud-upload" size="small" @click="openSubmit(row)">
|
||||
{{ row.hwStatus === "CLOSED" ? "补交作业" : "提交作业" }}</Button>
|
||||
<Button v-else-if="myStatus(row) === 'RETURNED' && canSubmit(row)" type="error" icon="md-refresh" size="small" @click="openSubmit(row)">重新提交</Button>
|
||||
<template v-else-if="['SUBMITTED', 'LATE_SUBMITTED'].includes(myStatus(row)) && canSubmit(row) && row.hwStatus === 'PUBLISHED'">
|
||||
<!-- 必须带 type:裸 ghost 是白字白底,在白色卡片上等于隐形 -->
|
||||
<Button type="primary" ghost icon="md-create" size="small" @click="openSubmit(row)">修改提交</Button>
|
||||
<Button icon="md-undo" size="small" @click="withdraw(row)">撤回</Button>
|
||||
</template>
|
||||
<Button icon="md-eye" size="small" @click="goDetail(row)">{{ myStatus(row) === "REVIEWED" || myStatus(row) === "RETURNED" ? "查看批阅" : "查看详情" }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -302,79 +331,85 @@ function zoom(url: string) {
|
||||
<Button type="primary" :loading="sub.submitting" icon="md-send" @click="submit">提交</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 图片放大 -->
|
||||
<Modal v-model="imgPreview.open" footer-hide title="图片预览" width="680px">
|
||||
<div class="zoom-wrap"><img :src="imgPreview.url" /></div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/*
|
||||
* 学生端列表版式对齐宿舍通知学生端(ParentNotices.vue / NoticeList.vue):
|
||||
* 1) 铺满内容区(原来是 max-width:960px,1440 视口下右侧空 280px)
|
||||
* 2) 顶部不做装饰性大色块,用浅蓝信息条(#e8f4ff + #d3e9ff 描边,与宿舍通知「我的回执」条同款)
|
||||
* 3) 列表项改为「左信息 / 右状态+操作」两栏,把横向宽度用起来,行高从 ~118px 收到 ~80px
|
||||
* 4) 状态用 Tag 表达(原来是裸色文字,「未提交」的 #c5c8ce 在白卡上几乎看不见)
|
||||
* 功能与交互(tab 过滤、搜索、分页、提交/撤回/跳详情)一行未动。
|
||||
*/
|
||||
.stu {
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- 顶部信息条 ---------- */
|
||||
.stu-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #2d8cf0, #5cadff);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
color: #fff;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 4px 14px rgba(45, 140, 240, 0.25);
|
||||
gap: 16px;
|
||||
background: #e8f4ff;
|
||||
border: 1px solid #d3e9ff;
|
||||
border-radius: 4px;
|
||||
padding: 9px 14px;
|
||||
margin-bottom: 12px;
|
||||
.sh-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.sh-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: #5979ea;
|
||||
}
|
||||
.sh-title {
|
||||
font-size: 18px;
|
||||
flex: none;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
}
|
||||
.sh-sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
margin-top: 2px;
|
||||
color: #808695;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sh-chips {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
.chip {
|
||||
min-width: 66px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
b {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sh-count {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
b {
|
||||
font-size: 16px;
|
||||
color: #5979ea;
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.stu-tabs {
|
||||
margin-bottom: 0;
|
||||
:deep(.ivu-tabs-bar) {
|
||||
margin-bottom: 0;
|
||||
|
||||
/* ---------- 筛选行(tab 左 / 搜索右) ---------- */
|
||||
.stu-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
.stu-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
:deep(.ivu-tabs-bar) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.stu-search {
|
||||
flex: none;
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
@@ -385,62 +420,80 @@ function zoom(url: string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 列表项 ---------- */
|
||||
.cards {
|
||||
/* 分页条是 position:absolute 钉在视口底部(72px 高、白底),列表在其下方滚动会被盖住;
|
||||
有分页时垫出这段高度,让最后一条能滚到分页条上方(同 Table 页预留横向滚动条的道理) */
|
||||
&.has-page {
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
}
|
||||
.cards .card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #eceef1;
|
||||
border-left: 4px solid #c5c8ce;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
transition: box-shadow 0.2s;
|
||||
border: 1px solid #ebeef4;
|
||||
border-left: 3px solid #c5c8ce;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
border-color 0.2s;
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
&.redo {
|
||||
border-color: #f5b0a6;
|
||||
border-left-color: #ed4014;
|
||||
background: #fffaf9;
|
||||
}
|
||||
|
||||
/* 左:主体信息 */
|
||||
.c-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.c-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
.c-subj {
|
||||
background: #eaf3ff;
|
||||
color: #2d8cf0;
|
||||
border-radius: 4px;
|
||||
padding: 2px 9px;
|
||||
color: #5979ea;
|
||||
border-radius: 3px;
|
||||
padding: 1px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
flex: none;
|
||||
}
|
||||
.c-title {
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
}
|
||||
.c-flag {
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.c-late {
|
||||
background: #ff9900;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
flex: none;
|
||||
}
|
||||
.c-status {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
padding: 0 5px;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
.c-meta {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 18px;
|
||||
margin-top: 6px;
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
.m-item {
|
||||
@@ -455,23 +508,38 @@ function zoom(url: string) {
|
||||
color: #ff9900;
|
||||
}
|
||||
}
|
||||
/* 评语条:inline-flex 让底色只包住内容,不拉满整行 */
|
||||
.c-grade {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #f6ffed;
|
||||
border-radius: 6px;
|
||||
color: #389e0d;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 5px 10px;
|
||||
background: #f6ffed;
|
||||
border-radius: 4px;
|
||||
color: #389e0d;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
&.warn {
|
||||
background: #fff1f0;
|
||||
color: #cf1322;
|
||||
}
|
||||
}
|
||||
|
||||
/* 右:状态 + 操作 */
|
||||
.c-side {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
.c-status,
|
||||
.c-flag {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.c-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -488,11 +556,4 @@ function zoom(url: string) {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
.zoom-wrap {
|
||||
text-align: center;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 62vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, h, onMounted, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { teacherApi } from "@/api/module/teacherHomework";
|
||||
import type {
|
||||
@@ -17,6 +17,7 @@ import Utils from "@/utils";
|
||||
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const serid = ref<number>(Number(route.query.serid) || 0);
|
||||
|
||||
/* ============ 未指定作业时:从我布置的已发布作业里选一份 ============ */
|
||||
@@ -123,7 +124,10 @@ onMounted(async () => {
|
||||
await loadMyHomeworks();
|
||||
load();
|
||||
loadStats();
|
||||
nextTick(calcTableHeight);
|
||||
window.addEventListener("resize", calcTableHeight);
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener("resize", calcTableHeight));
|
||||
|
||||
const total = computed(() => stats.value?.total || null);
|
||||
const summaryTitle = computed(() => {
|
||||
@@ -184,6 +188,31 @@ function remindOne(row: StudentMatrixVo) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 一键提醒全部未交学生(不传 studentIds,后端按 NOT_SUBMITTED 圈定全量未交) */
|
||||
function remindAllUnsubmitted() {
|
||||
const un = total.value?.unsubmitted || 0;
|
||||
if (!un) {
|
||||
Message.info("当前作业没有未交学生");
|
||||
return;
|
||||
}
|
||||
MyConfirm({
|
||||
title: "一键未交提醒",
|
||||
msg: `向「${summaryTitle.value}」的 ${un} 名未交学生发送提交提醒?`,
|
||||
async onOk() {
|
||||
try {
|
||||
const r = await teacherApi.remind(serid.value, "NOT_SUBMITTED").send();
|
||||
Message.success(`已提醒 ${r?.reminded ?? 0} 位学生`);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "提醒失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
/** 带当前作业跳批改中心 */
|
||||
function goGrade() {
|
||||
router.push({ path: "/grade", query: { serid: String(serid.value) } });
|
||||
}
|
||||
|
||||
/* ============ 矩阵列 ============ */
|
||||
const columns = computed(() => [
|
||||
{ title: "学生", key: "studentName", minWidth: 110, align: "left" },
|
||||
@@ -257,7 +286,8 @@ const columns = computed(() => [
|
||||
const r = p.row as StudentMatrixVo;
|
||||
const nodes: any[] = [];
|
||||
if (r.submitStatus !== "NOT_SUBMITTED") {
|
||||
nodes.push(Utils.operatingHandle(h, r.reviewStatus === "PENDING" ? "批阅" : "查看", r.reviewStatus === "PENDING" ? "icon-edit" : "icon-chakan", () => openReview(r)));
|
||||
// 批阅统一用 icon-shenpi(与「我布置的」行操作同图标),查看用 icon-chakan
|
||||
nodes.push(Utils.operatingHandle(h, r.reviewStatus === "PENDING" ? "批阅" : "查看", r.reviewStatus === "PENDING" ? "icon-shenpi" : "icon-chakan", () => openReview(r)));
|
||||
} else {
|
||||
nodes.push(Utils.operatingHandle(h, "提醒", "icon-fasong", () => remindOne(r)));
|
||||
}
|
||||
@@ -265,7 +295,14 @@ const columns = computed(() => [
|
||||
},
|
||||
},
|
||||
]);
|
||||
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
|
||||
/* 表格高度:扣除顶部(筛选行 + 汇总卡片 + Tab)实测高度,保证分页不被挤出视口 */
|
||||
const topArea = ref<HTMLElement | null>(null);
|
||||
const tableHeight = ref(320);
|
||||
function calcTableHeight() {
|
||||
const topH = topArea.value?.offsetHeight || 0;
|
||||
tableHeight.value = Math.max(Utils.useTableHeight({ isPage: true }) - topH - 16, 200);
|
||||
}
|
||||
watch(total, () => nextTick(calcTableHeight));
|
||||
|
||||
/* 图片放大 */
|
||||
const imgPreview = reactive({ open: false, url: "" });
|
||||
@@ -278,51 +315,57 @@ function zoom(url: string) {
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 顶部:作业选择 + 班级/状态筛选 -->
|
||||
<div class="fix mb-12">
|
||||
<div class="fl" style="display: flex; align-items: center; gap: 8px">
|
||||
<span style="color: #515a6e">作业:</span>
|
||||
<Select :model-value="serid" style="width: 360px" placeholder="选择一份我布置的作业" filterable :loading="loadingList" @on-change="pickHw">
|
||||
<Option v-for="hw in myHomeworks" :key="hw.id" :value="hw.id">
|
||||
{{ hw.subject ? hw.subject + " · " : "" }}{{ hw.title }}{{ (hw.progress?.pendingGrade ?? 0) > 0 ? `(待批 ${hw.progress!.pendingGrade})` : "" }}
|
||||
</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="fr" style="display: flex; align-items: center; gap: 8px">
|
||||
<span style="color: #515a6e">班级:</span>
|
||||
<Select v-model="classId" style="width: 150px" clearable placeholder="全部班级" @on-change="doSearch">
|
||||
<Option v-for="c in stats?.byClass || []" :key="c.classId" :value="c.classId">{{ c.className }}</Option>
|
||||
</Select>
|
||||
<Checkbox v-model="pendingOnly" @on-change="doSearch">只看待批阅</Checkbox>
|
||||
<!-- 顶部:作业/班级/待批筛选 + 一键提醒/去批阅(整体作为表格高度的实测让出区) -->
|
||||
<div ref="topArea">
|
||||
<div class="fix mb-12">
|
||||
<div class="fl" style="display: flex; align-items: center; gap: 8px">
|
||||
<span style="color: #515a6e">作业:</span>
|
||||
<Select :model-value="serid" style="width: 360px" placeholder="选择一份我布置的作业" filterable :loading="loadingList" @on-change="pickHw">
|
||||
<Option v-for="hw in myHomeworks" :key="hw.id" :value="hw.id">
|
||||
{{ hw.subject ? hw.subject + " · " : "" }}{{ hw.title }}{{ (hw.progress?.pendingGrade ?? 0) > 0 ? `(待批 ${hw.progress!.pendingGrade})` : "" }}
|
||||
</Option>
|
||||
</Select>
|
||||
<span style="color: #515a6e; margin-left: 8px">班级:</span>
|
||||
<Select v-model="classId" style="width: 140px" clearable placeholder="全部班级" @on-change="doSearch">
|
||||
<Option v-for="c in stats?.byClass || []" :key="c.classId" :value="c.classId">{{ c.className }}</Option>
|
||||
</Select>
|
||||
<Checkbox v-model="pendingOnly" @on-change="doSearch">只看待批阅</Checkbox>
|
||||
</div>
|
||||
<div class="fr" style="display: flex; align-items: center; gap: 8px">
|
||||
<Button :disabled="!serid" @click="remindAllUnsubmitted">一键未交提醒</Button>
|
||||
<Button type="primary" :disabled="!serid" @click="goGrade">去批阅</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!serid" class="tip">请先在上方选择一份作业查看提交情况。</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- 汇总卡片 -->
|
||||
<div v-if="total" class="sum-bar mb-12">
|
||||
<div class="sum-title">
|
||||
<b>{{ summaryTitle }}</b>
|
||||
<span class="sub">
|
||||
应交 {{ total.should }} 人 · 阅读率 {{ total.readRate }}% · 提交率 {{ total.submitRate }}% · 批阅率 {{ total.gradeRate }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="sum-cards">
|
||||
<div class="card"><div class="n">{{ total.should }}</div><div class="l">应交</div></div>
|
||||
<div class="card"><div class="n">{{ total.read }}</div><div class="l">已读</div></div>
|
||||
<div class="card"><div class="n">{{ total.submitted }}</div><div class="l">已交</div></div>
|
||||
<div class="card"><div class="n warn">{{ total.pendingGrade }}</div><div class="l">待批阅</div></div>
|
||||
<div class="card"><div class="n err">{{ total.unreadUndone }}</div><div class="l">未读未交</div></div>
|
||||
<div class="card"><div class="n">{{ total.submitRate }}%</div><div class="l">提交率</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交状态 Tab -->
|
||||
<Tabs :value="submitStatus" @on-click="(n: string) => { submitStatus = n; doSearch(); }" class="mb-12">
|
||||
<TabPane v-for="t in SUBMIT_TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
|
||||
</Tabs>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="!serid" class="tip">请先在上方选择一份作业查看提交情况。</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- 汇总卡片 -->
|
||||
<div v-if="total" class="sum-bar mb-12">
|
||||
<div class="sum-title">
|
||||
<b>{{ summaryTitle }}</b>
|
||||
<span class="sub">
|
||||
应交 {{ total.should }} 人 · 阅读率 {{ total.readRate }}% · 提交率 {{ total.submitRate }}% · 批阅率 {{ total.gradeRate }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="sum-cards">
|
||||
<div class="card"><div class="n">{{ total.should }}</div><div class="l">应交</div></div>
|
||||
<div class="card"><div class="n">{{ total.read }}</div><div class="l">已读</div></div>
|
||||
<div class="card"><div class="n">{{ total.submitted }}</div><div class="l">已交</div></div>
|
||||
<div class="card"><div class="n warn">{{ total.pendingGrade }}</div><div class="l">待批阅</div></div>
|
||||
<div class="card"><div class="n err">{{ total.unreadUndone }}</div><div class="l">未读未交</div></div>
|
||||
<div class="card"><div class="n">{{ total.submitRate }}%</div><div class="l">提交率</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交状态 Tab -->
|
||||
<Tabs :value="submitStatus" @on-click="(n: string) => { submitStatus = n; doSearch(); }" class="mb-12">
|
||||
<TabPane v-for="t in SUBMIT_TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
|
||||
</Tabs>
|
||||
|
||||
<template v-if="serid">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
@@ -367,21 +410,26 @@ function zoom(url: string) {
|
||||
.sum-bar {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
padding: 6px 14px;
|
||||
background: #fafcff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 20px;
|
||||
.sub {
|
||||
color: #808695;
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sum-cards {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
margin-top: 12px;
|
||||
gap: 18px;
|
||||
.card {
|
||||
text-align: center;
|
||||
.n {
|
||||
font-size: 22px;
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, h } from "vue";
|
||||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { teacherApi } from "@/api/module/teacherHomework";
|
||||
import type { HomeworkV2Vo, TeacherHomeworkQuery } from "@/types/homeworkV2";
|
||||
import type { BasePageResult } from "@/types/homeworkV2";
|
||||
import type { BasePageResult, SubjectVo } from "@/types/homeworkV2";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
/* 学科下拉绑定 /subjects/assigned(布置过 ∪ 任教,subId=筛选值与列表 subject 精确匹配);
|
||||
* 接口失败或为空时降级为内置清单,保证筛选始终可用 */
|
||||
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "道德与法治", "历史", "地理", "科学", "综合实践"];
|
||||
const subjectOpts = ref<SubjectVo[]>(SUBJECTS.map((s) => ({ subId: s, subName: s })));
|
||||
async function loadSubjects() {
|
||||
try {
|
||||
const list = await teacherApi.assignedSubjects().send();
|
||||
if (Array.isArray(list) && list.length) subjectOpts.value = list;
|
||||
} catch {
|
||||
/* 降级用内置清单 */
|
||||
}
|
||||
}
|
||||
loadSubjects();
|
||||
|
||||
/* ============ 顶级视图:已发布 / 草稿箱 ============ */
|
||||
/* ============ 视图:单行 Tab(已发布/进行中/已截止/已归档 + 草稿箱,草稿箱用分隔线隔开,带数量) ============ */
|
||||
type ViewMode = "published" | "drafts";
|
||||
const viewMode = ref<ViewMode>("published");
|
||||
|
||||
/* 已发布状态筛选(空 = 全部) */
|
||||
const PUB_TABS = [
|
||||
{ name: "", label: "全部" },
|
||||
/* 已发布状态筛选(空 = 已发布全部状态) */
|
||||
const pubStatus = ref("");
|
||||
const TAB_DEFS = [
|
||||
{ name: "", label: "已发布" },
|
||||
{ name: "PUBLISHED", label: "进行中" },
|
||||
{ name: "CLOSED", label: "已截止" },
|
||||
{ name: "ARCHIVED", label: "已归档" },
|
||||
{ name: "drafts", label: "草稿箱" },
|
||||
];
|
||||
const pubStatus = ref("");
|
||||
/* Tab 数量(接口无汇总端点,用 pageSize=1 的分页 total 汇出;随关键字/学科筛选刷新) */
|
||||
const tabCounts = ref<Record<string, number>>({});
|
||||
async function loadCounts() {
|
||||
const base = { keyword: keyword.value || undefined, subject: subject.value || undefined, pageIndex: 1, pageSize: 1 };
|
||||
try {
|
||||
const [pub, closed, arch, draft] = await Promise.all([
|
||||
teacherApi.published({ ...base, hwStatus: "PUBLISHED" }).send(),
|
||||
teacherApi.published({ ...base, hwStatus: "CLOSED" }).send(),
|
||||
teacherApi.published({ ...base, hwStatus: "ARCHIVED" }).send(),
|
||||
teacherApi.drafts(base).send(),
|
||||
]);
|
||||
tabCounts.value = {
|
||||
"": pub.total + closed.total + arch.total,
|
||||
PUBLISHED: pub.total,
|
||||
CLOSED: closed.total,
|
||||
ARCHIVED: arch.total,
|
||||
drafts: draft.total,
|
||||
};
|
||||
} catch {
|
||||
/* 数量加载失败不影响主列表 */
|
||||
}
|
||||
}
|
||||
/* Tab 标签用 render:文字 + 计数徽标(数量未加载时只显示文字) */
|
||||
const PUB_TABS = computed(() =>
|
||||
TAB_DEFS.map((t) => ({
|
||||
name: t.name,
|
||||
label: (h: any) =>
|
||||
h("span", { class: "hw-tab" }, [
|
||||
h("span", { class: "hw-tab-text" }, t.label),
|
||||
tabCounts.value[t.name] === undefined
|
||||
? null
|
||||
: h("span", { class: "hw-tab-count" }, String(tabCounts.value[t.name])),
|
||||
]),
|
||||
}))
|
||||
);
|
||||
const tabValue = computed(() => (viewMode.value === "drafts" ? "drafts" : pubStatus.value));
|
||||
|
||||
/* ============ 搜索条件 ============ */
|
||||
const keyword = ref("");
|
||||
@@ -47,6 +95,7 @@ async function run() {
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
if (viewMode.value === "published" && pubStatus.value) params.hwStatus = pubStatus.value;
|
||||
loadCounts();
|
||||
try {
|
||||
await reload(params, viewMode.value);
|
||||
} catch (e: any) {
|
||||
@@ -57,13 +106,13 @@ function doSearch() {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function switchView(name: string) {
|
||||
viewMode.value = name as ViewMode;
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function onPubTab(name: string) {
|
||||
pubStatus.value = name;
|
||||
function onTab(name: string) {
|
||||
if (name === "drafts") {
|
||||
viewMode.value = "drafts";
|
||||
} else {
|
||||
viewMode.value = "published";
|
||||
pubStatus.value = name;
|
||||
}
|
||||
doSearch();
|
||||
}
|
||||
function pageUpdate(p: number) {
|
||||
@@ -90,17 +139,33 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
/* ============ 状态色 ============ */
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT_UNPUBLISHED: "default",
|
||||
DRAFT_SCHEDULED: "blue",
|
||||
PUBLISHED: "primary",
|
||||
CLOSED: "warning",
|
||||
ARCHIVED: "success",
|
||||
};
|
||||
/* ============ 状态列 & 操作列(与「全部作业」共用 Utils 里那一套口径) ============ */
|
||||
/**
|
||||
* 状态:统一走 Utils.hwStatusPill(状态图标 + 文字 + 同色浅底)。
|
||||
* 之前这里用 iview Tag、全部作业用圆点,同一个模块两套观感;现在两边同一张状态表,
|
||||
* 颜色/图标/文案都只在 utils/index.ts 的 HW_STATUS_META 里定义一次。
|
||||
*/
|
||||
function statusCell(h: any, code: string | undefined, text?: string) {
|
||||
return Utils.hwStatusPill(h, code, text);
|
||||
}
|
||||
function fmtTime(v?: string | null): string {
|
||||
return v ? String(v).slice(0, 16) : "—";
|
||||
}
|
||||
/** 短字段单元格:强制单行(时间、状态这类短文本折行很难看,且会顶破 48px 行高) */
|
||||
function nowrapCell(h: any, text: string, style = "") {
|
||||
return h("span", { style: `white-space:nowrap;${style}` }, text);
|
||||
}
|
||||
/** 长字段单元格:单行 + 省略号,悬停由原生 title 看全文(与宿舍通知「接收宿舍」同一手法) */
|
||||
function ellipsisCell(h: any, text: string) {
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
title: text,
|
||||
style: "display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;",
|
||||
},
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ 行操作 ============ */
|
||||
function viewDetail(row: HomeworkV2Vo) {
|
||||
@@ -112,6 +177,11 @@ function viewSubmissions(row: HomeworkV2Vo) {
|
||||
function toGrade(row: HomeworkV2Vo) {
|
||||
router.push({ path: "/grade", query: { serid: String(row.id) } });
|
||||
}
|
||||
/** 勾选了任一提交渠道(拍照/图片、上传附件、文字作答)=教师需批改 → 显示批阅入口;
|
||||
* 仅查阅(none)=学生只预览,不显示批阅 */
|
||||
function needGrade(row: HomeworkV2Vo): boolean {
|
||||
return (row.submitType || []).some((t) => t !== "none");
|
||||
}
|
||||
function edit(row: HomeworkV2Vo) {
|
||||
router.push({ path: "/list/new", query: { serid: String(row.id) } });
|
||||
}
|
||||
@@ -211,7 +281,10 @@ function publishDraft(row: HomeworkV2Vo) {
|
||||
try {
|
||||
await teacherApi.publish(row.id).send();
|
||||
Message.success("发布成功");
|
||||
switchView("published");
|
||||
viewMode.value = "published";
|
||||
pubStatus.value = "";
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "发布失败");
|
||||
}
|
||||
@@ -253,48 +326,68 @@ function del(row: HomeworkV2Vo) {
|
||||
}
|
||||
|
||||
/* ============ 已发布列表列 ============ */
|
||||
/**
|
||||
* 列宽口径(对齐宿舍通知 NoticeList):
|
||||
* - 序号列 ≥ 76:iview 单元格左右各 18px padding,「序号」两字约 36px,给 60 会被挤成「序/号」两行;
|
||||
* - 「未读未交」「待批阅」「提交率」这类 3~4 字表头各自留足一字不折行的宽度;
|
||||
* - 时间列 150 + nowrap:130 会把「2026-09-15 10:00」折成两行;
|
||||
* - 班级单行省略,悬停由原生 title 看全文;
|
||||
* - 列宽合计 ~1700px,宽屏也保留横向滚动(滚动条 8px 圆角的样式见 App.vue 的
|
||||
* .ivu-table-overflowX 覆写)。**不要再为了「收进一屏」去压列宽或合并列** ——
|
||||
* 把列压窄只会让表头折行,越改越难看。
|
||||
*/
|
||||
const pubColumns = computed(() => [
|
||||
{ title: "序号", type: "index", width: 60, align: "center" },
|
||||
{ title: "序号", type: "index", width: 76, align: "center" },
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
minWidth: 300,
|
||||
align: "left",
|
||||
tooltip: true,
|
||||
/* 标题是长自由文本,列宽再大也会超 —— 单行 + 省略号,悬停用原生 title 看全文
|
||||
* (宿舍通知 NoticeList 的 titleCell 就是「蓝色链接 + title 属性」,这里补上省略号,
|
||||
* 否则长标题会折成两行、和「全部作业」页的单行样式不一致) */
|
||||
render: (h: any, p: any) =>
|
||||
h("a", { style: "color:#2d8cf0;", onClick: () => viewDetail(p.row) }, () => p.row.title),
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
style: "display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;color:#2d8cf0;",
|
||||
title: p.row.title,
|
||||
onClick: () => viewDetail(p.row),
|
||||
},
|
||||
() => p.row.title
|
||||
),
|
||||
},
|
||||
{ title: "学科", key: "subject", width: 80, align: "center" },
|
||||
{ title: "学科", key: "subject", width: 120, align: "center" },
|
||||
{
|
||||
title: "班级",
|
||||
width: 130,
|
||||
width: 190,
|
||||
align: "left",
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) => h("span", {}, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
|
||||
render: (h: any, p: any) => ellipsisCell(h, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
|
||||
},
|
||||
{ title: "发布时间", width: 130, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.publishedAt)) },
|
||||
{ title: "截止时间", width: 130, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.deadlineAt)) },
|
||||
{ title: "发布时间", width: 170, align: "left", render: (h: any, p: any) => nowrapCell(h, fmtTime(p.row.publishedAt)) },
|
||||
{ title: "截止时间", width: 170, align: "left", render: (h: any, p: any) => nowrapCell(h, fmtTime(p.row.deadlineAt)) },
|
||||
{
|
||||
title: "应交",
|
||||
width: 65,
|
||||
width: 70,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.should ?? "—")),
|
||||
},
|
||||
{
|
||||
title: "已读",
|
||||
width: 65,
|
||||
width: 70,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.read ?? "—")),
|
||||
},
|
||||
{
|
||||
title: "已交",
|
||||
width: 65,
|
||||
width: 70,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.submitted ?? "—")),
|
||||
},
|
||||
{
|
||||
title: "未读未交",
|
||||
width: 85,
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const g = p.row.progress;
|
||||
@@ -304,7 +397,7 @@ const pubColumns = computed(() => [
|
||||
},
|
||||
{
|
||||
title: "提交率",
|
||||
width: 80,
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const g = p.row.progress;
|
||||
@@ -314,7 +407,7 @@ const pubColumns = computed(() => [
|
||||
},
|
||||
{
|
||||
title: "待批阅",
|
||||
width: 75,
|
||||
width: 95,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const g = p.row.progress;
|
||||
@@ -324,14 +417,16 @@ const pubColumns = computed(() => [
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 90,
|
||||
width: 120,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("Tag", { color: STATUS_COLOR[p.row.hwStatus] || "default" }, () => p.row.hwStatusDesc || p.row.hwStatus),
|
||||
render: (h: any, p: any) => statusCell(h, p.row.hwStatus, p.row.hwStatusDesc),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
slot: "action",
|
||||
width: 230,
|
||||
/* 3 个图标按钮:32px + tooltip 左右各 2px = 36px → 108px,再留单元格左右 padding
|
||||
各 18px(36px) 与少量余量 → 170 恰好一行;给 150 时最后一个会被挤到第二行 */
|
||||
width: 170,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
},
|
||||
@@ -349,62 +444,92 @@ function onPubMore(name: string, row: HomeworkV2Vo) {
|
||||
|
||||
/* ============ 草稿箱列 ============ */
|
||||
const draftColumns = computed(() => [
|
||||
{ title: "序号", type: "index", width: 60, align: "center" },
|
||||
{ title: "标题", key: "title", minWidth: 220, align: "left", tooltip: true },
|
||||
{ title: "学科", key: "subject", width: 90, align: "center" },
|
||||
{ title: "序号", type: "index", width: 76, align: "center" },
|
||||
{ title: "标题", key: "title", minWidth: 300, align: "left", tooltip: true },
|
||||
{ title: "学科", key: "subject", width: 120, align: "center" },
|
||||
{
|
||||
title: "班级",
|
||||
width: 150,
|
||||
width: 190,
|
||||
align: "left",
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) => h("span", {}, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
|
||||
render: (h: any, p: any) => ellipsisCell(h, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
|
||||
},
|
||||
{ title: "更新时间", width: 140, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.updateTime)) },
|
||||
{ title: "截止时间", width: 140, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.deadlineAt)) },
|
||||
{ title: "更新时间", width: 170, align: "left", render: (h: any, p: any) => nowrapCell(h, fmtTime(p.row.updateTime)) },
|
||||
{ title: "截止时间", width: 170, align: "left", render: (h: any, p: any) => nowrapCell(h, fmtTime(p.row.deadlineAt)) },
|
||||
{
|
||||
title: "状态",
|
||||
width: 110,
|
||||
/* 「定时 2026-09-18 08:00」+ 状态图标 + 胶囊内边距实测 185px,单元格左右 padding 各 18px
|
||||
→ 列宽给 230 才有余量(220 实测只剩 0~1px,改字号/字体就会截掉一截) */
|
||||
width: 230,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const row = p.row as HomeworkV2Vo;
|
||||
const st = row.hwStatus === "DRAFT_SCHEDULED" ? `定时 ${fmtTime(row.scheduledAt)}` : "未发布";
|
||||
return h("Tag", { color: STATUS_COLOR[row.hwStatus] || "default" }, () => st);
|
||||
const scheduled = row.hwStatus === "DRAFT_SCHEDULED";
|
||||
return statusCell(h, row.hwStatus, scheduled ? `定时 ${fmtTime(row.scheduledAt)}` : undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
slot: "draftAction",
|
||||
width: 250,
|
||||
/* 最多 4 个图标按钮(编辑/发布/取消定时/删除):4 × 36 + padding 36 + 余量 = 200 */
|
||||
width: 200,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
},
|
||||
]);
|
||||
|
||||
const columns = computed(() => (viewMode.value === "drafts" ? draftColumns.value : pubColumns.value));
|
||||
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
|
||||
|
||||
/* ============ 表格高度:量真实 DOM,不用常量估算 ============ */
|
||||
/** 表格外层容器(量它的顶边位置) */
|
||||
const tableWrap = ref<HTMLElement | null>(null);
|
||||
const tableHeight = ref(400);
|
||||
|
||||
/**
|
||||
* 表格高度 = 分页条顶边 − 表格顶边 − 12px 呼吸位(列宽超出容器时再扣掉横向滚动条约 15px)。
|
||||
*
|
||||
* 原先用 `Utils.useTableHeight({isSearch,isOperat,isPage})` 按 body 高度减固定值估算:
|
||||
* 它既不认本页把状态 Tab 嵌在搜索行里的实际高度,也不认容器内边距,算出的值与真实可用
|
||||
* 空间对不齐;而 `.page-panel` 是 absolute 吸底定位,表格一旦比可用空间高就压到分页上,
|
||||
* 横向滚动条也没给它留位置(最后一行会被滚动条切掉)。
|
||||
* 这里直接量 DOM —— 加减筛选条件、切 Tab、改字号都不会再失配;窗口 resize 时重算。
|
||||
*/
|
||||
function calcTableHeight() {
|
||||
const wrap = tableWrap.value;
|
||||
if (!wrap) return;
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const panel = wrap.parentElement?.querySelector(".page-panel") as HTMLElement | null;
|
||||
const limit = panel ? panel.getBoundingClientRect().top : window.innerHeight;
|
||||
// 列宽合计超过容器宽度 → 会出现横向滚动条,它会吃掉表体 ~15px 高度,一并预留
|
||||
const colsWidth = (columns.value as any[]).reduce((s, c) => s + (c.width || c.minWidth || 100), 0);
|
||||
const hBar = colsWidth > wrap.clientWidth ? 15 : 0;
|
||||
tableHeight.value = Math.max(240, Math.floor(limit - rect.top - 12 - hBar));
|
||||
}
|
||||
onMounted(() => {
|
||||
nextTick(calcTableHeight);
|
||||
window.addEventListener("resize", calcTableHeight);
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener("resize", calcTableHeight));
|
||||
/* 已发布 / 草稿箱两套列宽不同 → 横向滚动条的有无会变,切 Tab 后重算 */
|
||||
watch(columns, () => nextTick(calcTableHeight));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 1. 操作 + 视图切换 -->
|
||||
<!-- 1. 操作 -->
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="router.push('/list/new')">布置作业</Button>
|
||||
</div>
|
||||
<Tabs :value="viewMode" @on-click="switchView" class="mb-12">
|
||||
<TabPane name="published" label="已发布"></TabPane>
|
||||
<TabPane name="drafts" label="草稿箱"></TabPane>
|
||||
</Tabs>
|
||||
|
||||
<!-- 2. 已发布状态筛选 + 搜索区 -->
|
||||
<!-- 2. 状态筛选(单行 Tab) + 搜索区 -->
|
||||
<div class="search-wrap fix">
|
||||
<div v-if="viewMode === 'published'" class="fl">
|
||||
<Tabs :value="pubStatus" @on-click="onPubTab" size="small" class="inner-tabs">
|
||||
<div class="fl">
|
||||
<Tabs :value="tabValue" @on-click="onTab" size="small" class="inner-tabs">
|
||||
<TabPane v-for="t in PUB_TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Select v-model="subject" class="search-wrap_sel ml-12 fl" clearable placeholder="学科" style="width: 120px" @on-change="doSearch">
|
||||
<Option v-for="s in SUBJECTS" :key="s" :value="s">{{ s }}</Option>
|
||||
<Option v-for="s in subjectOpts" :key="s.subId" :value="s.subId">{{ s.subName }}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
class="fr"
|
||||
@@ -418,41 +543,59 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 3. 表格 + 分页 -->
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无数据"
|
||||
>
|
||||
<!-- 已发布行操作 -->
|
||||
<template #action="{ row }">
|
||||
<Button type="text" size="small" @click="viewSubmissions(row)">提交情况</Button>
|
||||
<Button v-if="(row.progress?.pendingGrade ?? 0) > 0" type="text" size="small" @click="toGrade(row)">批阅</Button>
|
||||
<Dropdown transfer @on-click="(name: string) => onPubMore(name, row)">
|
||||
<Button type="text" size="small">更多∨</Button>
|
||||
<template #list>
|
||||
<DropdownMenu>
|
||||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED' && (row.progress?.unread ?? 0) > 0" name="notRead">提醒未读</DropdownItem>
|
||||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED' && (row.progress?.unsubmitted ?? 0) > 0" name="notSubmitted">催交</DropdownItem>
|
||||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED'" name="extend">延长截止</DropdownItem>
|
||||
<DropdownItem name="copy">复制</DropdownItem>
|
||||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED' || row.hwStatus === 'CLOSED'" name="archive">归档</DropdownItem>
|
||||
<DropdownItem name="export">导出报表</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<!-- 草稿箱行操作 -->
|
||||
<template #draftAction="{ row }">
|
||||
<Button type="text" size="small" @click="edit(row)">编辑</Button>
|
||||
<Button type="text" size="small" @click="publishDraft(row)">发布</Button>
|
||||
<Button v-if="row.hwStatus === 'DRAFT_SCHEDULED'" type="text" size="small" @click="cancelSchedule(row)">取消定时</Button>
|
||||
<Button type="text" size="small" @click="del(row)">删除</Button>
|
||||
</template>
|
||||
<!-- 3. 表格 + 分页(tableWrap 供 calcTableHeight 量真实可用高度) -->
|
||||
<div ref="tableWrap">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无数据"
|
||||
>
|
||||
<!-- 已发布行操作:图标按钮 + 悬浮说明
|
||||
(提交情况=icon-chakan、批阅=icon-shenpi,与「全部作业」「提交情况」页同一套语义;
|
||||
其余低频操作仍收在「更多」里,不丢功能) -->
|
||||
<template #action="{ row }">
|
||||
<Tooltip content="提交情况" placement="bottom" transfer>
|
||||
<div class="operating-btn iconfont icon-chakan" @click="viewSubmissions(row)"></div>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="needGrade(row)" content="批阅" placement="bottom" transfer>
|
||||
<div class="operating-btn iconfont icon-shenpi" @click="toGrade(row)"></div>
|
||||
</Tooltip>
|
||||
<Dropdown transfer @on-click="(name: string) => onPubMore(name, row)">
|
||||
<!-- 触发器不再包 Tooltip:Dropdown 默认 hover 展开,悬停时两个气泡会打架,
|
||||
这里用原生 title 兜底说明 -->
|
||||
<div class="operating-btn operating-btn-more" title="更多"><Icon type="ios-more" size="20" /></div>
|
||||
<template #list>
|
||||
<DropdownMenu>
|
||||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED' && (row.progress?.unread ?? 0) > 0" name="notRead">提醒未读</DropdownItem>
|
||||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED' && (row.progress?.unsubmitted ?? 0) > 0" name="notSubmitted">催交</DropdownItem>
|
||||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED'" name="extend">延长截止</DropdownItem>
|
||||
<DropdownItem name="copy">复制</DropdownItem>
|
||||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED' || row.hwStatus === 'CLOSED'" name="archive">归档</DropdownItem>
|
||||
<DropdownItem name="export">导出报表</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<!-- 草稿箱行操作:同一套图标口径(编辑/发布/取消定时/删除) -->
|
||||
<template #draftAction="{ row }">
|
||||
<Tooltip content="编辑" placement="bottom" transfer>
|
||||
<div class="operating-btn iconfont icon-edit" @click="edit(row)"></div>
|
||||
</Tooltip>
|
||||
<Tooltip content="发布" placement="bottom" transfer>
|
||||
<div class="operating-btn iconfont icon-fasong" @click="publishDraft(row)"></div>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="row.hwStatus === 'DRAFT_SCHEDULED'" content="取消定时" placement="bottom" transfer>
|
||||
<div class="operating-btn iconfont icon-stop" @click="cancelSchedule(row)"></div>
|
||||
</Tooltip>
|
||||
<Tooltip content="删除" placement="bottom" transfer>
|
||||
<div class="operating-btn iconfont icon-ashbin" @click="del(row)"></div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
@@ -502,6 +645,73 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
||||
:deep(.ivu-tabs-bar) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* ink bar 由组件 JS 按前序 tab 宽度累计定位,自定义 margin 间距会使其偏移,
|
||||
* 故隐藏之,改用激活项伪元素画下划线 */
|
||||
:deep(.ivu-tabs-ink-bar) {
|
||||
display: none;
|
||||
}
|
||||
:deep(.ivu-tabs-tab) {
|
||||
position: relative;
|
||||
margin-right: 28px;
|
||||
padding: 10px 2px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
/* 激活下划线:加粗 + 圆角,随 tab 盒子对齐 */
|
||||
:deep(.ivu-tabs-tab-active)::after,
|
||||
:deep(.ivu-tabs-tab-active:hover)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: #2d8cf0;
|
||||
}
|
||||
/* 标签文字 + 计数徽标 */
|
||||
:deep(.hw-tab) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
.hw-tab-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
.hw-tab-count {
|
||||
min-width: 16px;
|
||||
margin-left: 6px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
background: #f2f3f5;
|
||||
color: #808695;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
:deep(.ivu-tabs-tab:hover .hw-tab-count) {
|
||||
background: #e8f3ff;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
:deep(.ivu-tabs-tab-active .hw-tab-count),
|
||||
:deep(.ivu-tabs-tab-active:hover .hw-tab-count) {
|
||||
background: #2d8cf0;
|
||||
color: #fff;
|
||||
}
|
||||
/* 末位(草稿箱)与状态组之间用竖线隔开 */
|
||||
:deep(.ivu-tabs-tab:last-child) {
|
||||
margin-right: 0;
|
||||
margin-left: 26px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: #dcdee2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.extend-tip {
|
||||
@@ -509,4 +719,13 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 操作列图标按钮组:Tooltip / Dropdown 两种容器都是 inline-block,
|
||||
默认 baseline 对齐会让「更多」比另两个偏低 2~3px,统一按中线对齐 */
|
||||
:deep(.ivu-table-cell) {
|
||||
.ivu-tooltip,
|
||||
.ivu-dropdown {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user