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(); return alovaInstance.Get<AnswerDetail>("/api/form/my/detail", { params: { answerId } }).send();
} }
// ==================== 可填写列表(P95 W3/W4,/my/fillable) ==================== // ==================== 可填写列表(契约 v2 C2,/my/fillable) ====================
// 数据源=后端按 P95 三层模型解析的**填报入口**清单,不再是会话菜单树里挑 /f/ 行(P91 的形态)。 // 数据源=后端按 **audience 门**(会话身份类型 ∈ 表单面向人群)解析的填报入口清单,
// 不再是会话菜单树里挑 /f/ 行(P91 的形态),也不再有"入口开了但不能自填"的中间态。
// appCode 可选:缺省=全部应用(中心 + 快应用),快应用上下文传自身 appCode 收窄。 // appCode 可选:缺省=全部应用(中心 + 快应用),快应用上下文传自身 appCode 收窄。
/** 我可填的表单(入口层非 NONE 的都下发;本人能不能填看 canFill) */ /** 我可填的表单(入集即等价于可填;资格判定在后端,与提交时同一个公式) */
export function listFillableForms(appCode?: string) { export function listFillableForms(appCode?: string) {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (appCode) params.appCode = appCode; if (appCode) params.appCode = appCode;
+16 -17
View File
@@ -206,7 +206,7 @@ export default [
appCode: queryValue(query, "appCode") || APP, appCode: queryValue(query, "appCode") || APP,
name: "三好学生申报", name: "三好学生申报",
version: 2, version: 2,
audience: 1, audience: ["STUDENT"],
fillLimit: 0, fillLimit: 0,
answerNoEnabled: 0, answerNoEnabled: 0,
deadline: null, deadline: null,
@@ -317,27 +317,26 @@ export default [
return ok(results); return ok(results);
} }, } },
// ==================== 可填写入口清单(P95 W3/W4,/my/fillable) ==================== // ==================== 可填写入口清单(契约 v2 C2,/my/fillable) ====================
/** /**
* 三行种子刻意覆盖三层模型的可观测差异(冒烟脚本 p95-fill-scope-smoke.sh 就断言这三件事): * 行形状按 C2:无 canFill/myTier,`audience` 是数组。<b>入集即等价于可填</b>
* ① FILL:学生面向、canFill=true → 「可填写」画卡; * (后端 audience 门筛过一次,不再有"进了列表但不能自填、只给代填"的中间态)。
* ② MANAGE:学生面向、canFill=false(教师那种"只能代填")→ 「可填写」<b>不</b>画卡, * 种子刻意覆盖三种人群形态:
* 但快应用上下文的数据管理 tab 要靠它出现; * ① 纯学生面向;② 学生+教师共集(C1 多值,一张表两类人都能填);③ 纯教师面向。
* ③ ADMIN:教师面向、canFill=true + 最高层 → 统计 tab 只由它决定。
* appCode 传值时按该应用收窄(缺省=全部应用),与后端 FillableFormService 同一口径。 * appCode 传值时按该应用收窄(缺省=全部应用),与后端 FillableFormService 同一口径。
*/ */
{ url: "/api/form/my/fillable", method: "get", response: ({ query }: any) => { { url: "/api/form/my/fillable", method: "get", response: ({ query }: any) => {
const rows: FillableForm[] = [ const rows: FillableForm[] = [
{ code: "honor_apply", name: "三好学生申报", appCode: APP, audience: 1, { code: "honor_apply", name: "三好学生申报", appCode: APP, audience: ["STUDENT"],
deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null, canFill: true, myTier: "FILL" }, deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null },
{ code: "home_visit", name: "家访登记(教师代填)", appCode: APP, audience: 1, { code: "home_visit", name: "家访登记(师生共集)", appCode: APP, audience: ["STUDENT", "TEACHER"],
deadline: null, fillLimit: 0, groupTag: "五年级", canFill: false, myTier: "MANAGE" }, deadline: null, fillLimit: 0, groupTag: "五年级" },
{ code: "teacher_survey", name: "教师问卷", appCode: APP, audience: 2, { code: "teacher_survey", name: "教师问卷", appCode: APP, audience: ["TEACHER"],
deadline: "2026-11-30 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "ADMIN" }, deadline: "2026-11-30 23:59:59", fillLimit: 0, groupTag: null },
{ code: "drill-honour", name: "荣誉申报(演练应用)", appCode: "fc-drill", audience: 1, { code: "drill-honour", name: "荣誉申报(演练应用)", appCode: "fc-drill", audience: ["STUDENT"],
deadline: "2026-10-31 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "FILL" }, deadline: "2026-10-31 23:59:59", fillLimit: 0, groupTag: null },
{ code: "drill_roster", name: "花名册(演练应用,教师代填)", appCode: "fc-drill", audience: 1, { code: "drill_roster", name: "花名册(演练应用,师生共集)", appCode: "fc-drill",
deadline: null, fillLimit: 0, groupTag: null, canFill: false, myTier: "MANAGE" } audience: ["STUDENT", "TEACHER"], deadline: null, fillLimit: 0, groupTag: null }
]; ];
// 排序沿后端口径:deadline 升序、不限截止在后、同档按 code // 排序沿后端口径:deadline 升序、不限截止在后、同档按 code
const app = String(query?.appCode || "").trim(); const app = String(query?.appCode || "").trim();
+29 -5
View File
@@ -95,10 +95,12 @@ const forms: MockForm[] = [
statusName: "PUBLISHED", statusName: "PUBLISHED",
currentVersion: 1, currentVersion: 1,
draftVersion: 2, draftVersion: 2,
audience: AUDIENCE.STUDENT, audience: [AUDIENCE.STUDENT],
fillLimit: 1, fillLimit: 1,
deadline: null, deadline: null,
answerNoEnabled: 0, answerNoEnabled: 0,
frozen: 0,
anonCookieMinutes: 10,
groupTag: null, groupTag: null,
statusDefs: DEFAULT_STATUS_DEFS, statusDefs: DEFAULT_STATUS_DEFS,
createTime: now(), createTime: now(),
@@ -161,10 +163,12 @@ function view(f: MockForm): FormView {
statusName: f.statusName, statusName: f.statusName,
currentVersion: f.currentVersion, currentVersion: f.currentVersion,
draftVersion: f.draftVersion, draftVersion: f.draftVersion,
audience: f.audience, audience: [...f.audience],
fillLimit: f.fillLimit, fillLimit: f.fillLimit,
deadline: f.deadline, deadline: f.deadline,
answerNoEnabled: f.answerNoEnabled, answerNoEnabled: f.answerNoEnabled,
frozen: f.frozen,
anonCookieMinutes: f.anonCookieMinutes,
groupTag: f.groupTag, groupTag: f.groupTag,
statusDefs: f.statusDefs ? JSON.parse(JSON.stringify(f.statusDefs)) : null, statusDefs: f.statusDefs ? JSON.parse(JSON.stringify(f.statusDefs)) : null,
createTime: f.createTime, createTime: f.createTime,
@@ -332,10 +336,12 @@ export default [
statusName: "DRAFT", statusName: "DRAFT",
currentVersion: null, currentVersion: null,
draftVersion: 1, draftVersion: 1,
audience: AUDIENCE.STUDENT, audience: [AUDIENCE.STUDENT],
fillLimit: 0, fillLimit: 0,
deadline: null, deadline: null,
answerNoEnabled: 0, answerNoEnabled: 0,
frozen: 0,
anonCookieMinutes: 10,
groupTag: null, groupTag: null,
statusDefs: DEFAULT_STATUS_DEFS, statusDefs: DEFAULT_STATUS_DEFS,
createTime: now(), createTime: now(),
@@ -443,13 +449,31 @@ export default [
const f = findForm(body.uuid); const f = findForm(body.uuid);
if (!f) return bizErr(2, "表单未找到"); if (!f) return bizErr(2, "表单未找到");
if (body.audience !== undefined) { if (body.audience !== undefined) {
if (body.audience !== AUDIENCE.STUDENT && body.audience !== AUDIENCE.TEACHER) return bizErr(2, "audience 只能是 1 或 2"); const codes = Array.isArray(body.audience) ? body.audience : [];
f.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 !== undefined) {
if (body.fillLimit !== 0 && body.fillLimit !== 1) return bizErr(2, "fillLimit 只能是 0 或 1"); if (body.fillLimit !== 0 && body.fillLimit !== 1) return bizErr(2, "fillLimit 只能是 0 或 1");
f.fillLimit = body.fillLimit; 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 !== undefined) {
if (body.answerNoEnabled !== 0 && body.answerNoEnabled !== 1) return bizErr(2, "answerNoEnabled 只能是 0 或 1"); 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 位"); 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 { computed, reactive, ref, watch } from "vue";
import { Message, Modal } from "view-ui-plus"; import { Message, Modal } from "view-ui-plus";
import { formApi } from "@/api/module/form"; import { formApi } from "@/api/module/form";
import type { FormView, StatusDefView } from "@/types/form"; import type { AudienceCode, FormView, StatusDefView } from "@/types/form";
import { AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form"; import { AUDIENCE, AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form";
import { MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema"; import { MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema";
const props = defineProps<{ form: FormView | null }>(); const props = defineProps<{ form: FormView | null }>();
@@ -23,30 +23,39 @@ const emit = defineEmits<{ (e: "saved", form: FormView): void }>();
const visible = ref(false); const visible = ref(false);
const saving = ref(false); const saving = ref(false);
const model = reactive<{ const model = reactive<{
audience: number; audience: string[];
fillLimit: number; fillLimit: number;
deadline: string; deadline: string;
answerNoEnabled: number; answerNoEnabled: number;
frozen: number;
anonCookieMinutes: number;
groupTag: string; groupTag: string;
}>({ }>({
audience: AUDIENCE_OPTIONS[0].value, audience: [AUDIENCE.STUDENT],
fillLimit: 0, fillLimit: 0,
deadline: "", deadline: "",
answerNoEnabled: 0, answerNoEnabled: 0,
frozen: 0,
anonCookieMinutes: 10,
groupTag: "" groupTag: ""
}); });
const statusDefs = ref<StatusDefView[]>([]); const statusDefs = ref<StatusDefView[]>([]);
const newStatus = reactive({ code: "", label: "" }); const newStatus = reactive({ code: "", label: "" });
/** 含匿名时后端强制 fill_limit=0(§3.2/作废-11:匿名无 limit_key)——前端同判据提前挡,不给点了才报错 */
const anonymousChosen = computed(() => model.audience.includes(AUDIENCE.ANONYMOUS));
watch( watch(
() => [visible.value, props.form?.formUuid] as const, () => [visible.value, props.form?.formUuid] as const,
() => { () => {
const f = props.form; const f = props.form;
if (!visible.value || !f) return; 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.fillLimit = f.fillLimit ?? 0;
model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : ""; model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : "";
model.answerNoEnabled = f.answerNoEnabled ?? 0; model.answerNoEnabled = f.answerNoEnabled ?? 0;
model.frozen = f.frozen ?? 0;
model.anonCookieMinutes = f.anonCookieMinutes ?? 10;
model.groupTag = f.groupTag || ""; model.groupTag = f.groupTag || "";
statusDefs.value = (f.statusDefs || []).map((d) => ({ ...d })); statusDefs.value = (f.statusDefs || []).map((d) => ({ ...d }));
newStatus.code = ""; newStatus.code = "";
@@ -110,15 +119,25 @@ async function submit() {
Message.error(`开启报名号要求 code ≤49 位(当前 ${f.code.length})`); Message.error(`开启报名号要求 code ≤49 位(当前 ${f.code.length})`);
return; return;
} }
if (!model.audience.length) {
Message.error("面向人群至少要选一项(空集合等于谁都不能填)");
return;
}
if (anonymousChosen.value && model.fillLimit === 1) {
Message.error("含「匿名可填」的表单不支持限次(匿名没有可比对的身份),请把限次改回不限");
return;
}
saving.value = true; saving.value = true;
try { try {
await formApi await formApi
.config({ .config({
uuid: f.formUuid, uuid: f.formUuid,
audience: model.audience, audience: model.audience as AudienceCode[],
fillLimit: model.fillLimit, fillLimit: model.fillLimit,
deadline: model.deadline, deadline: model.deadline,
answerNoEnabled: model.answerNoEnabled, answerNoEnabled: model.answerNoEnabled,
frozen: model.frozen,
anonCookieMinutes: model.anonCookieMinutes,
groupTag: model.groupTag groupTag: model.groupTag
}) })
.send(); .send();
@@ -184,21 +203,24 @@ defineExpose({ open });
<div class="cfg-row"> <div class="cfg-row">
<span class="cfg-label">面向人群</span> <span class="cfg-label">面向人群</span>
<RadioGroup v-model="model.audience"> <CheckboxGroup v-model="model.audience">
<Radio v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Radio> <Checkbox v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Checkbox>
</RadioGroup> </CheckboxGroup>
<span class="cfg-note">决定数据范围矩阵落在哪一列(契约 §8)</span> <span class="cfg-note">
可多选(契约 v2 C1)。填报资格=登录身份类型落在集合内;「匿名可填」只对无会话的
<code>/anon</code> 通道生效,登录用户不会因此多出入口
</span>
</div> </div>
<div class="cfg-row"> <div class="cfg-row">
<span class="cfg-label">限次</span> <span class="cfg-label">限次</span>
<RadioGroup v-model="model.fillLimit"> <RadioGroup v-model="model.fillLimit">
<Radio :label="0">不限次数</Radio> <Radio :label="0">不限次数</Radio>
<Radio :label="1">每人限填一次</Radio> <Radio :label="1" :disabled="anonymousChosen">每人限填一次</Radio>
</RadioGroup> </RadioGroup>
<span class="cfg-note"> <span class="cfg-note">
「每人」按提交主体判:管理员代填指定了被代填人时按那个人限,否则按提交人限; 「每人」按提交主体判:管理员代录指定了被记录人时按那个人限,否则按提交人限;软删答卷会释放名额。
软删答卷会释放名额(契约 §5) <template v-if="anonymousChosen">含匿名人群时不可限次(匿名无 limit_key,防重只有 cookie)。</template>
</span> </span>
</div> </div>
@@ -217,6 +239,21 @@ defineExpose({ open });
</span> </span>
</div> </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"> <div class="cfg-row">
<span class="cfg-label">分组标签</span> <span class="cfg-label">分组标签</span>
<Input v-model="model.groupTag" :maxlength="64" placeholder="可空,用于列表筛选归类" style="width: 220px" /> <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" }, meta: { title: "关于我的", parentPath: "/my" },
}, },
{ {
// P91 起第三视图;P95 W4 换数据源=GET /api/form/my/fillable(中心按三层模型解析的填报入口, // P91 起第三视图;P95 W4 换数据源=GET /api/form/my/fillable,契约 v2 C2 定行形状
// 不再从会话菜单树挑 /f/ 行)。子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。 // (中心按 audience 门解析填报入口,不再从会话菜单树挑 /f/ 行)。
// 子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。
path: "/my/fillable", path: "/my/fillable",
name: "fc_my_fillable", name: "fc_my_fillable",
component: () => import("@/views/My.vue"), component: () => import("@/views/My.vue"),
+2 -1
View File
@@ -18,7 +18,8 @@ export interface ResolvedForm {
appCode: string; appCode: string;
name: string; name: string;
version: number; version: number;
audience: number; /** 面向人群(契约 v2 C1 数组形态;填报页只用于展示与预提示,资格判定在后端 audience 门) */
audience: string[];
fillLimit: number; fillLimit: number;
answerNoEnabled: number; answerNoEnabled: number;
deadline: string | null; 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 三层模型逐张表算出"这个人在这张表上算哪一层", * <p>这不是答卷行,是**表单入口**行:后端按 audience 门逐张表判"这个人的身份类型在不在面向人群里",
* NONE 的行不下发。`canFill` 与 `myTier` 是两件事——`canFill=false` 且 `myTier="MANAGE"` * 不在就不下发。<b>入集即等价于可填</b>——原 P95 的 `canFill`/`myTier` 两字段随三层模型出清
* 就是"教师×面向学生"那种只能代填的表:「可填写」列表把它筛掉,但快应用上下文的 * (§19 作废-3),不再有"进了列表但不能自填、只给代填"的中间态。</p>
* 数据管理 tab 仍要它(AppContext.vue 按 myTier 决定 tab 显隐)。</p>
*/ */
export interface FillableForm { export interface FillableForm {
code: string; code: string;
name: string; name: string;
appCode: string; appCode: string;
/** 面向人群:1 学生 / 2 教师(与定义域 FormView 同一个数字口径) */ /** 面向人群(C1 数组形态) */
audience: number; audience: string[];
/** yyyy-MM-dd HH:mm:ss;null=不限截止 */ /** yyyy-MM-dd HH:mm:ss;null=不限截止 */
deadline: string | null; deadline: string | null;
/** 限填次数:0 不限 / 1 限一次 */ /** 限填次数:0 不限 / 1 限一次 */
fillLimit: number; fillLimit: number;
groupTag: string | null; 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> = { export const DATA_SCOPE_LABELS: Record<string, string> = {
ALL: "全部", ALL: "全部",
TEACHING_CLASS: "任教班级", SELF: "我相关"
SELF_SUBMITTED: "仅本人提交",
ABOUT_ME: "关于我"
}; };
/** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */ /** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */
+37 -7
View File
@@ -11,17 +11,37 @@ export const FORM_STATUS = {
DISABLED: 2 DISABLED: 2
} as const; } as const;
/** 面向人群(后端 FormEnums.Audience):1 面向学生 / 2 面向教师 */ /**
* 面向人群(契约 v2 C1 多选):后端列值=逗号分隔枚举码,JSON 面一律字符串数组。
* 闭集三值;ANONYMOUS 只对匿名通道生效(登录会话不会因它拿到填报入口)。
*/
export const AUDIENCE = { export const AUDIENCE = {
STUDENT: 1, STUDENT: "STUDENT",
TEACHER: 2 TEACHER: "TEACHER",
ANONYMOUS: "ANONYMOUS"
} as const; } 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.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 正文) */ /** 表单项(头表 + 版本指针,不含 schema 正文) */
export interface FormView { export interface FormView {
formUuid: string; formUuid: string;
@@ -36,11 +56,16 @@ export interface FormView {
currentVersion: number | null; currentVersion: number | null;
/** 待发布的草稿版本号,null=无草稿(P20:UI 必须区分这两根指针) */ /** 待发布的草稿版本号,null=无草稿(P20:UI 必须区分这两根指针) */
draftVersion: number | null; draftVersion: number | null;
audience: number; /** 面向人群(C1 数组形态) */
audience: string[];
fillLimit: number; fillLimit: number;
/** yyyy-MM-dd HH:mm:ss,null=不限 */ /** yyyy-MM-dd HH:mm:ss,null=不限 */
deadline: string | null; deadline: string | null;
answerNoEnabled: number; answerNoEnabled: number;
/** 提交后禁编辑(契约 v2 §4.2):1=本人编辑与管理员编辑同关 */
frozen: number;
/** 匿名 cookie 防重窗口分钟数(C7) */
anonCookieMinutes: number;
groupTag: string | null; groupTag: string | null;
statusDefs: StatusDefView[] | null; statusDefs: StatusDefView[] | null;
createTime: string; createTime: string;
@@ -104,11 +129,16 @@ export interface FormSaveRequest {
export interface FormConfigRequest { export interface FormConfigRequest {
uuid: string; uuid: string;
audience?: number; /** 面向人群(C1 数组;缺省=不改这一项) */
audience?: AudienceCode[];
fillLimit?: number; fillLimit?: number;
/** yyyy-MM-dd(后端补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串=不限 */ /** yyyy-MM-dd(后端补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串=不限 */
deadline?: string | null; deadline?: string | null;
answerNoEnabled?: number; answerNoEnabled?: number;
/** 提交后禁编辑(契约 v2 §4.2) */
frozen?: number;
/** 匿名 cookie 防重窗口分钟数(C7,1~1440) */
anonCookieMinutes?: number;
groupTag?: string | null; groupTag?: string | null;
} }
+11 -14
View File
@@ -41,20 +41,17 @@ const rawTree = ref<IModule[]>([]);
const loading = ref(false); const loading = ref(false);
const errorText = ref(""); const errorText = ref("");
/** 填报 tab 的行:后端 canFill 判定(层②"只能代填"的行 canFill=false,不进这张列表) */ /** 填报 tab 的行:契约 v2 C2 之后入集即等价于可填(行里没有 canFill,前端不再筛) */
const fillCards = computed(() => fillRows.value.filter((r) => r.canFill)); const fillCards = computed(() => fillRows.value);
/** 数据管理候选=层②∪层③;统计候选=层③(与后端 FillAccessService 的三个谓词一一对应) */ /**
const manageRows = computed(() => fillRows.value.filter((r) => r.myTier === "MANAGE" || r.myTier === "ADMIN")); * 数据管理/统计两档的驱动位在 T13 这一批处于<b>换轨空窗</b>:
const adminRows = computed(() => fillRows.value.filter((r) => r.myTier === "ADMIN")); * 原驱动是 P95 的 `myTier`(三层模型的最高层),随 §19 作废-3 一起删掉了;
* 新驱动是 T14 的 `GET /api/form/my/managed`(创建人 ∪ form_admin 的"我管理的表单")。
function codesOf(rows: FillableForm[]): string[] { * 本批同一次执行内就会接上,空窗期这两档不出现在壳上——<b>刻意不留"按 myTier 猜"的兼容分支</b>,
const codes: string[] = []; * 免得留下第二套判定口径(红线:壳零自有权限逻辑)。
for (const row of rows) if (!codes.includes(row.code)) codes.push(row.code); */
return codes; const dataCodes = computed<string[]>(() => []);
} const statsCodes = computed<string[]>(() => []);
const dataCodes = computed(() => codesOf(manageRows.value));
const statsCodes = computed(() => codesOf(adminRows.value));
const showDataTab = computed(() => dataCodes.value.length > 0); const showDataTab = computed(() => dataCodes.value.length > 0);
const showStatsTab = computed(() => statsCodes.value.length > 0); const showStatsTab = computed(() => statsCodes.value.length > 0);
+10 -9
View File
@@ -9,11 +9,11 @@
* 两者是**两个端点、两个谓词**,页面按路由分派;顶部 Tab 只是 UI 导航(切路由),不是接口参数。 * 两者是**两个端点、两个谓词**,页面按路由分派;顶部 Tab 只是 UI 导航(切路由),不是接口参数。
* 管理员代填后:这条答卷在**填表人的"我提交的"**里出现,同时在**被代填人的"关于我的"**里出现(V18)。 * 管理员代填后:这条答卷在**填表人的"我提交的"**里出现,同时在**被代填人的"关于我的"**里出现(V18)。
* *
* P91 起第三视图「可填写」(/my/fillable),P95 W4 换数据源:这不再是答卷视图,而是**填报入口列表**。 * P91 起第三视图「可填写」(/my/fillable),P95 W4 换数据源、契约 v2 C2 定行形状:这不再是答卷视图,
* 数据源从"会话菜单树里挑 path 形如 /f/{code} 的行"改成 `GET /api/form/my/fillable`—— * 而是**填报入口列表**。数据源从"会话菜单树里挑 path 形如 /f/{code} 的行"改成 `GET /api/form/my/fillable`——
* 谁能填哪张表由中心按三层模型(audience × 身份 × 管理员两表)自解析, * 谁能填哪张表由中心按 <b>audience 门</b>(会话身份类型 ∈ 表单面向人群)自解析,
* UserBase 侧不再需要逐表单配 `/f/` 模块行(任务书 P95 §0.1/§0.2 红线 6:分享链接与直链解析不动)。 * UserBase 侧不再需要逐表单配 `/f/` 模块行(任务书 P95 §0.1/§0.2 红线 6:分享链接与直链解析不动)。
* 前端照旧**不做本地角色过滤**:接口下发 canFill=true 的才画卡(canFill 是后端算的,不是我筛的)。 * 前端照旧**不做本地角色过滤**:接口下发什么就画什么(入集即等价于可填,行里不再有 canFill)。
* *
* 三条交互口径沿 T7 的"我的提交"段(不另造轮子): * 三条交互口径沿 T7 的"我的提交"段(不另造轮子):
* 1. **编辑入口只在"我提交的"**:编辑=填表人的动作;"关于我的"整列只读 * 1. **编辑入口只在"我提交的"**:编辑=填表人的动作;"关于我的"整列只读
@@ -67,7 +67,7 @@ const VIEW_META = {
fillable: { fillable: {
title: "可填写", title: "可填写",
endpoint: "GET /api/form/my/fillable", endpoint: "GET /api/form/my/fillable",
predicate: "谓词:后端按三层模型解析出的填报入口(canFill=true 才画卡)", predicate: "谓词:会话身份类型 ∈ form.audience(audience 门,与提交时同一公式)",
note: "这里是当前账号能填报的表单。表单发布后按面向人群自动出现在这里——不需要有人在基础平台逐张表配模块行。" note: "这里是当前账号能填报的表单。表单发布后按面向人群自动出现在这里——不需要有人在基础平台逐张表配模块行。"
} }
} as const; } as const;
@@ -84,12 +84,13 @@ const loadedOnce = ref(false);
const errorText = ref(""); const errorText = ref("");
/** /**
* 「可填写」的卡片集=接口里 `canFill=true` 的行。 * 「可填写」的卡片集=接口下发的全部行。
* *
* <p>过滤条件只有这一条,且判定<b>来自后端</b>:`canFill=false` 的行(教师×面向学生那种"只能代填") * <p>契约 v2 C2 之后行里没有 `canFill`:<b>入集即等价于可填</b>(后端按 audience 门筛过一次,
* 由后端按三层模型算出,前端只是不把它画成"进入填报"的卡——前端不重新按角色判一遍(T8 红线:范围以后端为准)。</p> * 不再有"进了列表但不能自填、只给代填"的中间态)。前端依旧不做任何本地身份过滤
* (T8 红线:范围与资格以后端为准,这里只画后端给的东西)。</p>
*/ */
const fillableCards = computed<FillableForm[]>(() => fillableRows.value.filter((r) => r.canFill)); const fillableCards = computed<FillableForm[]>(() => fillableRows.value);
/** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */ /** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */
const sessionUserId = computed(() => store.sessionUserId); 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 FormConfigModal from "@/components/form/FormConfigModal.vue";
import FormAdminModal from "@/components/admin/FormAdminModal.vue"; import FormAdminModal from "@/components/admin/FormAdminModal.vue";
import type { FormView } from "@/types/form"; 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"; import { CODE_PATTERN, MAX_NAME_LEN } from "@/types/formSchema";
const router = useRouter(); const router = useRouter();
@@ -86,8 +86,9 @@ const columns = computed(() => [
{ {
title: "面向", title: "面向",
key: "audience", key: "audience",
width: 90, width: 120,
render: (h2: any, p: any) => h2("span", p.row.audience === AUDIENCE.TEACHER ? "面向教师" : "面向学生") // 人群是多值(契约 v2 C1),列宽给得比原来宽一点:"学生/教师/匿名" 三种组合都要能读完
render: (h2: any, p: any) => h2("span", audienceText(p.row.audience))
}, },
{ {
title: "截止", title: "截止",