P76(T8 验收遗留):快应用上下文的导出按钮此前恒按中心会话树判"导出"锚点 (Data.vue exportable 读 store.modules),锚点判定不生效(fail-closed 方向, 有授权也不画按钮)。本轮把 AppContext 拉到的**目标应用树**经 modules prop 下传, Data.vue 的权限判据改为 permsTree(app=应用树 / center=会话树),canExport 纯函数不动。 Stats.vue 未接:本页无锚点判定也无 store.modules 引用,加 prop 是死代码(差异记 T9 报告)。 P77(T8 验收遗留):应用上下文内的"详情"不再跳回中心路由 /admin/data/detail。 形态二选一取**子路由 /app/:appCode/data/detail**(同一个 AnswerDetail.vue): 地址保留 uuid 可深链/刷新、详情页只有一份实现、back() 本就按 app 回应用数据视图, 写 parentPath 反而造假(menuRoute 的 ROUTE_PATHS 只收静态 path,应用段导航在页内轻导航)。 goDetail 按 props.context 分派;back() 改读 route.params.appCode,撤掉 query.app 双轨。 观察项①(T8 验收记档):确认跨触底页勾选**会被清空**——view-ui-plus 的 data 监听器 用 makeObjBaseData 重建 objData,_isChecked 只认行上的 _checked(点选不回写行), 整组替换后勾选视觉与 getSelection 归零而"已选 N 条"仍累计。新增 withSelectionState() 在 reload/loadMore 两条装载路径按 selected 回填 _checked,selected 为唯一事实源。 演练桩(本地件,dev:mock 生效):baseapi 增 fc-drill 快应用模块树(方案 §6.2 五行含 导入/导出两个 path 空锚点),dataapi 增 /api/form/resolve 桩(快应用数据视图唯一的 合规取数路径是 code→resolve→uuid,契约 §1)。其余外指 appCode 仍旧返回空树(P3 口径)。 门禁:npm run build ✓ / npm run type-check 0 错 ✓; t1~t9 全量复跑:t5 13/0/1、t6 22/0/2、t7 12/0/0、t8 21/0/1、t9 12/0/1 (skip 均为 headless Edge --dump-dom 吐 0 字节的 P73 环境事实,不假通过)。 本提交含 T6~T8 三轮堆在工作区的既有改动(沿 T8 验收 §5 推送分层)。
This commit is contained in:
@@ -91,6 +91,78 @@ const OWN_MODULES: IModule[] = [
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* 第一个快应用的模块树(方案 §6.2 表的典型配法,T9 演练桩)。
|
||||
*
|
||||
* 只在 `appCode=fc-drill` 时下发,用来在本机把 **P76(导出锚点按应用树判)/ P77(应用内详情形态)/
|
||||
* V19(快应用独立模块树渲染左导航)** 跑到可实拍的程度——真会话取证要等 H2/H4(执行手册 §2 人工项)。
|
||||
* 其余外指 appCode 仍旧返回空树:那是 H4 未上线时的既定口径(P3),桩不能替平台把没上线的能力假装成有。
|
||||
*/
|
||||
const DRILL_APP_MODULES: IModule[] = [
|
||||
{
|
||||
id: 9201,
|
||||
modId: 9201,
|
||||
name: "填报",
|
||||
parentId: 0,
|
||||
page: "/f/drill-honour",
|
||||
level: 1,
|
||||
icon: "edit",
|
||||
orderNumber: 1,
|
||||
usable: true,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 9202,
|
||||
modId: 9202,
|
||||
name: "数据查看",
|
||||
parentId: 0,
|
||||
page: "/app/fc-drill/data",
|
||||
level: 1,
|
||||
icon: "category",
|
||||
orderNumber: 2,
|
||||
usable: true,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 9203,
|
||||
modId: 9203,
|
||||
name: "统计",
|
||||
parentId: 0,
|
||||
page: "/app/fc-drill/stats",
|
||||
level: 1,
|
||||
icon: "tongji",
|
||||
orderNumber: 3,
|
||||
usable: true,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
// 1.5 预留位:path 空锚点(方案 §6.2 表第四行)——不进导航
|
||||
id: 9204,
|
||||
modId: 9204,
|
||||
name: "导入",
|
||||
parentId: 0,
|
||||
page: "",
|
||||
level: 1,
|
||||
icon: "",
|
||||
orderNumber: 4,
|
||||
usable: true,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
// 按钮级权限锚点:导出按钮显隐按它判(V26 第一半句,P76 修的就是"判的是哪棵树")
|
||||
id: 9205,
|
||||
modId: 9205,
|
||||
name: "导出",
|
||||
parentId: 0,
|
||||
page: "",
|
||||
level: 1,
|
||||
icon: "",
|
||||
orderNumber: 5,
|
||||
usable: true,
|
||||
children: []
|
||||
}
|
||||
];
|
||||
|
||||
export default [
|
||||
{
|
||||
url: "/api/base/GetUserInfo",
|
||||
@@ -101,15 +173,21 @@ export default [
|
||||
account: "mockadmin",
|
||||
name: "Mock 管理员",
|
||||
profilePicture: "",
|
||||
// type=1 是 UserBase UserTypeEnum.SUPER_ADMIN:字典页的平台级维护上下文按它判
|
||||
// (FormAdminGuard.isSuperAdmin 同一条);mock 里给成超管,两级 CRUD 两条路径都能实拍
|
||||
type: 1,
|
||||
currentRole: { schId: 3260, roleId: 7 },
|
||||
school: { id: 3260, name: "云海附属中学" }
|
||||
})
|
||||
},
|
||||
{
|
||||
url: "/api/base/GetUserMod",
|
||||
method: "get",
|
||||
// 与后端同一条判定:appCode 指向别的应用 → 空树(H4 未上线口径,P3:前端不得回落本应用树)
|
||||
// 与后端同一条判定:appCode 指向别的应用 → 空树(H4 未上线口径,P3:前端不得回落本应用树)。
|
||||
// 唯一例外 fc-drill=T9 快应用演练桩(模块树见 DRILL_APP_MODULES),用于本机实拍 P76/P77/V19
|
||||
response: ({ query }: any) => {
|
||||
const target = String(query?.appCode ?? "").trim();
|
||||
if (target === "fc-drill") return ok(DRILL_APP_MODULES);
|
||||
if (target && target.toLowerCase() !== "form-center") return ok([]);
|
||||
return ok(OWN_MODULES);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 数据管理面 / 统计 / 我的填写三组端点的本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 生效)。
|
||||
*
|
||||
* 定位(沿 P48 的降级取证口径):本地 CAS/UserBase 未起、库里没有 form-center 应用行,
|
||||
* 真会话进不了 `/api/form/admin/data/*`;本文件让数据列表、详情、统计、我的两视图在浏览器里
|
||||
* 可点、可截图取证。**不替代后端权威**——scope/批量/统计的聚合语义由 T4 单测覆盖,
|
||||
* 前端以"对接形状"取证为准(任务书 T8 原文),所以这里的响应形状逐字段对齐
|
||||
* `ReadModels` / `MyModels`,不做任何加工。
|
||||
*
|
||||
* 种子的三个刻意设计(对应 T8 三个断言):
|
||||
* ① **V5**:所有响应里的答卷身份只有 `answerId`(32 位 uuid)、表单身份只有 `formUuid`
|
||||
* (32 位 uuid),**不出现库内自增 id**(submitterId/subjectId 是平台用户 id,不是本库主键);
|
||||
* ② **V8**:seed schema 已删字段 `legacy_class` 的索引行还在、值文档里还在——
|
||||
* 列表列要降级显示"(已移除字段)legacy_class",按历史字段筛选仍要能出结果;
|
||||
* ③ **V18**:两条答卷的 submitter/subject 交叉(管理员代填场景),
|
||||
* 用来断言"我提交的"与"关于我的"两视图分别可见。
|
||||
*
|
||||
* 游标(keyset):`cursor`=上一页末行 answerId(uuid),服务端按它定位后取下一页——
|
||||
* 这里用 uuid 在数组里的下标模拟,形状与后端一致(前端不感知差异)。
|
||||
*/
|
||||
import type { AnswerDetail, AnswerRow, BatchItemResult, FilterField, ListView, MyAnswerViewItem, StatsView } from "@/types/data";
|
||||
|
||||
const ok = (data: any) => ({ code: 0, msg: "", data });
|
||||
const bizErr = (code: number, msg: string) => ({ code, msg, data: null });
|
||||
|
||||
const APP = "form-center";
|
||||
const CODE = "honor_apply";
|
||||
const FORM_UUID = "mock0000000000000000000000000001";
|
||||
/** 当前发布版本(v2);种子答卷钉在 v1 或 v2,用来验"按钉住版本渲染" */
|
||||
const PUBLISHED_VERSION = 2;
|
||||
|
||||
/** v1 钉住版本的 schema(有 legacy_class,没有 nation) */
|
||||
const GROUPS_V1 = [
|
||||
{
|
||||
key: "base",
|
||||
label: "基本信息",
|
||||
fields: [
|
||||
{ key: "name", label: "学生姓名", type: "text", required: true, searchable: true, maxLength: 32 },
|
||||
{ key: "grade", label: "年级", type: "select", required: true, searchable: true, options: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }] },
|
||||
// 已在 v2 被删除的字段:历史值仍在(V8)
|
||||
{ key: "legacy_class", label: "原班级(已删)", type: "text", searchable: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/** v2 当前发布版本的 schema(删了 legacy_class,加了 nation + birthday) */
|
||||
const GROUPS_V2 = [
|
||||
{
|
||||
key: "base",
|
||||
label: "基本信息",
|
||||
fields: [
|
||||
{ key: "name", label: "学生姓名", type: "text", required: true, searchable: true, maxLength: 32 },
|
||||
{ key: "grade", label: "年级", type: "select", required: true, searchable: true, options: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }] },
|
||||
{ key: "nation", label: "民族", type: "select", searchable: true, options: [{ label: "汉族", value: "han" }, { label: "回族", value: "hui" }] },
|
||||
{ key: "birthday", label: "出生日期", type: "date", searchable: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/** 筛选候选:当前发布版本里 searchable 且有算子的字段(历史已删字段不出现在这里——后端同口径) */
|
||||
const FILTER_FIELDS: FilterField[] = [
|
||||
{ key: "name", label: "学生姓名", type: "text", operator: "PREFIX" },
|
||||
{ key: "grade", label: "年级", type: "select", operator: "EQ" },
|
||||
{ key: "nation", label: "民族", type: "select", operator: "EQ" },
|
||||
{ key: "birthday", label: "出生日期", type: "date", operator: "RANGE" }
|
||||
];
|
||||
|
||||
/** 会话用户(与 baseapi.ts 的 GetUserInfo 一致):id=186532 */
|
||||
const SESSION_USER = 186532;
|
||||
/** 被代填的学生(用来验 V18:代填后两视图分别可见) */
|
||||
const STUDENT = 20260001;
|
||||
|
||||
interface MockAnswer {
|
||||
answerId: string;
|
||||
version: number;
|
||||
status: string;
|
||||
submitterId: number;
|
||||
subjectId: number | null;
|
||||
answerNo: string | null;
|
||||
submitTime: string;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const seed: MockAnswer[] = [
|
||||
{
|
||||
// 管理员(会话用户)代填给 STUDENT:在"我提交的"里可见(提交人是我),也在 STUDENT 的"关于我的"里可见
|
||||
answerId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
|
||||
version: PUBLISHED_VERSION,
|
||||
status: "pending",
|
||||
submitterId: SESSION_USER,
|
||||
subjectId: STUDENT,
|
||||
answerNo: "honor_apply-20261014-0001",
|
||||
submitTime: "2026-10-14 09:12:33",
|
||||
values: {
|
||||
name: "张同学",
|
||||
grade: "opt_1",
|
||||
nation: "han",
|
||||
birthday: "2010-03-09",
|
||||
// 历史已删字段的残留值(V8:降级显示,不静默丢弃)
|
||||
legacy_class: "高二(3)班"
|
||||
}
|
||||
},
|
||||
{
|
||||
// 另一位学生自填:不在"我提交的"里,也不在"关于我的"里(用来验两视图的谓词各自成立)
|
||||
answerId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2",
|
||||
version: 1,
|
||||
status: "processed",
|
||||
submitterId: STUDENT,
|
||||
subjectId: null,
|
||||
answerNo: null,
|
||||
submitTime: "2026-10-13 15:40:02",
|
||||
values: { name: "李同学", grade: "opt_2", legacy_class: "高二(5)班" }
|
||||
},
|
||||
{
|
||||
// 又一条本人提交的(无代填主体):验"仅本人提交"范围文案里的行
|
||||
answerId: "ccccccccccccccccccccccccccccccc3",
|
||||
version: PUBLISHED_VERSION,
|
||||
status: "pending",
|
||||
submitterId: SESSION_USER,
|
||||
subjectId: null,
|
||||
answerNo: null,
|
||||
submitTime: "2026-10-12 08:05:11",
|
||||
values: { name: "王同学", grade: "opt_1", nation: "hui" }
|
||||
}
|
||||
];
|
||||
|
||||
function statusLabel(code: string): string | null {
|
||||
if (code === "pending") return "待处理";
|
||||
if (code === "processed") return "已处理";
|
||||
return null; // 已废弃 code
|
||||
}
|
||||
|
||||
function toRow(a: MockAnswer): AnswerRow {
|
||||
return {
|
||||
answerId: a.answerId,
|
||||
formUuid: FORM_UUID,
|
||||
version: a.version,
|
||||
status: a.status,
|
||||
statusLabel: statusLabel(a.status),
|
||||
submitterId: a.submitterId,
|
||||
subjectId: a.subjectId,
|
||||
answerNo: a.answerNo,
|
||||
submitTime: a.submitTime,
|
||||
values: a.values
|
||||
};
|
||||
}
|
||||
|
||||
/** 值匹配(简化:EQ 精确、PREFIX 前缀、RANGE 按日期串比较——只够驱动 UI 取证) */
|
||||
function matchFilter(a: MockAnswer, f: any): boolean {
|
||||
const v = a.values[f.key];
|
||||
if (v === undefined || v === null) return false;
|
||||
const text = String(v);
|
||||
if (f.from || f.to) {
|
||||
if (f.from && text < String(f.from)) return false;
|
||||
if (f.to && text > String(f.to)) return false;
|
||||
return true;
|
||||
}
|
||||
if (f.value === undefined || f.value === null || f.value === "") return true;
|
||||
const spec = FILTER_FIELDS.find((x) => x.key === f.key);
|
||||
if (spec?.operator === "PREFIX") return text.startsWith(String(f.value));
|
||||
return text === String(f.value);
|
||||
}
|
||||
|
||||
function filtered(body: any): MockAnswer[] {
|
||||
const inScope = (a: MockAnswer) => a; // mock 不做范围(后端做,T4 已验)
|
||||
const list = seed.map(inScope)
|
||||
.filter((a) => !body?.status || a.status === body.status)
|
||||
.filter((a) => !Array.isArray(body?.filters) || body.filters.every((f: any) => matchFilter(a, f)));
|
||||
return list.sort((a, b) => (body?.order === "asc" ? (a.submitTime < b.submitTime ? -1 : 1) : a.submitTime > b.submitTime ? -1 : 1));
|
||||
}
|
||||
|
||||
function bodyOf(raw: any): any {
|
||||
return typeof raw?.body === "string" ? JSON.parse(raw.body || "{}") : raw?.body || {};
|
||||
}
|
||||
|
||||
function queryValue(query: any, key: string): string {
|
||||
const v = query?.[key];
|
||||
return Array.isArray(v) ? String(v[0] ?? "") : String(v ?? "");
|
||||
}
|
||||
|
||||
function detailOf(answerId: string): AnswerDetail | null {
|
||||
const a = seed.find((x) => x.answerId === answerId);
|
||||
if (!a) return null;
|
||||
return {
|
||||
...toRow(a),
|
||||
appCode: APP,
|
||||
code: CODE,
|
||||
formName: "三好学生申报",
|
||||
// 钉住版本内联展开的 groups:v1 的点拿到 v1 的 schema(新字段不在旧数据上凭空出现)
|
||||
groups: a.version === 1 ? GROUPS_V1 : GROUPS_V2
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
// ==================== 读接口唯一入口 resolve(契约 §1) ====================
|
||||
// T9 快应用演练桩:中心上下文与快应用上下文都靠它把模块行里的 code 换成 uuid
|
||||
// (useFormCandidates 的 app 分支)。桩只有一份表单数据,所以**任何 code 都解析到同一张表**,
|
||||
// 回显请求里的 code/appCode——这是本机 UI 取证用,不替代后端 resolve 的任何判据。
|
||||
{
|
||||
url: "/api/form/resolve",
|
||||
method: "get",
|
||||
response: ({ query }: any) =>
|
||||
ok({
|
||||
formUuid: FORM_UUID,
|
||||
code: queryValue(query, "code") || CODE,
|
||||
appCode: queryValue(query, "appCode") || APP,
|
||||
name: "三好学生申报",
|
||||
version: 2,
|
||||
audience: 1,
|
||||
fillLimit: 0,
|
||||
answerNoEnabled: 0,
|
||||
deadline: null,
|
||||
deadlineReached: false,
|
||||
statusDefs: [
|
||||
{ code: "pending", label: "待处理", is_deleted: false },
|
||||
{ code: "processed", label: "已处理", is_deleted: false }
|
||||
],
|
||||
groups: GROUPS_V2
|
||||
})
|
||||
},
|
||||
// ==================== 数据面:列表 / 详情 / 筛选候选 / 统计 ====================
|
||||
{ url: "/api/form/admin/data/list", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const all = filtered(body);
|
||||
const size = Number(body?.size ?? 20);
|
||||
const cursor = body?.cursor ? String(body.cursor) : "";
|
||||
const start = cursor ? all.findIndex((a) => a.answerId === cursor) + 1 : 0;
|
||||
const page = all.slice(start, start + size);
|
||||
const hasMore = start + size < all.length;
|
||||
const view: ListView = {
|
||||
rows: page.map(toRow),
|
||||
nextCursor: hasMore ? page[page.length - 1].answerId : null,
|
||||
size,
|
||||
// 范围枚举名(前端只展示,不过滤)
|
||||
scopes: ["ALL"]
|
||||
};
|
||||
return ok(view);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/detail", method: "get", response: ({ query }: any) => {
|
||||
const d = detailOf(queryValue(query, "answerId"));
|
||||
return d ? ok(d) : bizErr(3, "答卷不在你的数据范围内");
|
||||
} },
|
||||
{ url: "/api/form/admin/data/filterFields", method: "get", response: () => ok(FILTER_FIELDS) },
|
||||
{ url: "/api/form/admin/data/stats", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const all = filtered(body);
|
||||
const byStatusMap = new Map<string, number>();
|
||||
all.forEach((a) => byStatusMap.set(a.status, (byStatusMap.get(a.status) || 0) + 1));
|
||||
const byStatus = [...byStatusMap.entries()].map(([value, count]) => ({ value, label: statusLabel(value), count }));
|
||||
const byField = [];
|
||||
if (body?.groupByField) {
|
||||
const key = String(body.groupByField);
|
||||
const map = new Map<string, number>();
|
||||
all.forEach((a) => {
|
||||
const v = a.values[key];
|
||||
if (v !== undefined && v !== null && String(v) !== "") map.set(String(v), (map.get(String(v)) || 0) + 1);
|
||||
});
|
||||
const spec = FILTER_FIELDS.find((f) => f.key === key);
|
||||
const options = ((({ grade: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }], nation: [{ label: "汉族", value: "han" }, { label: "回族", value: "hui" }] }) as any)[key] || []) as Array<{ label: string; value: string }>;
|
||||
byField.push({
|
||||
key,
|
||||
label: spec?.label || key,
|
||||
items: [...map.entries()].map(([value, count]) => ({
|
||||
value,
|
||||
label: options.find((o) => o.value === value)?.label ?? null,
|
||||
count
|
||||
}))
|
||||
});
|
||||
}
|
||||
const view: StatsView = { total: all.length, byStatus, byField, cached: false };
|
||||
return ok(view);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/edit", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const a = seed.find((x) => x.answerId === body.answerId);
|
||||
if (!a) return bizErr(3, "答卷不在你的数据范围内");
|
||||
// 按钉住版本拒绝未知 key(后端 rejectUnknownKeys 的同口径)
|
||||
const fields = (a.version === 1 ? GROUPS_V1 : GROUPS_V2)[0].fields.map((f: any) => f.key);
|
||||
for (const k of Object.keys(body.values || {})) {
|
||||
if (!fields.includes(k)) return bizErr(2, `未知字段 key:${k}(按钉住版本 v${a.version} 校验)`);
|
||||
}
|
||||
a.values = { ...a.values, ...body.values };
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/softDelete", method: "post", response: ({ query }: any) => {
|
||||
const id = queryValue(query, "answerId");
|
||||
const idx = seed.findIndex((x) => x.answerId === id);
|
||||
if (idx < 0) return bizErr(3, "答卷不在你的数据范围内");
|
||||
seed.splice(idx, 1);
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/changeStatus", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const a = seed.find((x) => x.answerId === body.answerId);
|
||||
if (!a) return bizErr(3, "答卷不在你的数据范围内");
|
||||
a.status = String(body.status);
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/batch/softDelete", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const results: BatchItemResult[] = (body.answerIds || []).map((id: string) => {
|
||||
const idx = seed.findIndex((x) => x.answerId === id);
|
||||
if (idx < 0) return { answerId: id, success: false, msg: "不在你的数据范围内" };
|
||||
seed.splice(idx, 1);
|
||||
return { answerId: id, success: true, msg: null };
|
||||
});
|
||||
return ok(results);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/batch/changeStatus", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const results: BatchItemResult[] = (body.answerIds || []).map((id: string) => {
|
||||
const a = seed.find((x) => x.answerId === id);
|
||||
if (!a) return { answerId: id, success: false, msg: "不在你的数据范围内" };
|
||||
a.status = String(body.status);
|
||||
return { answerId: id, success: true, msg: null };
|
||||
});
|
||||
return ok(results);
|
||||
} },
|
||||
|
||||
// ==================== 我的填写两视图(V18) ====================
|
||||
{ url: "/api/form/my/submit-list", method: "get", response: () => {
|
||||
const items: MyAnswerViewItem[] = seed
|
||||
.filter((a) => a.submitterId === SESSION_USER)
|
||||
.map((a) => ({
|
||||
answerId: a.answerId,
|
||||
formUuid: FORM_UUID,
|
||||
appCode: APP,
|
||||
code: CODE,
|
||||
formName: "三好学生申报",
|
||||
version: a.version,
|
||||
status: a.status,
|
||||
statusLabel: statusLabel(a.status),
|
||||
submitterId: a.submitterId,
|
||||
subjectId: a.subjectId,
|
||||
answerNo: a.answerNo,
|
||||
submitTime: a.submitTime
|
||||
}));
|
||||
return ok(items);
|
||||
} },
|
||||
{ url: "/api/form/my/about-list", method: "get", response: () => {
|
||||
// 会话用户是被代填人(subject)的那几条:mock 里把 SESSION_USER 当成被代填主体来驱动页面
|
||||
const items: MyAnswerViewItem[] = seed
|
||||
.filter((a) => a.subjectId === STUDENT || a.subjectId === SESSION_USER)
|
||||
.map((a) => ({
|
||||
answerId: a.answerId,
|
||||
formUuid: FORM_UUID,
|
||||
appCode: APP,
|
||||
code: CODE,
|
||||
formName: "三好学生申报",
|
||||
version: a.version,
|
||||
status: a.status,
|
||||
statusLabel: statusLabel(a.status),
|
||||
submitterId: a.submitterId,
|
||||
subjectId: a.subjectId,
|
||||
answerNo: a.answerNo,
|
||||
submitTime: a.submitTime
|
||||
}));
|
||||
return ok(items);
|
||||
} },
|
||||
{ url: "/api/form/my/detail", method: "get", response: ({ query }: any) => {
|
||||
const d = detailOf(queryValue(query, "answerId"));
|
||||
return d ? ok(d) : bizErr(3, "答卷不存在或不属于你");
|
||||
} },
|
||||
{ url: "/api/form/my/delete", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const idx = seed.findIndex((x) => x.answerId === body.answerId && x.submitterId === SESSION_USER);
|
||||
if (idx < 0) return bizErr(3, "只能删除自己提交的答卷");
|
||||
seed.splice(idx, 1);
|
||||
return ok(null);
|
||||
} }
|
||||
];
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* 定义域与字典的本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 即 --mode mock 生效)。
|
||||
*
|
||||
* 定位(沿 P48 的降级取证口径):本地 CAS/UserBase 未起、库里没有 form-center 应用行,
|
||||
* 真会话进不了 `/api/form/admin/*`;本文件让设计器与字典页在浏览器里可点、可截图取证,
|
||||
* 并**不**替代后端权威——schema 契约的判定在 `utils/designer/schema.ts` 与后端
|
||||
* `FormSchemaValidator` 两侧同源,t6 冒烟脚本拿真后端校验器跑导出的 schema 复核。
|
||||
*
|
||||
* 状态是进程内内存:一次 dev:mock 生命周期内 create→save→publish→copy 的推进可复现,
|
||||
* 重启即回种子数据。种子刻意做成「已发布 v1 + 待发布草稿 v2」,用来验 P20 的两根指针区分。
|
||||
*/
|
||||
import type {
|
||||
DictItemView,
|
||||
DictSaveRequest,
|
||||
DictView,
|
||||
FormConfigRequest,
|
||||
FormCopyRequest,
|
||||
FormCreateRequest,
|
||||
FormSaveRequest,
|
||||
FormView,
|
||||
StatusDefsRequest,
|
||||
VersionView
|
||||
} from "@/types/form";
|
||||
import { AUDIENCE, FORM_STATUS } from "@/types/form";
|
||||
|
||||
const ok = (data: any) => ({ code: 0, msg: "", data });
|
||||
const bizErr = (code: number, msg: string) => ({ code, msg, data: null });
|
||||
|
||||
const SCHOOL = 3260;
|
||||
const PLATFORM = 0;
|
||||
const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const KEY_RE = /^[A-Za-z0-9_]{1,64}$/;
|
||||
/** 后端 updateStatusDefs 传 null 时回落的默认集合(FormStatusDefs.DEFAULT_DEFS_JSON 同形态) */
|
||||
const DEFAULT_STATUS_DEFS = JSON.parse(
|
||||
'[{"code":"pending","label":"待处理","is_deleted":false},{"code":"processed","label":"已处理","is_deleted":false}]'
|
||||
);
|
||||
|
||||
interface MockForm extends FormView {
|
||||
/** 版本行:version → schema 原文(已发布行不再改写,V7) */
|
||||
rows: { version: number; schemaJson: string; changeLog: string | null; publishTime: string | null }[];
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
interface MockDict extends DictView {
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
let uuidSeq = 0;
|
||||
function newUuid(): string {
|
||||
uuidSeq += 1;
|
||||
const base = Date.now().toString(16) + uuidSeq.toString(16).padStart(4, "0");
|
||||
return (base + "0".repeat(32)).slice(0, 32).replace(/[^0-9a-f]/g, "0");
|
||||
}
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
const SEED_SCHEMA_V1 = JSON.stringify({
|
||||
groups: [
|
||||
{
|
||||
key: "base",
|
||||
label: "基本信息",
|
||||
fields: [
|
||||
{ key: "name", label: "学生姓名", type: "text", required: true, maxLength: 32 },
|
||||
{ key: "grade", label: "年级", type: "select", required: true, searchable: true, options: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
const SEED_SCHEMA_V2 = JSON.stringify({
|
||||
groups: [
|
||||
{
|
||||
key: "base",
|
||||
label: "基本信息",
|
||||
fields: [
|
||||
{ key: "name", label: "学生姓名", type: "text", required: true, maxLength: 32 },
|
||||
{ key: "grade", label: "年级", type: "select", required: true, searchable: true, options: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }] },
|
||||
// v2 草稿新增:字典引用活字段 + 可筛日期(P28 之外才可勾)
|
||||
{ key: "nation", label: "民族", type: "select", options: { source: "dict:nation" } },
|
||||
{ key: "birthday", label: "出生日期", type: "date", searchable: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const forms: MockForm[] = [
|
||||
{
|
||||
formUuid: "mock0000000000000000000000000001",
|
||||
schId: SCHOOL,
|
||||
appCode: "form-center",
|
||||
code: "honor_apply",
|
||||
name: "三好学生申报",
|
||||
status: FORM_STATUS.PUBLISHED,
|
||||
statusName: "PUBLISHED",
|
||||
currentVersion: 1,
|
||||
draftVersion: 2,
|
||||
audience: AUDIENCE.STUDENT,
|
||||
fillLimit: 1,
|
||||
deadline: null,
|
||||
answerNoEnabled: 0,
|
||||
groupTag: null,
|
||||
statusDefs: DEFAULT_STATUS_DEFS,
|
||||
createTime: now(),
|
||||
updateTime: now(),
|
||||
rows: [
|
||||
{ version: 1, schemaJson: SEED_SCHEMA_V1, changeLog: "首版发布", publishTime: now() },
|
||||
{ version: 2, schemaJson: SEED_SCHEMA_V2, changeLog: "加民族/出生日期", publishTime: null }
|
||||
],
|
||||
deleted: false
|
||||
}
|
||||
];
|
||||
|
||||
const dicts: MockDict[] = [
|
||||
{
|
||||
schId: PLATFORM,
|
||||
platformLevel: true,
|
||||
code: "nation",
|
||||
name: "民族",
|
||||
items: [
|
||||
{ value: "han", label: "汉族", sort: 1, deleted: false },
|
||||
{ value: "hui", label: "回族", sort: 2, deleted: false },
|
||||
{ value: "man", label: "满族", sort: 3, deleted: false },
|
||||
// 软删条目:设计器不可新选,旧答卷的该 value 提交仍放行
|
||||
{ value: "other_old", label: "其它(旧)", sort: 4, deleted: true }
|
||||
],
|
||||
deleted: false
|
||||
},
|
||||
{
|
||||
schId: PLATFORM,
|
||||
platformLevel: true,
|
||||
code: "gender",
|
||||
name: "性别",
|
||||
items: [
|
||||
{ value: "male", label: "男", sort: 1, deleted: false },
|
||||
{ value: "female", label: "女", sort: 2, deleted: false }
|
||||
],
|
||||
deleted: false
|
||||
},
|
||||
{
|
||||
schId: SCHOOL,
|
||||
platformLevel: false,
|
||||
code: "subject",
|
||||
name: "获奖学科(本校)",
|
||||
items: [
|
||||
{ value: "math", label: "数学", sort: 1, deleted: false },
|
||||
{ value: "phy", label: "物理", sort: 2, deleted: false }
|
||||
],
|
||||
deleted: false
|
||||
}
|
||||
];
|
||||
|
||||
function view(f: MockForm): FormView {
|
||||
return {
|
||||
formUuid: f.formUuid,
|
||||
schId: f.schId,
|
||||
appCode: f.appCode,
|
||||
code: f.code,
|
||||
name: f.name,
|
||||
status: f.status,
|
||||
statusName: f.statusName,
|
||||
currentVersion: f.currentVersion,
|
||||
draftVersion: f.draftVersion,
|
||||
audience: f.audience,
|
||||
fillLimit: f.fillLimit,
|
||||
deadline: f.deadline,
|
||||
answerNoEnabled: f.answerNoEnabled,
|
||||
groupTag: f.groupTag,
|
||||
statusDefs: f.statusDefs ? JSON.parse(JSON.stringify(f.statusDefs)) : null,
|
||||
createTime: f.createTime,
|
||||
updateTime: f.updateTime
|
||||
};
|
||||
}
|
||||
|
||||
function draftOf(f: MockForm) {
|
||||
return f.rows.filter((r) => !r.publishTime).map((r) => r.version).sort((a, b) => b - a)[0] ?? null;
|
||||
}
|
||||
|
||||
function syncPointers(f: MockForm) {
|
||||
f.draftVersion = draftOf(f);
|
||||
f.updateTime = now();
|
||||
}
|
||||
|
||||
function findForm(uuid: string): MockForm | undefined {
|
||||
return forms.find((x) => x.formUuid === uuid && !x.deleted);
|
||||
}
|
||||
|
||||
/** 草稿行直改;没有草稿行就开 max+1 新行,头表指针不动(后端 save 的三条分支) */
|
||||
function writeDraft(f: MockForm, schemaJson: string, changeLog?: string) {
|
||||
const draft = f.rows.find((r) => !r.publishTime && r.version === draftOf(f));
|
||||
if (draft) {
|
||||
draft.schemaJson = schemaJson;
|
||||
if (changeLog !== undefined) draft.changeLog = changeLog;
|
||||
} else {
|
||||
const version = Math.max(...f.rows.map((r) => r.version)) + 1;
|
||||
f.rows.push({ version, schemaJson, changeLog: changeLog || null, publishTime: null });
|
||||
}
|
||||
syncPointers(f);
|
||||
}
|
||||
|
||||
function schemaFields(schemaJson: string): any[] {
|
||||
try {
|
||||
const parsed = JSON.parse(schemaJson || "{}");
|
||||
return (parsed.groups || []).flatMap((g: any) => g.fields || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 字典两级解析:同 code 本校遮蔽平台级(契约 §1 校域不变式的字典侧) */
|
||||
function resolveDict(code: string, schId = SCHOOL): MockDict | undefined {
|
||||
return dicts.find((d) => d.code === code && d.schId === schId && !d.deleted)
|
||||
|| dicts.find((d) => d.code === code && d.schId === PLATFORM && !d.deleted);
|
||||
}
|
||||
|
||||
/** 发布前的字典可解析性(后端 assertRefsResolvable:字典缺失或无未删条目即拒) */
|
||||
function assertDictRefs(schemaJson: string): string | null {
|
||||
for (const f of schemaFields(schemaJson)) {
|
||||
const source = f.options && !Array.isArray(f.options) ? String(f.options.source || "") : "";
|
||||
if (!source.startsWith("dict:")) continue;
|
||||
const dict = resolveDict(source.slice(5));
|
||||
if (!dict) return `字典引用无法解析:${source}`;
|
||||
if (!dict.items.some((i) => !i.deleted)) return `字典 ${dict.code} 没有可用条目,不能发布`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 跨版本字段身份:同 key 不得换 type(后端 assertFieldTypesStable) */
|
||||
function assertTypeStable(f: MockForm, schemaJson: string): string | null {
|
||||
if (f.currentVersion === null) return null;
|
||||
const published = f.rows.find((r) => r.version === f.currentVersion);
|
||||
if (!published) return null;
|
||||
const oldTypes = new Map(schemaFields(published.schemaJson).map((x) => [x.key, x.type]));
|
||||
for (const field of schemaFields(schemaJson)) {
|
||||
const old = oldTypes.get(field.key);
|
||||
if (old && old !== field.type) return `字段「${field.key}」key 已发布过,type 不可变更(${old}→${field.type})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queryValue(query: any, key: string): string {
|
||||
const v = query?.[key];
|
||||
return Array.isArray(v) ? String(v[0] ?? "") : String(v ?? "");
|
||||
}
|
||||
|
||||
function bodyOf(raw: any): any {
|
||||
return typeof raw?.body === "string" ? JSON.parse(raw.body || "{}") : raw?.body || {};
|
||||
}
|
||||
|
||||
/** GET 端点的目标域:不带 schId=会话校(mock 里固定 3260) */
|
||||
function targetSchId(query: any): number {
|
||||
const s = queryValue(query, "schId");
|
||||
return s === "" ? SCHOOL : Number(s);
|
||||
}
|
||||
|
||||
/** 字典端点的目标域在**请求体**里(后端 @RequestBody DictSaveRequest/DictItemRequest 带 schId) */
|
||||
function dictForm(raw: any): number {
|
||||
const body = bodyOf(raw);
|
||||
return body?.schId === undefined || body?.schId === null ? SCHOOL : Number(body.schId);
|
||||
}
|
||||
|
||||
export default [
|
||||
{ url: "/api/form/admin/list", method: "get", response: ({ query }: any) => {
|
||||
const schId = queryValue(query, "schId") === "" ? SCHOOL : Number(queryValue(query, "schId"));
|
||||
const appCode = queryValue(query, "appCode");
|
||||
const status = queryValue(query, "status");
|
||||
const list = forms
|
||||
.filter((f) => !f.deleted && f.schId === schId)
|
||||
.filter((f) => !appCode || f.appCode === appCode)
|
||||
.filter((f) => status === "" || f.status === Number(status))
|
||||
.map(view);
|
||||
return ok(list);
|
||||
} },
|
||||
{ url: "/api/form/admin/get", method: "get", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
return f ? ok(view(f)) : bizErr(2, "表单未找到");
|
||||
} },
|
||||
{ url: "/api/form/admin/versions", method: "get", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
const rows: VersionView[] = f.rows.map((r) => ({
|
||||
formUuid: f.formUuid,
|
||||
version: r.version,
|
||||
published: !!r.publishTime,
|
||||
changeLog: r.changeLog,
|
||||
publishTime: r.publishTime,
|
||||
createTime: now(),
|
||||
updateTime: now()
|
||||
}));
|
||||
return ok(rows);
|
||||
} },
|
||||
{ url: "/api/form/admin/version", method: "get", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
const row = f?.rows.find((r) => r.version === Number(queryValue(query, "version")));
|
||||
if (!f || !row) return bizErr(2, "版本不存在");
|
||||
return ok({
|
||||
formUuid: f.formUuid,
|
||||
version: row.version,
|
||||
published: !!row.publishTime,
|
||||
changeLog: row.changeLog,
|
||||
publishTime: row.publishTime,
|
||||
createTime: now(),
|
||||
updateTime: now(),
|
||||
schemaJson: row.schemaJson
|
||||
} as VersionView);
|
||||
} },
|
||||
{ url: "/api/form/admin/filterFields", method: "get", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f || f.currentVersion === null) return ok([]);
|
||||
const row = f.rows.find((r) => r.version === f.currentVersion);
|
||||
const keys = schemaFields(row?.schemaJson || "")
|
||||
.filter((x: any) => x.searchable === true && x.type !== "upload" && x.type !== "image")
|
||||
.map((x: any) => String(x.key));
|
||||
return ok(keys);
|
||||
} },
|
||||
{ url: "/api/form/admin/create", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as FormCreateRequest;
|
||||
if (!CODE_RE.test(String(body.code || "").trim())) return bizErr(2, `表单 code 非法:${body.code}`);
|
||||
if (!String(body.name || "").trim()) return bizErr(2, "表单名称不能为空");
|
||||
const code = String(body.code).trim();
|
||||
if (forms.some((f) => !f.deleted && f.schId === SCHOOL && f.appCode === (body.appCode || "form-center") && f.code === code)) {
|
||||
return bizErr(2, `同应用下 code 已存在:${code}`);
|
||||
}
|
||||
const schemaJson = body.schemaJson && body.schemaJson.trim() ? body.schemaJson : '{"groups":[]}';
|
||||
const form: MockForm = {
|
||||
formUuid: newUuid(),
|
||||
schId: SCHOOL,
|
||||
appCode: body.appCode || "form-center",
|
||||
code,
|
||||
name: String(body.name).trim(),
|
||||
status: FORM_STATUS.DRAFT,
|
||||
statusName: "DRAFT",
|
||||
currentVersion: null,
|
||||
draftVersion: 1,
|
||||
audience: AUDIENCE.STUDENT,
|
||||
fillLimit: 0,
|
||||
deadline: null,
|
||||
answerNoEnabled: 0,
|
||||
groupTag: null,
|
||||
statusDefs: DEFAULT_STATUS_DEFS,
|
||||
createTime: now(),
|
||||
updateTime: now(),
|
||||
rows: [{ version: 1, schemaJson, changeLog: null, publishTime: null }],
|
||||
deleted: false
|
||||
};
|
||||
forms.push(form);
|
||||
return ok(view(form));
|
||||
} },
|
||||
{ url: "/api/form/admin/save", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as FormSaveRequest;
|
||||
const f = findForm(body.uuid);
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (body.name && String(body.name).trim()) f.name = String(body.name).trim();
|
||||
if (body.code && body.code.trim() !== f.code) {
|
||||
if (f.currentVersion !== null) return bizErr(5, "code 发布后不可改(改 code=换表单,契约 §1)");
|
||||
if (!CODE_RE.test(body.code.trim())) return bizErr(2, `表单 code 非法:${body.code}`);
|
||||
f.code = body.code.trim();
|
||||
}
|
||||
if (body.schemaJson && body.schemaJson.trim()) {
|
||||
try {
|
||||
JSON.parse(body.schemaJson);
|
||||
} catch {
|
||||
return bizErr(2, "表单结构不是合法 JSON");
|
||||
}
|
||||
const dictErr = assertDictRefs(body.schemaJson);
|
||||
if (dictErr) return bizErr(2, dictErr);
|
||||
const typeErr = assertTypeStable(f, body.schemaJson);
|
||||
if (typeErr) return bizErr(2, typeErr);
|
||||
writeDraft(f, body.schemaJson, body.changeLog);
|
||||
}
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/publish", method: "post", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
const draft = f.rows.find((r) => !r.publishTime && r.version === draftOf(f));
|
||||
if (!draft) return bizErr(5, "没有待发布的草稿版本;已发布表单请先到设计器保存升版");
|
||||
const dictErr = assertDictRefs(draft.schemaJson);
|
||||
if (dictErr) return bizErr(2, dictErr);
|
||||
const typeErr = assertTypeStable(f, draft.schemaJson);
|
||||
if (typeErr) return bizErr(2, typeErr);
|
||||
const changeLog = queryValue(query, "changeLog");
|
||||
draft.publishTime = now();
|
||||
if (changeLog) draft.changeLog = changeLog;
|
||||
f.currentVersion = draft.version;
|
||||
f.status = FORM_STATUS.PUBLISHED;
|
||||
f.statusName = "PUBLISHED";
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/disable", method: "post", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (f.status !== FORM_STATUS.PUBLISHED) return bizErr(5, "仅已发布的表单可停用");
|
||||
f.status = FORM_STATUS.DISABLED;
|
||||
f.statusName = "DISABLED";
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/enable", method: "post", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (f.status !== FORM_STATUS.DISABLED) return bizErr(5, "仅停用的表单可恢复;草稿请走发布");
|
||||
f.status = FORM_STATUS.PUBLISHED;
|
||||
f.statusName = "PUBLISHED";
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/softDelete", method: "post", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
f.deleted = true;
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/copy", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as FormCopyRequest;
|
||||
const source = findForm(body.uuid);
|
||||
if (!source) return bizErr(2, "源表单未找到");
|
||||
if (!CODE_RE.test(String(body.code || "").trim())) return bizErr(2, `副本 code 非法:${body.code}`);
|
||||
if (forms.some((f) => !f.deleted && f.code === body.code.trim())) return bizErr(2, "同应用下 code 已存在");
|
||||
// 副本取源表单最新版本行(后端 copySourceVersion 同口径:有草稿取草稿,否则取当前发布版)
|
||||
const srcRow = source.rows.find((r) => !r.publishTime && r.version === draftOf(source))
|
||||
|| source.rows.find((r) => r.version === source.currentVersion)
|
||||
|| source.rows[source.rows.length - 1];
|
||||
const copy: MockForm = {
|
||||
...JSON.parse(JSON.stringify(source)),
|
||||
formUuid: newUuid(),
|
||||
code: body.code.trim(),
|
||||
name: String(body.name || source.name).trim(),
|
||||
status: FORM_STATUS.DRAFT,
|
||||
statusName: "DRAFT",
|
||||
currentVersion: null,
|
||||
draftVersion: 1,
|
||||
deadline: null,
|
||||
rows: [{ version: 1, schemaJson: srcRow.schemaJson, changeLog: `另存为副本,源表单 ${source.appCode}/${source.code} v${srcRow.version}`, publishTime: null }],
|
||||
deleted: false
|
||||
};
|
||||
forms.push(copy);
|
||||
return ok(view(copy));
|
||||
} },
|
||||
{ url: "/api/form/admin/config", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as FormConfigRequest;
|
||||
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;
|
||||
}
|
||||
if (body.fillLimit !== undefined) {
|
||||
if (body.fillLimit !== 0 && body.fillLimit !== 1) return bizErr(2, "fillLimit 只能是 0 或 1");
|
||||
f.fillLimit = body.fillLimit;
|
||||
}
|
||||
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 位");
|
||||
f.answerNoEnabled = body.answerNoEnabled;
|
||||
}
|
||||
if (body.deadline !== undefined) f.deadline = body.deadline ? String(body.deadline) : null;
|
||||
if (body.groupTag !== undefined) f.groupTag = body.groupTag ? String(body.groupTag) : null;
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/statusDefs", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as StatusDefsRequest;
|
||||
const f = findForm(body.uuid);
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (body.statusDefs === null || body.statusDefs === undefined) {
|
||||
f.statusDefs = JSON.parse(JSON.stringify(DEFAULT_STATUS_DEFS));
|
||||
return ok(view(f));
|
||||
}
|
||||
if (!Array.isArray(body.statusDefs) || body.statusDefs.length === 0) return bizErr(2, "状态定义必须是非空 JSON 数组");
|
||||
const codes = new Set<string>();
|
||||
for (const def of body.statusDefs) {
|
||||
const code = String(def?.code || "");
|
||||
// 后端 FormStatusDefs.normalize:code 非空、≤64、唯一(含软删)、label 非空 ≤128、至少留一个未废弃
|
||||
if (!code.trim() || code.length > 64) return bizErr(2, `状态 code 非法(非空且 ≤64):${code}`);
|
||||
if (codes.has(code)) return bizErr(2, `状态 code 重复:${code}`);
|
||||
codes.add(code);
|
||||
const label = String(def?.label || "");
|
||||
if (!label.trim() || label.length > 128) return bizErr(2, `状态 label 非法(非空且 ≤128):${code}`);
|
||||
}
|
||||
if (!body.statusDefs.some((d) => !d.is_deleted)) return bizErr(2, "至少要保留一个可用状态");
|
||||
f.statusDefs = body.statusDefs.map((d) => ({ code: d.code, label: d.label, is_deleted: !!d.is_deleted }));
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
|
||||
// ==================== 选项字典 ====================
|
||||
{ url: "/api/form/admin/dict/list", method: "get", response: ({ query }: any) => {
|
||||
const schId = targetSchId(query);
|
||||
return ok(dicts.filter((d) => !d.deleted && d.schId === schId).map((d) => ({ ...d, items: d.items.map((i) => ({ ...i })) })));
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/selectable", method: "get", response: ({ query }: any) => {
|
||||
const schId = targetSchId(query);
|
||||
const byCode = new Map<string, MockDict>();
|
||||
dicts
|
||||
.filter((d) => !d.deleted && (d.schId === schId || d.schId === PLATFORM))
|
||||
.forEach((d) => {
|
||||
const held = byCode.get(d.code);
|
||||
if (!held || d.schId === schId) byCode.set(d.code, d);
|
||||
});
|
||||
const list: DictView[] = [...byCode.values()].map((d) => ({
|
||||
...d,
|
||||
// 设计器候选=未删条目(软删条目不可新选,契约 §2.1)
|
||||
items: d.items.filter((i) => !i.deleted).map((i) => ({ ...i }))
|
||||
}));
|
||||
return ok(list);
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/save", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as DictSaveRequest;
|
||||
const schId = dictForm(ctx);
|
||||
const code = String(body.code || "").trim();
|
||||
if (!KEY_RE.test(code)) return bizErr(2, `字典 code 仅限字母/数字/下划线(≤64 位):${code}`);
|
||||
if (!String(body.name || "").trim()) return bizErr(2, "字典名称不能为空");
|
||||
let dict = dicts.find((d) => d.schId === schId && d.code === code);
|
||||
if (!dict) {
|
||||
dict = { schId, platformLevel: schId === PLATFORM, code, name: String(body.name).trim(), items: [], deleted: false };
|
||||
dicts.push(dict);
|
||||
} else {
|
||||
// code 建后不可改(它是 schema 的引用键),这里只改名
|
||||
dict.name = String(body.name).trim();
|
||||
dict.deleted = false;
|
||||
}
|
||||
return ok({ ...dict, items: dict.items.map((i) => ({ ...i })) } as DictView);
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/delete", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as DictSaveRequest;
|
||||
const dict = dicts.find((d) => d.schId === dictForm(ctx) && d.code === String(body.code || "").trim());
|
||||
if (!dict) return bizErr(2, "字典未找到");
|
||||
dict.deleted = true;
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/item/save", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const dict = dicts.find((d) => d.schId === dictForm(ctx) && d.code === String(body.dictCode || ""));
|
||||
if (!dict) return bizErr(2, `字典未找到:${body.dictCode}`);
|
||||
const value = String(body.value || "").trim();
|
||||
if (!value) return bizErr(2, "字典条目 value 不能为空");
|
||||
if (value.length > 64) return bizErr(2, `字典条目 value 过长(≤64):${value}`);
|
||||
if (!String(body.label || "").trim()) return bizErr(2, "字典条目 label 不能为空");
|
||||
const hit = dict.items.find((i) => i.value === value);
|
||||
const item: DictItemView = hit || { value, label: "", sort: 0, deleted: false };
|
||||
// value 是定位键:接口上没有改 value 的通道,能改的只有 label/sort(软删条目被重新编辑=放回可选集)
|
||||
item.label = String(body.label).trim();
|
||||
if (body.sort !== undefined && body.sort !== null) item.sort = Number(body.sort);
|
||||
item.deleted = false;
|
||||
if (!hit) dict.items.push(item);
|
||||
return ok({ ...item });
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/item/delete", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const dict = dicts.find((d) => d.schId === dictForm(ctx) && d.code === String(body.dictCode || ""));
|
||||
const item = dict?.items.find((i) => i.value === String(body.value || "").trim());
|
||||
if (!item) return bizErr(2, `字典条目不存在:${body.dictCode}/${body.value}`);
|
||||
item.deleted = true;
|
||||
return ok(null);
|
||||
} }
|
||||
];
|
||||
Reference in New Issue
Block a user