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
+4 -3
View File
@@ -56,11 +56,12 @@ export function getMyAnswerDetail(answerId: string) {
return alovaInstance.Get<AnswerDetail>("/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<string, string> = {};
if (appCode) params.appCode = appCode;
+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 位");
+50 -13
View File
@@ -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<StatusDefView[]>([]);
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 });
<div class="cfg-row">
<span class="cfg-label">面向人群</span>
<RadioGroup v-model="model.audience">
<Radio v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Radio>
</RadioGroup>
<span class="cfg-note">决定数据范围矩阵落在哪一列(契约 §8)</span>
<CheckboxGroup v-model="model.audience">
<Checkbox v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Checkbox>
</CheckboxGroup>
<span class="cfg-note">
可多选(契约 v2 C1)。填报资格=登录身份类型落在集合内;「匿名可填」只对无会话的
<code>/anon</code> 通道生效,登录用户不会因此多出入口
</span>
</div>
<div class="cfg-row">
<span class="cfg-label">限次</span>
<RadioGroup v-model="model.fillLimit">
<Radio :label="0">不限次数</Radio>
<Radio :label="1">每人限填一次</Radio>
<Radio :label="1" :disabled="anonymousChosen">每人限填一次</Radio>
</RadioGroup>
<span class="cfg-note">
「每人」按提交主体判:管理员代填指定了被代填人时按那个人限,否则按提交人限;
软删答卷会释放名额(契约 §5)
「每人」按提交主体判:管理员代录指定了被记录人时按那个人限,否则按提交人限;软删答卷会释放名额。
<template v-if="anonymousChosen">含匿名人群时不可限次(匿名无 limit_key,防重只有 cookie)。</template>
</span>
</div>
@@ -217,6 +239,21 @@ defineExpose({ open });
</span>
</div>
<div class="cfg-row">
<span class="cfg-label">提交后禁编辑</span>
<i-switch v-model="model.frozen" :true-value="1" :false-value="0" />
<span class="cfg-note">
开启后提交人与管理员的编辑通道同时关闭,修正只能「撤回(软删)+ 重新提交」——
<b>喂外部业务应用的表单请开启</b>(原件是档案,业务对象归应用)
</span>
</div>
<div class="cfg-row">
<span class="cfg-label">匿名防重窗口</span>
<InputNumber v-model="model.anonCookieMinutes" :min="1" :max="1440" style="width: 140px" />
<span class="cfg-note">分钟。仅对面向人群含「匿名可填」的表单有意义(默认 10 分钟)</span>
</div>
<div class="cfg-row">
<span class="cfg-label">分组标签</span>
<Input v-model="model.groupTag" :maxlength="64" placeholder="可空,用于列表筛选归类" style="width: 220px" />
+3 -2
View File
@@ -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"),
+2 -1
View File
@@ -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;
+8 -14
View File
@@ -127,35 +127,29 @@ export interface MyAnswerViewItem {
}
/**
* 「可填写」列表的行(P95 W3/W4,`GET /api/form/my/fillable`)。
* 「可填写」列表的行(契约 v2 C2,`GET /api/form/my/fillable`)。
*
* <p>这不是答卷行,是**表单入口**行:后端按 P95 三层模型逐张表算出"这个人在这张表上算哪一层",
* NONE 的行不下发。`canFill` 与 `myTier` 是两件事——`canFill=false` 且 `myTier="MANAGE"`
* 就是"教师×面向学生"那种只能代填的表:「可填写」列表把它筛掉,但快应用上下文的
* 数据管理 tab 仍要它(AppContext.vue 按 myTier 决定 tab 显隐)。</p>
* <p>这不是答卷行,是**表单入口**行:后端按 audience 门逐张表判"这个人的身份类型在不在面向人群里",
* 不在就不下发。<b>入集即等价于可填</b>——原 P95 的 `canFill`/`myTier` 两字段随三层模型出清
* (§19 作废-3),不再有"进了列表但不能自填、只给代填"的中间态。</p>
*/
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<string, string> = {
ALL: "全部",
TEACHING_CLASS: "任教班级",
SELF_SUBMITTED: "仅本人提交",
ABOUT_ME: "关于我"
SELF: "我相关"
};
/** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */
+37 -7
View File
@@ -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<string, string> = {
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;
}
+11 -14
View File
@@ -41,20 +41,17 @@ const rawTree = ref<IModule[]>([]);
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 这一批处于<b>换轨空窗</b>:
* 原驱动是 P95 的 `myTier`(三层模型的最高层),随 §19 作废-3 一起删掉了;
* 新驱动是 T14 的 `GET /api/form/my/managed`(创建人 ∪ form_admin 的"我管理的表单")。
* 本批同一次执行内就会接上,空窗期这两档不出现在壳上——<b>刻意不留"按 myTier 猜"的兼容分支</b>,
* 免得留下第二套判定口径(红线:壳零自有权限逻辑)。
*/
const dataCodes = computed<string[]>(() => []);
const statsCodes = computed<string[]>(() => []);
const showDataTab = computed(() => dataCodes.value.length > 0);
const showStatsTab = computed(() => statsCodes.value.length > 0);
+10 -9
View File
@@ -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`——
* 谁能填哪张表由中心按 <b>audience 门</b>(会话身份类型 ∈ 表单面向人群)自解析,
* 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` 的行。
* 「可填写」的卡片集=接口下发的全部行。
*
* <p>过滤条件只有这一条,且判定<b>来自后端</b>:`canFill=false` 的行(教师×面向学生那种"只能代填")
* 由后端按三层模型算出,前端只是不把它画成"进入填报"的卡——前端不重新按角色判一遍(T8 红线:范围以后端为准)。</p>
* <p>契约 v2 C2 之后行里没有 `canFill`:<b>入集即等价于可填</b>(后端按 audience 门筛过一次,
* 不再有"进了列表但不能自填、只给代填"的中间态)。前端依旧不做任何本地身份过滤
* (T8 红线:范围与资格以后端为准,这里只画后端给的东西)。</p>
*/
const fillableCards = computed<FillableForm[]>(() => fillableRows.value.filter((r) => r.canFill));
const fillableCards = computed<FillableForm[]>(() => fillableRows.value);
/** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */
const sessionUserId = computed(() => store.sessionUserId);
+4 -3
View File
@@ -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: "截止",