From 4a7db3d22c6d6db4e2719371c5e8dbc2c5588daa Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 20 Oct 2026 12:54:26 +0800 Subject: [PATCH] =?UTF-8?q?t13:=E5=89=8D=E7=AB=AF=E6=8D=A2=E5=BD=A2?= =?UTF-8?q?=E2=80=94=E2=80=94audience=20=E6=95=B0=E7=BB=84=E5=A4=9A?= =?UTF-8?q?=E9=80=89=20+=20=E5=86=BB=E7=BB=93/=E5=8C=BF=E5=90=8D=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=BC=80=E5=85=B3=EF=BC=8Cfillable=20=E8=A1=8C?= =?UTF-8?q?=E5=8E=BB=20canFill/myTier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 数组形态 + 匿名×限次跨字段约束 --- src/api/module/fill.ts | 7 +-- src/assets/mock/dataapi.ts | 33 +++++++------ src/assets/mock/formapi.ts | 34 +++++++++++-- src/components/form/FormConfigModal.vue | 63 ++++++++++++++++++++----- src/router/routes.ts | 5 +- src/types/answer.ts | 3 +- src/types/data.ts | 22 ++++----- src/types/form.ts | 44 ++++++++++++++--- src/views/AppContext.vue | 25 +++++----- src/views/My.vue | 19 ++++---- src/views/admin/Forms.vue | 7 +-- 11 files changed, 174 insertions(+), 88 deletions(-) diff --git a/src/api/module/fill.ts b/src/api/module/fill.ts index 5cc4a06..308db09 100644 --- a/src/api/module/fill.ts +++ b/src/api/module/fill.ts @@ -56,11 +56,12 @@ export function getMyAnswerDetail(answerId: string) { return alovaInstance.Get("/api/form/my/detail", { params: { answerId } }).send(); } -// ==================== 可填写列表(P95 W3/W4,/my/fillable) ==================== -// 数据源=后端按 P95 三层模型解析的**填报入口**清单,不再是会话菜单树里挑 /f/ 行(P91 的形态)。 +// ==================== 可填写列表(契约 v2 C2,/my/fillable) ==================== +// 数据源=后端按 **audience 门**(会话身份类型 ∈ 表单面向人群)解析的填报入口清单, +// 不再是会话菜单树里挑 /f/ 行(P91 的形态),也不再有"入口开了但不能自填"的中间态。 // appCode 可选:缺省=全部应用(中心 + 快应用),快应用上下文传自身 appCode 收窄。 -/** 我可填的表单(入口层非 NONE 的都下发;本人能不能填看 canFill) */ +/** 我可填的表单(入集即等价于可填;资格判定在后端,与提交时同一个公式) */ export function listFillableForms(appCode?: string) { const params: Record = {}; if (appCode) params.appCode = appCode; diff --git a/src/assets/mock/dataapi.ts b/src/assets/mock/dataapi.ts index 7fbf923..9343238 100644 --- a/src/assets/mock/dataapi.ts +++ b/src/assets/mock/dataapi.ts @@ -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(教师那种"只能代填")→ 「可填写」不画卡, - * 但快应用上下文的数据管理 tab 要靠它出现; - * ③ ADMIN:教师面向、canFill=true + 最高层 → 统计 tab 只由它决定。 + * 行形状按 C2:无 canFill/myTier,`audience` 是数组。入集即等价于可填 + * (后端 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(); diff --git a/src/assets/mock/formapi.ts b/src/assets/mock/formapi.ts index d6fc14b..c2e47b5 100644 --- a/src/assets/mock/formapi.ts +++ b/src/assets/mock/formapi.ts @@ -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 位"); diff --git a/src/components/form/FormConfigModal.vue b/src/components/form/FormConfigModal.vue index 5093dae..90d155b 100644 --- a/src/components/form/FormConfigModal.vue +++ b/src/components/form/FormConfigModal.vue @@ -13,8 +13,8 @@ import { computed, reactive, ref, watch } from "vue"; import { Message, Modal } from "view-ui-plus"; import { formApi } from "@/api/module/form"; -import type { FormView, StatusDefView } from "@/types/form"; -import { AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form"; +import type { AudienceCode, FormView, StatusDefView } from "@/types/form"; +import { AUDIENCE, AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form"; import { MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema"; const props = defineProps<{ form: FormView | null }>(); @@ -23,30 +23,39 @@ const emit = defineEmits<{ (e: "saved", form: FormView): void }>(); const visible = ref(false); const saving = ref(false); const model = reactive<{ - audience: number; + audience: string[]; fillLimit: number; deadline: string; answerNoEnabled: number; + frozen: number; + anonCookieMinutes: number; groupTag: string; }>({ - audience: AUDIENCE_OPTIONS[0].value, + audience: [AUDIENCE.STUDENT], fillLimit: 0, deadline: "", answerNoEnabled: 0, + frozen: 0, + anonCookieMinutes: 10, groupTag: "" }); const statusDefs = ref([]); const newStatus = reactive({ code: "", label: "" }); +/** 含匿名时后端强制 fill_limit=0(§3.2/作废-11:匿名无 limit_key)——前端同判据提前挡,不给点了才报错 */ +const anonymousChosen = computed(() => model.audience.includes(AUDIENCE.ANONYMOUS)); + watch( () => [visible.value, props.form?.formUuid] as const, () => { const f = props.form; if (!visible.value || !f) return; - model.audience = f.audience ?? AUDIENCE_OPTIONS[0].value; + model.audience = f.audience?.length ? [...f.audience] : [AUDIENCE.STUDENT]; model.fillLimit = f.fillLimit ?? 0; model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : ""; model.answerNoEnabled = f.answerNoEnabled ?? 0; + model.frozen = f.frozen ?? 0; + model.anonCookieMinutes = f.anonCookieMinutes ?? 10; model.groupTag = f.groupTag || ""; statusDefs.value = (f.statusDefs || []).map((d) => ({ ...d })); newStatus.code = ""; @@ -110,15 +119,25 @@ async function submit() { Message.error(`开启报名号要求 code ≤49 位(当前 ${f.code.length})`); return; } + if (!model.audience.length) { + Message.error("面向人群至少要选一项(空集合等于谁都不能填)"); + return; + } + if (anonymousChosen.value && model.fillLimit === 1) { + Message.error("含「匿名可填」的表单不支持限次(匿名没有可比对的身份),请把限次改回不限"); + return; + } saving.value = true; try { await formApi .config({ uuid: f.formUuid, - audience: model.audience, + audience: model.audience as AudienceCode[], fillLimit: model.fillLimit, deadline: model.deadline, answerNoEnabled: model.answerNoEnabled, + frozen: model.frozen, + anonCookieMinutes: model.anonCookieMinutes, groupTag: model.groupTag }) .send(); @@ -184,21 +203,24 @@ defineExpose({ open });
面向人群 - - {{ a.label }} - - 决定数据范围矩阵落在哪一列(契约 §8) + + {{ a.label }} + + + 可多选(契约 v2 C1)。填报资格=登录身份类型落在集合内;「匿名可填」只对无会话的 + /anon 通道生效,登录用户不会因此多出入口 +
限次 不限次数 - 每人限填一次 + 每人限填一次 - 「每人」按提交主体判:管理员代填指定了被代填人时按那个人限,否则按提交人限; - 软删答卷会释放名额(契约 §5) + 「每人」按提交主体判:管理员代录指定了被记录人时按那个人限,否则按提交人限;软删答卷会释放名额。 +
@@ -217,6 +239,21 @@ defineExpose({ open }); +
+ 提交后禁编辑 + + + 开启后提交人与管理员的编辑通道同时关闭,修正只能「撤回(软删)+ 重新提交」—— + 喂外部业务应用的表单请开启(原件是档案,业务对象归应用) + +
+ +
+ 匿名防重窗口 + + 分钟。仅对面向人群含「匿名可填」的表单有意义(默认 10 分钟) +
+
分组标签 diff --git a/src/router/routes.ts b/src/router/routes.ts index 7a874c6..ed93c35 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -130,8 +130,9 @@ const routes: RouteRecordRaw[] = [ meta: { title: "关于我的", parentPath: "/my" }, }, { - // P91 起第三视图;P95 W4 换数据源=GET /api/form/my/fillable(中心按三层模型解析的填报入口, - // 不再从会话菜单树挑 /f/ 行)。子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。 + // P91 起第三视图;P95 W4 换数据源=GET /api/form/my/fillable,契约 v2 C2 定行形状 + // (中心按 audience 门解析填报入口,不再从会话菜单树挑 /f/ 行)。 + // 子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。 path: "/my/fillable", name: "fc_my_fillable", component: () => import("@/views/My.vue"), diff --git a/src/types/answer.ts b/src/types/answer.ts index b32c63a..e00a4cc 100644 --- a/src/types/answer.ts +++ b/src/types/answer.ts @@ -18,7 +18,8 @@ export interface ResolvedForm { appCode: string; name: string; version: number; - audience: number; + /** 面向人群(契约 v2 C1 数组形态;填报页只用于展示与预提示,资格判定在后端 audience 门) */ + audience: string[]; fillLimit: number; answerNoEnabled: number; deadline: string | null; diff --git a/src/types/data.ts b/src/types/data.ts index 936b343..6dfab06 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -127,35 +127,29 @@ export interface MyAnswerViewItem { } /** - * 「可填写」列表的行(P95 W3/W4,`GET /api/form/my/fillable`)。 + * 「可填写」列表的行(契约 v2 C2,`GET /api/form/my/fillable`)。 * - *

这不是答卷行,是**表单入口**行:后端按 P95 三层模型逐张表算出"这个人在这张表上算哪一层", - * NONE 的行不下发。`canFill` 与 `myTier` 是两件事——`canFill=false` 且 `myTier="MANAGE"` - * 就是"教师×面向学生"那种只能代填的表:「可填写」列表把它筛掉,但快应用上下文的 - * 数据管理 tab 仍要它(AppContext.vue 按 myTier 决定 tab 显隐)。

+ *

这不是答卷行,是**表单入口**行:后端按 audience 门逐张表判"这个人的身份类型在不在面向人群里", + * 不在就不下发。入集即等价于可填——原 P95 的 `canFill`/`myTier` 两字段随三层模型出清 + * (§19 作废-3),不再有"进了列表但不能自填、只给代填"的中间态。

*/ export interface FillableForm { code: string; name: string; appCode: string; - /** 面向人群:1 学生 / 2 教师(与定义域 FormView 同一个数字口径) */ - audience: number; + /** 面向人群(C1 数组形态) */ + audience: string[]; /** yyyy-MM-dd HH:mm:ss;null=不限截止 */ deadline: string | null; /** 限填次数:0 不限 / 1 限一次 */ fillLimit: number; groupTag: string | null; - canFill: boolean; - /** 并集最高层:ADMIN > MANAGE > FILL */ - myTier: "ADMIN" | "MANAGE" | "FILL"; } -/** 数据范围闭集四枚举的展示名(契约 §8;与后端 FormEnums.DataScope 的 label 同源) */ +/** 数据范围两档的展示名(契约 v2 §2.3;与后端 FormEnums.DataScope 的 label 同源) */ export const DATA_SCOPE_LABELS: Record = { ALL: "全部", - TEACHING_CLASS: "任教班级", - SELF_SUBMITTED: "仅本人提交", - ABOUT_ME: "关于我" + SELF: "我相关" }; /** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */ diff --git a/src/types/form.ts b/src/types/form.ts index 2fcea41..e42be9d 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -11,17 +11,37 @@ export const FORM_STATUS = { DISABLED: 2 } as const; -/** 面向人群(后端 FormEnums.Audience):1 面向学生 / 2 面向教师 */ +/** + * 面向人群(契约 v2 C1 多选):后端列值=逗号分隔枚举码,JSON 面一律字符串数组。 + * 闭集三值;ANONYMOUS 只对匿名通道生效(登录会话不会因它拿到填报入口)。 + */ export const AUDIENCE = { - STUDENT: 1, - TEACHER: 2 + STUDENT: "STUDENT", + TEACHER: "TEACHER", + ANONYMOUS: "ANONYMOUS" } as const; -export const AUDIENCE_OPTIONS = [ +export type AudienceCode = (typeof AUDIENCE)[keyof typeof AUDIENCE]; + +export const AUDIENCE_OPTIONS: { value: AudienceCode; label: string }[] = [ { value: AUDIENCE.STUDENT, label: "面向学生" }, - { value: AUDIENCE.TEACHER, label: "面向教师" } + { value: AUDIENCE.TEACHER, label: "面向教师" }, + { value: AUDIENCE.ANONYMOUS, label: "匿名可填" } ]; +/** 码 → 展示名(列表/详情文案用) */ +export const AUDIENCE_LABELS: Record = { + STUDENT: "学生", + TEACHER: "教师", + ANONYMOUS: "匿名" +}; + +/** 数组人群 → "学生/教师" 形式的短文案 */ +export function audienceText(audience: string[] | null | undefined): string { + const list = audience || []; + return list.length ? list.map((a) => AUDIENCE_LABELS[a] || a).join("/") : "—"; +} + /** 表单项(头表 + 版本指针,不含 schema 正文) */ export interface FormView { formUuid: string; @@ -36,11 +56,16 @@ export interface FormView { currentVersion: number | null; /** 待发布的草稿版本号,null=无草稿(P20:UI 必须区分这两根指针) */ draftVersion: number | null; - audience: number; + /** 面向人群(C1 数组形态) */ + audience: string[]; fillLimit: number; /** yyyy-MM-dd HH:mm:ss,null=不限 */ deadline: string | null; answerNoEnabled: number; + /** 提交后禁编辑(契约 v2 §4.2):1=本人编辑与管理员编辑同关 */ + frozen: number; + /** 匿名 cookie 防重窗口分钟数(C7) */ + anonCookieMinutes: number; groupTag: string | null; statusDefs: StatusDefView[] | null; createTime: string; @@ -104,11 +129,16 @@ export interface FormSaveRequest { export interface FormConfigRequest { uuid: string; - audience?: number; + /** 面向人群(C1 数组;缺省=不改这一项) */ + audience?: AudienceCode[]; fillLimit?: number; /** yyyy-MM-dd(后端补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串=不限 */ deadline?: string | null; answerNoEnabled?: number; + /** 提交后禁编辑(契约 v2 §4.2) */ + frozen?: number; + /** 匿名 cookie 防重窗口分钟数(C7,1~1440) */ + anonCookieMinutes?: number; groupTag?: string | null; } diff --git a/src/views/AppContext.vue b/src/views/AppContext.vue index 06a800b..33b8784 100644 --- a/src/views/AppContext.vue +++ b/src/views/AppContext.vue @@ -41,20 +41,17 @@ const rawTree = ref([]); const loading = ref(false); const errorText = ref(""); -/** 填报 tab 的行:后端 canFill 判定(层②"只能代填"的行 canFill=false,不进这张列表) */ -const fillCards = computed(() => fillRows.value.filter((r) => r.canFill)); -/** 数据管理候选=层②∪层③;统计候选=层③(与后端 FillAccessService 的三个谓词一一对应) */ -const manageRows = computed(() => fillRows.value.filter((r) => r.myTier === "MANAGE" || r.myTier === "ADMIN")); -const adminRows = computed(() => fillRows.value.filter((r) => r.myTier === "ADMIN")); - -function codesOf(rows: FillableForm[]): string[] { - const codes: string[] = []; - for (const row of rows) if (!codes.includes(row.code)) codes.push(row.code); - return codes; -} - -const dataCodes = computed(() => codesOf(manageRows.value)); -const statsCodes = computed(() => codesOf(adminRows.value)); +/** 填报 tab 的行:契约 v2 C2 之后入集即等价于可填(行里没有 canFill,前端不再筛) */ +const fillCards = computed(() => fillRows.value); +/** + * 数据管理/统计两档的驱动位在 T13 这一批处于换轨空窗: + * 原驱动是 P95 的 `myTier`(三层模型的最高层),随 §19 作废-3 一起删掉了; + * 新驱动是 T14 的 `GET /api/form/my/managed`(创建人 ∪ form_admin 的"我管理的表单")。 + * 本批同一次执行内就会接上,空窗期这两档不出现在壳上——刻意不留"按 myTier 猜"的兼容分支, + * 免得留下第二套判定口径(红线:壳零自有权限逻辑)。 + */ +const dataCodes = computed(() => []); +const statsCodes = computed(() => []); const showDataTab = computed(() => dataCodes.value.length > 0); const showStatsTab = computed(() => statsCodes.value.length > 0); diff --git a/src/views/My.vue b/src/views/My.vue index ce53ad4..8c7b69b 100644 --- a/src/views/My.vue +++ b/src/views/My.vue @@ -9,11 +9,11 @@ * 两者是**两个端点、两个谓词**,页面按路由分派;顶部 Tab 只是 UI 导航(切路由),不是接口参数。 * 管理员代填后:这条答卷在**填表人的"我提交的"**里出现,同时在**被代填人的"关于我的"**里出现(V18)。 * - * P91 起第三视图「可填写」(/my/fillable),P95 W4 换数据源:这不再是答卷视图,而是**填报入口列表**。 - * 数据源从"会话菜单树里挑 path 形如 /f/{code} 的行"改成 `GET /api/form/my/fillable`—— - * 谁能填哪张表由中心按三层模型(audience × 身份 × 管理员两表)自解析, + * P91 起第三视图「可填写」(/my/fillable),P95 W4 换数据源、契约 v2 C2 定行形状:这不再是答卷视图, + * 而是**填报入口列表**。数据源从"会话菜单树里挑 path 形如 /f/{code} 的行"改成 `GET /api/form/my/fillable`—— + * 谁能填哪张表由中心按 audience 门(会话身份类型 ∈ 表单面向人群)自解析, * UserBase 侧不再需要逐表单配 `/f/` 模块行(任务书 P95 §0.1/§0.2 红线 6:分享链接与直链解析不动)。 - * 前端照旧**不做本地角色过滤**:接口下发 canFill=true 的才画卡(canFill 是后端算的,不是我筛的)。 + * 前端照旧**不做本地角色过滤**:接口下发什么就画什么(入集即等价于可填,行里不再有 canFill)。 * * 三条交互口径沿 T7 的"我的提交"段(不另造轮子): * 1. **编辑入口只在"我提交的"**:编辑=填表人的动作;"关于我的"整列只读 @@ -67,7 +67,7 @@ const VIEW_META = { fillable: { title: "可填写", endpoint: "GET /api/form/my/fillable", - predicate: "谓词:后端按三层模型解析出的填报入口(canFill=true 才画卡)", + predicate: "谓词:会话身份类型 ∈ form.audience(audience 门,与提交时同一公式)", note: "这里是当前账号能填报的表单。表单发布后按面向人群自动出现在这里——不需要有人在基础平台逐张表配模块行。" } } as const; @@ -84,12 +84,13 @@ const loadedOnce = ref(false); const errorText = ref(""); /** - * 「可填写」的卡片集=接口里 `canFill=true` 的行。 + * 「可填写」的卡片集=接口下发的全部行。 * - *

过滤条件只有这一条,且判定来自后端:`canFill=false` 的行(教师×面向学生那种"只能代填") - * 由后端按三层模型算出,前端只是不把它画成"进入填报"的卡——前端不重新按角色判一遍(T8 红线:范围以后端为准)。

+ *

契约 v2 C2 之后行里没有 `canFill`:入集即等价于可填(后端按 audience 门筛过一次, + * 不再有"进了列表但不能自填、只给代填"的中间态)。前端依旧不做任何本地身份过滤 + * (T8 红线:范围与资格以后端为准,这里只画后端给的东西)。

*/ -const fillableCards = computed(() => fillableRows.value.filter((r) => r.canFill)); +const fillableCards = computed(() => fillableRows.value); /** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */ const sessionUserId = computed(() => store.sessionUserId); diff --git a/src/views/admin/Forms.vue b/src/views/admin/Forms.vue index 0902584..f62fab2 100644 --- a/src/views/admin/Forms.vue +++ b/src/views/admin/Forms.vue @@ -21,7 +21,7 @@ import { useElementHeight } from "@/utils/hook/useElementHeight"; import FormConfigModal from "@/components/form/FormConfigModal.vue"; import FormAdminModal from "@/components/admin/FormAdminModal.vue"; import type { FormView } from "@/types/form"; -import { AUDIENCE, FORM_STATUS } from "@/types/form"; +import { FORM_STATUS, audienceText } from "@/types/form"; import { CODE_PATTERN, MAX_NAME_LEN } from "@/types/formSchema"; const router = useRouter(); @@ -86,8 +86,9 @@ const columns = computed(() => [ { title: "面向", key: "audience", - width: 90, - render: (h2: any, p: any) => h2("span", p.row.audience === AUDIENCE.TEACHER ? "面向教师" : "面向学生") + width: 120, + // 人群是多值(契约 v2 C1),列宽给得比原来宽一点:"学生/教师/匿名" 三种组合都要能读完 + render: (h2: any, p: any) => h2("span", audienceText(p.row.audience)) }, { title: "截止",