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:
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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 位");
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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: "截止",
|
||||||
|
|||||||
Reference in New Issue
Block a user