t13:前端换形——audience 数组多选 + 冻结/匿名窗口开关,fillable 行去 canFill/myTier

- types/form.ts:AUDIENCE 改字符串码闭集(含 ANONYMOUS)+ audienceText();
  FormView/FormConfigRequest 加 audience 数组、frozen、anonCookieMinutes
- types/data.ts:FillableForm 去 canFill/myTier(C2,入集即可填);
  DATA_SCOPE_LABELS 由四枚举改两档(ALL/SELF,§19 作废-1)
- FormConfigModal:面向人群改复选组(多选共集)+ 冻结开关(文案指路"喂外部业务应用的表单请开启")
  + 匿名防重窗口;含 ANONYMOUS 时限次置灰并在提交前挡(与后端同判据)
- Forms.vue 面向列、My.vue「可填写」卡集、fill.ts/routes.ts 注记同步换形
- AppContext.vue:填报 tab 直接吃入集行;数据/统计两档的驱动位改由 T14 的 managed 端点接,
  本批不留"按 myTier 猜"的兼容分支(壳零自有权限逻辑)
- mock:formapi/dataapi 的 audience 数组形态 + 匿名×限次跨字段约束
This commit is contained in:
chenxing
2026-10-20 12:54:26 +08:00
parent 25dc3eb2e5
commit 4a7db3d22c
11 changed files with 174 additions and 88 deletions
+16 -17
View File
@@ -206,7 +206,7 @@ export default [
appCode: queryValue(query, "appCode") || APP,
name: "三好学生申报",
version: 2,
audience: 1,
audience: ["STUDENT"],
fillLimit: 0,
answerNoEnabled: 0,
deadline: null,
@@ -317,27 +317,26 @@ export default [
return ok(results);
} },
// ==================== 可填写入口清单(P95 W3/W4,/my/fillable) ====================
// ==================== 可填写入口清单(契约 v2 C2,/my/fillable) ====================
/**
* 三行种子刻意覆盖三层模型的可观测差异(冒烟脚本 p95-fill-scope-smoke.sh 就断言这三件事):
* ① FILL:学生面向、canFill=true → 「可填写」画卡;
* ② MANAGE:学生面向、canFill=false(教师那种"只能代填")→ 「可填写」<b>不</b>画卡,
* 但快应用上下文的数据管理 tab 要靠它出现;
* ③ ADMIN:教师面向、canFill=true + 最高层 → 统计 tab 只由它决定。
* 行形状按 C2:无 canFill/myTier,`audience` 是数组。<b>入集即等价于可填</b>
* (后端 audience 门筛过一次,不再有"进了列表但不能自填、只给代填"的中间态)。
* 种子刻意覆盖三种人群形态:
* ① 纯学生面向;② 学生+教师共集(C1 多值,一张表两类人都能填);③ 纯教师面向。
* appCode 传值时按该应用收窄(缺省=全部应用),与后端 FillableFormService 同一口径。
*/
{ url: "/api/form/my/fillable", method: "get", response: ({ query }: any) => {
const rows: FillableForm[] = [
{ code: "honor_apply", name: "三好学生申报", appCode: APP, audience: 1,
deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null, canFill: true, myTier: "FILL" },
{ code: "home_visit", name: "家访登记(教师代填)", appCode: APP, audience: 1,
deadline: null, fillLimit: 0, groupTag: "五年级", canFill: false, myTier: "MANAGE" },
{ code: "teacher_survey", name: "教师问卷", appCode: APP, audience: 2,
deadline: "2026-11-30 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "ADMIN" },
{ code: "drill-honour", name: "荣誉申报(演练应用)", appCode: "fc-drill", audience: 1,
deadline: "2026-10-31 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "FILL" },
{ code: "drill_roster", name: "花名册(演练应用,教师代填)", appCode: "fc-drill", audience: 1,
deadline: null, fillLimit: 0, groupTag: null, canFill: false, myTier: "MANAGE" }
{ code: "honor_apply", name: "三好学生申报", appCode: APP, audience: ["STUDENT"],
deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null },
{ code: "home_visit", name: "家访登记(师生共集)", appCode: APP, audience: ["STUDENT", "TEACHER"],
deadline: null, fillLimit: 0, groupTag: "五年级" },
{ code: "teacher_survey", name: "教师问卷", appCode: APP, audience: ["TEACHER"],
deadline: "2026-11-30 23:59:59", fillLimit: 0, groupTag: null },
{ code: "drill-honour", name: "荣誉申报(演练应用)", appCode: "fc-drill", audience: ["STUDENT"],
deadline: "2026-10-31 23:59:59", fillLimit: 0, groupTag: null },
{ code: "drill_roster", name: "花名册(演练应用,师生共集)", appCode: "fc-drill",
audience: ["STUDENT", "TEACHER"], deadline: null, fillLimit: 0, groupTag: null }
];
// 排序沿后端口径:deadline 升序、不限截止在后、同档按 code
const app = String(query?.appCode || "").trim();
+29 -5
View File
@@ -95,10 +95,12 @@ const forms: MockForm[] = [
statusName: "PUBLISHED",
currentVersion: 1,
draftVersion: 2,
audience: AUDIENCE.STUDENT,
audience: [AUDIENCE.STUDENT],
fillLimit: 1,
deadline: null,
answerNoEnabled: 0,
frozen: 0,
anonCookieMinutes: 10,
groupTag: null,
statusDefs: DEFAULT_STATUS_DEFS,
createTime: now(),
@@ -161,10 +163,12 @@ function view(f: MockForm): FormView {
statusName: f.statusName,
currentVersion: f.currentVersion,
draftVersion: f.draftVersion,
audience: f.audience,
audience: [...f.audience],
fillLimit: f.fillLimit,
deadline: f.deadline,
answerNoEnabled: f.answerNoEnabled,
frozen: f.frozen,
anonCookieMinutes: f.anonCookieMinutes,
groupTag: f.groupTag,
statusDefs: f.statusDefs ? JSON.parse(JSON.stringify(f.statusDefs)) : null,
createTime: f.createTime,
@@ -332,10 +336,12 @@ export default [
statusName: "DRAFT",
currentVersion: null,
draftVersion: 1,
audience: AUDIENCE.STUDENT,
audience: [AUDIENCE.STUDENT],
fillLimit: 0,
deadline: null,
answerNoEnabled: 0,
frozen: 0,
anonCookieMinutes: 10,
groupTag: null,
statusDefs: DEFAULT_STATUS_DEFS,
createTime: now(),
@@ -443,13 +449,31 @@ export default [
const f = findForm(body.uuid);
if (!f) return bizErr(2, "表单未找到");
if (body.audience !== undefined) {
if (body.audience !== AUDIENCE.STUDENT && body.audience !== AUDIENCE.TEACHER) return bizErr(2, "audience 只能是 1 或 2");
f.audience = body.audience;
const codes = Array.isArray(body.audience) ? body.audience : [];
if (!codes.length) return bizErr(2, "audience 不能是空数组");
for (const c of codes) {
if (c !== AUDIENCE.STUDENT && c !== AUDIENCE.TEACHER && c !== AUDIENCE.ANONYMOUS)
return bizErr(2, "audience 只能是 STUDENT/TEACHER/ANONYMOUS");
}
f.audience = codes;
}
if (body.fillLimit !== undefined) {
if (body.fillLimit !== 0 && body.fillLimit !== 1) return bizErr(2, "fillLimit 只能是 0 或 1");
f.fillLimit = body.fillLimit;
}
if (body.frozen !== undefined) {
if (body.frozen !== 0 && body.frozen !== 1) return bizErr(2, "frozen 只能是 0 或 1");
f.frozen = body.frozen;
}
if (body.anonCookieMinutes !== undefined) {
const minutes = Number(body.anonCookieMinutes);
if (!(minutes >= 1 && minutes <= 1440)) return bizErr(2, "anonCookieMinutes 需在 1~1440 之间");
f.anonCookieMinutes = minutes;
}
// 与后端同一条跨字段约束(§3.2/作废-11):匿名表单无 limit_key,限次只能关着
if (f.audience.includes(AUDIENCE.ANONYMOUS) && f.fillLimit === 1) {
return bizErr(5, "面向人群含匿名时不支持限次:fillLimit 必须为 0");
}
if (body.answerNoEnabled !== undefined) {
if (body.answerNoEnabled !== 0 && body.answerNoEnabled !== 1) return bizErr(2, "answerNoEnabled 只能是 0 或 1");
if (body.answerNoEnabled === 1 && f.code.length > 49) return bizErr(2, "开启报名号要求 code ≤49 位");