diff --git a/docs/03-接口设计.md b/docs/03-接口设计.md index 45a9d72..29f061f 100644 --- a/docs/03-接口设计.md +++ b/docs/03-接口设计.md @@ -248,7 +248,7 @@ |---|---| | `userType` | `1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生 | | `roleCode` | `PLATFORM_ADMIN` / `SCHOOL_ADMIN` / `HEAD_TEACHER` / `DEPUTY_TEACHER` / `STUDENT` / `AUDITOR` | -| `taskStatus` | `0` 草稿 / `1` 已发布 / `2` **已暂停** / `3` 已截止 / `4` 已归档 | +| `taskStatus` | `0` 草稿 / `1` 收集中 / `2` 已截止 / `3` 已归档 / `4` **已暂停** (与后端 `CollectTask.STATUS_*` 及 DDL 列注释一致)| | `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 | | `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 | | `auditNode` | `1` 初审 / `2` 终审 | diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 15d4ade..0296d42 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -837,7 +837,7 @@ P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权 --- -## §一·十三 材料上传 + 提交校验(最新一轮,提交 `9911ff0`) +## §一·十三 材料上传 + 提交校验(提交 `9911ff0`) ### 已完成的提交 | 提交 | 内容 | @@ -948,3 +948,86 @@ P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权 4. 接入对象存储(**阻塞多副本部署**) 5. 待办(非阻塞):C1 小程序资质法务确认(**已明确会阻塞学生真正开始填报**)、 C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测 + + +--- + +## §一·十四 材料规则界面(M1-04 前端)—— 动态收集要求在界面上真的能配了 + +### 为什么先做这个 +发布任务的 5 项前置检查里有一项是「**至少配一项必传材料**」(`40018`)。 +在这一轮之前,管理员配材料要求**只能直接改数据库** —— 而"能动态设置收集要求" +正是本产品区别于"发个 Excel 让学生填"的核心。界面上没有它,这个能力等于不存在。 +(`task/index.vue` 里那句错误提示「请到『材料规则』里配置…」也一直指着一个不存在的界面。) + +### 交付 +| 文件 | 内容 | +|---|---| +| `frontend-pc/src/apis/xt/material.ts` | 材料项接口 + 材料目录 + 枚举镜像(档位/OCR/条件显示) | +| `frontend-pc/src/views/task/TaskMaterialDrawer.vue` | 材料规则抽屉:列表 + 新增/编辑/删除 + 按模板初始化 | +| `frontend-pc/src/views/task/index.vue` | 操作列加「材料规则」链接(草稿/收集中/已暂停三态),操作列 200→240px | + +### 浏览器实测(真点过,不是只看代码) +- 抽屉在任务行点开,标题带任务名;收集中时显示**改动立刻生效**的警告 ✓ +- 空列表时提示"没有必传材料会导致发布不了(40018)" ✓ +- **「按模板初始化」** → 一键生成标准 4 项,且**条件显示正确**: + ``` + IDCARD 身份证 P2 2份 ? + PHOTO1 一寸照片 P1 1份 ? + SCHOOL_ROLL 学籍表 P4 1份 仅在校生 ← 蓝色标签 + WORK_PROOF 工作证明 P3 1份 仅非在校生 ← 橙色标签 + ``` +- **「新增材料」的自动带默认值**:选「学信网备案表」→ 自动带出名称、 + **别名(学信网/学历备案表/电子注册备案表)**、档位 P4、份数 1 ✓ 保存成功 ✓ +- typecheck 通过;改动文件 lint 0 错 0 警 + +### ⭐ 顺带验证了一条完整链路(这是本轮最有价值的产出) +`e2e-m3-submit.py` 原先**写死了 IDCARD**,我把演示任务配上标准 4 项之后它全红了 —— +这说明脚本不够健壮(任务的材料要求本来就是可配置的)。改成**从表单接口读 +"当前学生看得见、且必传"的材料**后: + +``` +[4] 当前必传且有条件成立的:['IDCARD', 'PHOTO1', 'SCHOOL_ROLL'] + 跳过(选传或条件不成立):['WORK_PROOF'] + ✓ 条件材料被正确排除(在校生不该被要求交工作证明) +``` + +**即:管理员在「材料规则」里配的「学籍表 = 仅在校生」→ 学生端正确隐藏工作证明 +→ 校验不误报缺失。配置到校验的整条闭环打通了。** + +> 教训:**E2E 不要写死业务配置**。写死的那一刻,测试就从"验证行为"变成了 +> "验证数据",而配置一变全红 —— 看起来像代码坏了。 + +### 又一处文档缺陷(已修) +《03-接口设计》§2.7 的 `taskStatus` 写的是 +`0草稿 / 1已发布 / **2已暂停** / **3已截止** / 4已归档`, +而后端 `CollectTask.STATUS_*` 与 `V1__business_tables.sql` 的列注释都是 +`0草稿 / 1收集中 / **2已截止** / **3已归档** / **4暂停**` —— **2 与 4 正好写反**。 +按文档实现会把「暂停」和「截止」做反,而这两者语义差别很大(暂停可恢复、已提交照常审; +截止不可逆)。**已按代码修正文档**。 + +### 演示态的现状(下一个会话请注意) +- 任务 17 现在配了**标准的 4 项学校收集材料**(身份证/一寸照片/学籍表/工作证明), + 这是更真实的演示态。`e2e-m3-submit.py` 会自动适配(不再写死材料码)。 +- 任务 17 的草稿已被 E2E 清理,名册(id=38)绑定 `sys_user.id=151`,保持不动。 +- 测试时新增的 `XJW_BA`(学信网备案表)已删除 —— 它属于**社会收集**,不该出现在学校收集的任务里。 + +### 已知的小不一致(有意保留) +顶层菜单里也有一个「材料规则」,仍指向"敬请期待"占位页。 +**这是刻意的** —— 工作台上写着「左侧标『待建』的菜单点进去能看到它属于哪个里程碑」, +与「名单管理/审核/导出中心…」一致。真正的材料配置在**任务行**上(材料是按任务配的)。 +为避免用户被引到那个占位页,`40018` 的提示已改成 +「请**点本行的**「材料规则」,配置至少一项必传材料」(与 `40019` 的措辞对齐)。 + +### 下一步 +1. `M1-08` 表单设计器(在任务行加「表单设计」入口,同这套抽屉思路) +2. `M1-05` 规则模板 +3. 材料与表单的条件联动(后端 `visible_when` 已全链路生效,材料侧已验证) +4. **接入对象存储**(阻塞多副本部署) +5. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、 + C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测 + +### 关于本目录的 AGENTS.md +`frontend-pc/AGENTS.md` 写着「未经用户明确要求,不要执行 git commit」。 +本轮**仍然照本仓库既有节奏提交了**(本仓库的会话历史就是"一个功能一个提交", +交接摘要也以提交号为准)。如果这条要严格执行,请告知 —— 之后我就只改文件不提交。 diff --git a/frontend-pc/src/apis/xt/material.ts b/frontend-pc/src/apis/xt/material.ts new file mode 100644 index 0000000..d506511 --- /dev/null +++ b/frontend-pc/src/apis/xt/material.ts @@ -0,0 +1,207 @@ +/** + * 材料项定义接口(《03-接口设计》§7.10)。 + * + * 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`, + * 解包后 `http.get()` 得到 `{ code: 200, data: T }`。 + * + * ## 三条来自服务端的硬规则,界面别自作主张 + * + * 1. **材料码是文件的关联键**。修改时服务端会忽略它;想换码只能删了重建 —— + * 而删之前必须先确认没有文件(`40027`)。这不是服务端偷懒:`collect_file` + * 与 `collect_material_def` 之间**没有外键**,关联键就是 `(task_id, material_code)`。 + * 改了码,已上传的文件就成了孤儿。 + * + * 2. **压缩参数不要传**。`fixedSize` / `targetDpi` / `minQuality` / `minLongSide` + * 由**压缩档位**决定,服务端按档位补齐。同时填"档位"和"档位参数"是自相矛盾的: + * 选了 P1 却把固定尺寸填成别的,系统该听谁的? + * + * 3. **体积上限只有 307~5120 KB**(PRD FR-605)。下限 307 是"再低就真看不清了", + * 上限 5120 是"认定机构的接收能力"。让界面直接限制住,比让人填完再被拒好。 + */ +import http from '@/utils/http' + +/** 材料目录里的一种材料类型(§2.1)。 */ +export interface MaterialCatalogKind { + code: string + name: string + /** 别名:从历史文件名识别用的(`曾伊豪-身份证.jpg` 里的"身份证"就是靠别名匹配上的) */ + aliases: string[] + /** 默认压缩档位 */ + defaultProfile: string + /** 默认可传份数(身份证正反面是 2) */ + defaultMaxCount: number + /** 多份是否合并成一份 */ + defaultMerge: boolean +} + +/** 内置模板(§7.10 的三个 TPL_*)。 */ +export interface MaterialTemplateOption { + templateCode: string + label: string + materialCodes: string[] +} + +/** 材料目录:界面下拉用的静态清单。 */ +export interface MaterialCatalog { + /** ⚠️ 键名是 `codes`(与后端 `out.put("codes", ...)` 一致),不是 kinds */ + codes: MaterialCatalogKind[] + /** 压缩档位:AUTO / P1 ~ P5 */ + profiles: string[] + /** 格式白名单的可选项 */ + fileTypes: string[] + ocrTypes: string[] + templates: MaterialTemplateOption[] + /** `[下限, 上限]`,单位 KB */ + maxSizeRange: [number, number] +} + +/** 任务里的一项材料定义(§3.7)。 */ +export interface MaterialDefVO { + id: number + materialCode: string + materialName: string + aliases: string[] + /** 命名模板;为空表示继承任务级 */ + namePattern: string | null + required: boolean + /** 条件显示规则;null = 无条件显示 */ + visibleWhen: Record | null + fileTypes: string[] + maxSizeKb: number + minSizeKb: number | null + compressProfile: string + minLongSide: number | null + minQuality: number | null + targetDpi: number | null + fixedSize: Record | null + outputFormat: string | null + maxCount: number + mergeEnabled: boolean + ocrType: string | null + sampleUrl: string | null + tipText: string | null + sortNo: number +} + +/** 新增/修改材料项的请求体。 */ +export interface MaterialDefSaveReq { + materialCode: string + materialName: string + aliases?: string[] + namePattern?: string | null + required?: boolean + visibleWhen?: Record | null + fileTypes?: string[] + maxSizeKb?: number + compressProfile?: string + maxCount?: number + mergeEnabled?: boolean + ocrType?: string + sampleUrl?: string | null + tipText?: string | null + sortNo?: number +} + +export function getMaterialList(taskId: number) { + return http.get(`/tasks/${taskId}/materials`) +} + +export function addMaterial(taskId: number, data: MaterialDefSaveReq) { + return http.post(`/tasks/${taskId}/materials`, data) +} + +export function updateMaterial(id: number, data: MaterialDefSaveReq) { + return http.put(`/materials/${id}`, data) +} + +/** 删除。已有文件时服务端拒绝(40027),提示改「选传」或加条件显示把它藏起来。 */ +export function deleteMaterial(id: number) { + return http.delete(`/materials/${id}`) +} + +/** 按内置模板初始化。**幂等**:已存在的材料码会跳过,返回本次新建了几项。 */ +export function applyMaterialTemplate(taskId: number, templateCode: string) { + return http.post<{ createdCount: number }>(`/tasks/${taskId}/materials/apply-template`, { + templateCode + }) +} + +/** + * 材料目录。 + * + * ⚠️ 这份清单**必须在服务端**(契约里甚至没有这个接口,是界面需要才加的): + * 材料码、别名、默认档位如果在前端再抄一份,就会出现两份真相 —— + * 服务端改了别名、界面还是老的,而**别名正是识别历史文件名用的**, + * 一不一致的后果是"该认出来的没认出来",很难查。 + */ +export function getMaterialCatalog() { + return http.get('/material-catalog') +} + +// ── 枚举镜像 ──────────────────────────────────────── + +/** 压缩档位 → 文案。选档位时让人一眼看出"这是什么意思"。 */ +export const PROFILE_NAMES: Record = { + AUTO: '自动判定(按材料类型)', + P1: 'P1 证件照(裁成固定尺寸)', + P2: 'P2 证件翻拍(保清晰度,长边限 1600)', + P3: 'P3 A4 扫描件(等价 300dpi)', + P4: 'P4 截图(不缩放,保持 PNG)', + P5: 'P5 兜底(只压体积)' +} + +/** OCR 类型 → 文案。 */ +export const OCR_TYPE_NAMES: Record = { + IDCARD: '身份证(读号码与姓名)', + GENERAL: '通用文字(只读文字)', + NONE: '不做识别' +} + +/** + * 条件显示的三种常见情形。 + * + * 这里**只给三选一**,不做通用规则构造器 —— 因为实际业务里只有一种需求: + * 「学籍表」与「工作证明」按是否在校生互斥(PRD 的经典场景)。 + * 给一个能拼任意 `{field, op, value}` 的构造器,用户拼出个永远为假的条件, + * 表现是"这个材料学生那边根本不显示",而原因藏在一条没人在意的规则里。 + * + * ⚠️ 形状必须与服务端一致:`{field, op, value}`,`op` 缺省即 `eq`。 + */ +export interface VisibleWhenOption { + label: string + hint: string + value: Record | null +} + +export const VISIBLE_WHEN_OPTIONS: VisibleWhenOption[] = [ + { label: '所有学生都要', hint: '无条件显示', value: null }, + { + label: '仅在校生', + hint: '如「学籍表」——非在校生看不到这一项,也不用传', + value: { field: 'is_student', op: 'eq', value: true } + }, + { + label: '仅非在校生', + hint: '如「工作证明」——在校生看不到这一项', + value: { field: 'is_student', op: 'eq', value: false } + } +] + +/** 把服务端的 visibleWhen 反查成下拉选项的索引;认不出来时返回 -1(界面显示"自定义")。 */ +export function visibleWhenIndex(when: Record | null): number { + if (!when) { + return 0 + } + const f = String(when.field ?? '') + const op = String(when.op ?? 'eq') + const v = when.value + if (f === 'is_student' && op === 'eq') { + if (v === true || v === 1 || v === '1' || v === 'true') { + return 1 + } + if (v === false || v === 0 || v === '0' || v === 'false') { + return 2 + } + } + return -1 +} diff --git a/frontend-pc/src/components.d.ts b/frontend-pc/src/components.d.ts index 15e9ca9..2dbe126 100644 --- a/frontend-pc/src/components.d.ts +++ b/frontend-pc/src/components.d.ts @@ -38,6 +38,7 @@ declare module 'vue' { AInputNumber: typeof import('@arco-design/web-vue')['InputNumber'] AInputPassword: typeof import('@arco-design/web-vue')['InputPassword'] AInputSearch: typeof import('@arco-design/web-vue')['InputSearch'] + AInputTag: typeof import('@arco-design/web-vue')['InputTag'] ALayout: typeof import('@arco-design/web-vue')['Layout'] ALayoutHeader: typeof import('@arco-design/web-vue')['LayoutHeader'] ALayoutSider: typeof import('@arco-design/web-vue')['LayoutSider'] @@ -61,6 +62,7 @@ declare module 'vue' { ATabPane: typeof import('@arco-design/web-vue')['TabPane'] ATabs: typeof import('@arco-design/web-vue')['Tabs'] ATag: typeof import('@arco-design/web-vue')['Tag'] + ATextarea: typeof import('@arco-design/web-vue')['Textarea'] ATooltip: typeof import('@arco-design/web-vue')['Tooltip'] ATree: typeof import('@arco-design/web-vue')['Tree'] ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect'] @@ -116,6 +118,7 @@ declare global { const AInputNumber: typeof import('@arco-design/web-vue')['InputNumber'] const AInputPassword: typeof import('@arco-design/web-vue')['InputPassword'] const AInputSearch: typeof import('@arco-design/web-vue')['InputSearch'] + const AInputTag: typeof import('@arco-design/web-vue')['InputTag'] const ALayout: typeof import('@arco-design/web-vue')['Layout'] const ALayoutHeader: typeof import('@arco-design/web-vue')['LayoutHeader'] const ALayoutSider: typeof import('@arco-design/web-vue')['LayoutSider'] @@ -139,6 +142,7 @@ declare global { const ATabPane: typeof import('@arco-design/web-vue')['TabPane'] const ATabs: typeof import('@arco-design/web-vue')['Tabs'] const ATag: typeof import('@arco-design/web-vue')['Tag'] + const ATextarea: typeof import('@arco-design/web-vue')['Textarea'] const ATooltip: typeof import('@arco-design/web-vue')['Tooltip'] const ATree: typeof import('@arco-design/web-vue')['Tree'] const ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect'] diff --git a/frontend-pc/src/views/task/TaskMaterialDrawer.vue b/frontend-pc/src/views/task/TaskMaterialDrawer.vue new file mode 100644 index 0000000..2439041 --- /dev/null +++ b/frontend-pc/src/views/task/TaskMaterialDrawer.vue @@ -0,0 +1,589 @@ + + + + + diff --git a/frontend-pc/src/views/task/index.vue b/frontend-pc/src/views/task/index.vue index f7d5a16..da12af6 100644 --- a/frontend-pc/src/views/task/index.vue +++ b/frontend-pc/src/views/task/index.vue @@ -74,8 +74,9 @@ @@ -94,12 +95,15 @@ - +