feat(task): 材料规则界面(M1-04 前端)—— 动态收集要求在界面上真的能配了
发布任务的 5 项前置检查里有一项是「至少配一项必传材料」(40018)。
在这之前,管理员配材料要求**只能直接改数据库** —— 而"能动态设置收集要求"
正是本产品区别于"发个 Excel 让学生填"的核心。界面上没有它,这个能力等于不存在。
(task/index.vue 里那句「请到『材料规则』里配置…」也一直指着一个不存在的界面。)
新增:
· apis/xt/material.ts —— 材料项接口 + 材料目录 + 枚举镜像
· views/task/TaskMaterialDrawer.vue —— 列表 + 新增/编辑/删除 + 按模板初始化
· task/index.vue 操作列加「材料规则」(草稿/收集中/已暂停三态),操作列 200→240px
四处刻意的设计:
1. 选材料类型**自动带出默认值**(规范名、别名、默认档位与份数)。
让人手填这些,一是慢,二是必然填不一致 —— 而"档位选错"的后果是
证件翻拍件被压糊、机构退回。实测:选「学信网备案表」自动带出
别名(学信网/学历备案表/电子注册备案表)+ P4 + 1 份。
2. **条件显示只给三选一**(所有学生/仅在校生/仅非在校生),不做通用规则构造器。
实际业务只有一种需求:学籍表与工作证明按是否在校生互斥。给一个能拼任意条件的
东西,用户拼出个永远为假的条件,表现是"学生那边根本不显示这一项",
而原因藏在一条没人在意的规则里。
3. **编辑时锁死材料码**:它是 collect_file 的关联键(两者之间没有外键),
改了码已上传的文件就成了孤儿 —— 静默丢材料。
4. **收集中改动显式警告**:服务端允许改,但学生会立刻看到新要求,
已传的材料可能因此出现新的问题项。
浏览器真点过:抽屉打开、模板初始化生成标准 4 项且**条件标签正确**
(学籍表=仅在校生、工作证明=仅非在校生)、新增的自动带默认值、保存成功。
typecheck 通过,改动文件 lint 0 错 0 警。
⭐ 顺带验证了一条完整链路:e2e-m3-submit.py 原先**写死了 IDCARD**,
配上标准 4 项之后全红 —— 那说明脚本不够健壮(材料要求本来就是可配置的)。
改成从表单接口读"当前学生看得见、且必传"的材料后:
当前必传且有条件成立的:['IDCARD', 'PHOTO1', 'SCHOOL_ROLL']
跳过(选传或条件不成立):['WORK_PROOF']
即:管理员配的「学籍表=仅在校生」→ 学生端正确隐藏工作证明 → 校验不误报缺失。
**教训:E2E 不要写死业务配置。写死的那一刻,测试就从"验证行为"变成了
"验证数据",配置一变全红 —— 看起来像代码坏了。**
又修一处文档缺陷:《03-接口设计》§2.7 的 taskStatus 把 2 与 4 写反了
(文档:2已暂停/3已截止/4已归档;代码与 DDL:2已截止/3已归档/4暂停)。
按文档实现会把「暂停」和「截止」做反,而这两者语义差别很大
(暂停可恢复、已提交照常审;截止不可逆)。
40018 的提示改为「请**点本行的**「材料规则」…」—— 顶层菜单里也有一个「材料规则」
(里程碑占位页),不点明"本行"会把人引到那个占位页去。
验收:e2e-m3-submit.py 31/31;
回归 材料目录 68 / 材料上传 29 / 草稿 27 / 门禁 29 全绿。
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 材料项定义接口(《03-接口设计》§7.10)。
|
||||
*
|
||||
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
|
||||
* 解包后 `http.get<T>()` 得到 `{ 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<string, unknown> | null
|
||||
fileTypes: string[]
|
||||
maxSizeKb: number
|
||||
minSizeKb: number | null
|
||||
compressProfile: string
|
||||
minLongSide: number | null
|
||||
minQuality: number | null
|
||||
targetDpi: number | null
|
||||
fixedSize: Record<string, unknown> | 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<string, unknown> | 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<MaterialDefVO[]>(`/tasks/${taskId}/materials`)
|
||||
}
|
||||
|
||||
export function addMaterial(taskId: number, data: MaterialDefSaveReq) {
|
||||
return http.post<number>(`/tasks/${taskId}/materials`, data)
|
||||
}
|
||||
|
||||
export function updateMaterial(id: number, data: MaterialDefSaveReq) {
|
||||
return http.put<void>(`/materials/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除。已有文件时服务端拒绝(40027),提示改「选传」或加条件显示把它藏起来。 */
|
||||
export function deleteMaterial(id: number) {
|
||||
return http.delete<void>(`/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<MaterialCatalog>('/material-catalog')
|
||||
}
|
||||
|
||||
// ── 枚举镜像 ────────────────────────────────────────
|
||||
|
||||
/** 压缩档位 → 文案。选档位时让人一眼看出"这是什么意思"。 */
|
||||
export const PROFILE_NAMES: Record<string, string> = {
|
||||
AUTO: '自动判定(按材料类型)',
|
||||
P1: 'P1 证件照(裁成固定尺寸)',
|
||||
P2: 'P2 证件翻拍(保清晰度,长边限 1600)',
|
||||
P3: 'P3 A4 扫描件(等价 300dpi)',
|
||||
P4: 'P4 截图(不缩放,保持 PNG)',
|
||||
P5: 'P5 兜底(只压体积)'
|
||||
}
|
||||
|
||||
/** OCR 类型 → 文案。 */
|
||||
export const OCR_TYPE_NAMES: Record<string, string> = {
|
||||
IDCARD: '身份证(读号码与姓名)',
|
||||
GENERAL: '通用文字(只读文字)',
|
||||
NONE: '不做识别'
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件显示的三种常见情形。
|
||||
*
|
||||
* 这里**只给三选一**,不做通用规则构造器 —— 因为实际业务里只有一种需求:
|
||||
* 「学籍表」与「工作证明」按是否在校生互斥(PRD 的经典场景)。
|
||||
* 给一个能拼任意 `{field, op, value}` 的构造器,用户拼出个永远为假的条件,
|
||||
* 表现是"这个材料学生那边根本不显示",而原因藏在一条没人在意的规则里。
|
||||
*
|
||||
* ⚠️ 形状必须与服务端一致:`{field, op, value}`,`op` 缺省即 `eq`。
|
||||
*/
|
||||
export interface VisibleWhenOption {
|
||||
label: string
|
||||
hint: string
|
||||
value: Record<string, unknown> | 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<string, unknown> | 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
|
||||
}
|
||||
Reference in New Issue
Block a user