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:
@@ -135,6 +135,42 @@ def upload(token: str, sid: int, code: str, filename: str, data: bytes) -> dict:
|
||||
return {"_http": e.code}
|
||||
|
||||
|
||||
def norm(v) -> str:
|
||||
s = '' if v is None else str(v).strip()
|
||||
if s.lower() == 'true':
|
||||
return '1'
|
||||
if s.lower() == 'false':
|
||||
return '0'
|
||||
return s
|
||||
|
||||
|
||||
def visible(when, values: dict) -> bool:
|
||||
"""
|
||||
条件可见求值。**必须与服务端 SubmissionValidator.visible 同口径** ——
|
||||
不一致会出现"学生看得见、后端说你没填"这类两边各自看都没错的问题。
|
||||
"""
|
||||
if not when:
|
||||
return True
|
||||
f = when.get('field')
|
||||
if not f:
|
||||
return True
|
||||
op = when.get('op')
|
||||
if op is not None and str(op) != 'eq':
|
||||
return True
|
||||
return norm(values.get(str(f))) == norm(when.get('value'))
|
||||
|
||||
|
||||
def form_values(token: str, task_id: int) -> tuple[dict, list]:
|
||||
"""读表单装配结果:字段当前值 + 材料项(含条件)。"""
|
||||
r = call("GET", f"/api/v1/mp/tasks/{task_id}/form", token)
|
||||
d = r.get("data") or {}
|
||||
values = {}
|
||||
for sec in d.get("sections") or []:
|
||||
for f in sec.get("fields") or []:
|
||||
values[f.get("key")] = f.get("value")
|
||||
return values, d.get("materials") or []
|
||||
|
||||
|
||||
def small_jpeg() -> bytes:
|
||||
from PIL import Image
|
||||
buf = io.BytesIO()
|
||||
@@ -245,16 +281,38 @@ def main() -> int:
|
||||
|
||||
# ── 4. 传材料 → VAL-05 消失 ──
|
||||
print("\n[4] 传上必传材料")
|
||||
# ⚠️ **不要写死材料码**。任务的材料要求是可配置的(管理员能在「材料规则」里
|
||||
# 加成一寸照片/学籍表/工作证明…),而"条件材料"还要看学生是不是在校生。
|
||||
# 写死 IDCARD 的脚本,在任务配置一变就全红 —— 而那看起来像代码坏了。
|
||||
# 这里从表单接口读"当前学生看得见、且必传"的材料,按它上传。
|
||||
img = small_jpeg()
|
||||
up = upload(tok, sid, "IDCARD", "身份证正面.jpg", img)
|
||||
if not check("上传成功", up.get("code") == 0, f"{up}"):
|
||||
return 1
|
||||
call("POST", f"/api/v1/mp/files/{up['data']['id']}/process", tok)
|
||||
# 同一张图再传一份(IDCARD 允许 2 份)→ 制造一条**可预期的黄色警告**,
|
||||
# 否则"无红无黄 → 直接提交成功",下一步那条"先确认再提交"的路径就成了没被测过的代码
|
||||
up2 = upload(tok, sid, "IDCARD", "身份证反面.jpg", img)
|
||||
if up2.get("code") == 0:
|
||||
call("POST", f"/api/v1/mp/files/{up2['data']['id']}/process", tok)
|
||||
values, mats = form_values(tok, task_id)
|
||||
need = [m for m in mats
|
||||
if m.get("required") and visible(m.get("visibleWhen"), values)]
|
||||
skipped = [m.get("materialCode") for m in mats if m not in need]
|
||||
print(f" 当前必传且有条件成立的:{ [m.get('materialCode') for m in need] }")
|
||||
if skipped:
|
||||
print(f" 跳过(选传或条件不成立):{skipped}")
|
||||
check("读到了必传材料清单(不是写死的一项)", len(need) >= 1, f"{need}")
|
||||
check("条件材料被正确排除(在校生不该被要求交工作证明)",
|
||||
all((m.get("visibleWhen") or {}).get("value") is not False for m in need),
|
||||
f"必传里混进了非在校生的材料: {[m.get('materialCode') for m in need]}")
|
||||
|
||||
for i, m in enumerate(need):
|
||||
code = m.get("materialCode")
|
||||
up = upload(tok, sid, code, f"{m.get('materialName') or code}.jpg", img)
|
||||
if up.get("code") != 0:
|
||||
fail(f"上传 {code}", f"{up}")
|
||||
return 1
|
||||
call("POST", f"/api/v1/mp/files/{up['data']['id']}/process", tok)
|
||||
# 第一项再传一份同一张图 → 制造一条**可预期的黄色警告**(VAL-09),
|
||||
# 否则"无红无黄 → 直接提交成功",下面那条"先确认再提交"的路径就成了没被测过的代码
|
||||
if i == 0:
|
||||
up2 = upload(tok, sid, code, f"{m.get('materialName') or code}-重复.jpg", img)
|
||||
if up2.get("code") == 0:
|
||||
call("POST", f"/api/v1/mp/files/{up2['data']['id']}/process", tok)
|
||||
else:
|
||||
print(f" ({code} 不支持第二份,跳过重复件造数:{up2.get('msg')})")
|
||||
r = sub(tok, sid)
|
||||
rs = rules(r)
|
||||
check("VAL-05 消失(材料齐了)", "VAL-05" not in rs, f"实际: {sorted(rs)}")
|
||||
|
||||
Reference in New Issue
Block a user