feat(t9): P76 导出锚点判树修正 + P77 应用内详情形态 + 观察项①勾选回填 + 演练桩
build / build (push) Successful in 3m17s

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:
chenxing
2026-10-15 12:57:10 +08:00
parent fdd9aaaa3b
commit 14d45f7e3c
61 changed files with 9236 additions and 72 deletions
+79 -1
View File
@@ -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);
}
+371
View File
@@ -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);
} }
];
+558
View File
@@ -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);
} }
];