From 14d45f7e3c03c5b8f605c0a91275948c675cd922 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 15 Oct 2026 12:57:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(t9):=20P76=20=E5=AF=BC=E5=87=BA=E9=94=9A?= =?UTF-8?q?=E7=82=B9=E5=88=A4=E6=A0=91=E4=BF=AE=E6=AD=A3=20+=20P77=20?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=86=85=E8=AF=A6=E6=83=85=E5=BD=A2=E6=80=81?= =?UTF-8?q?=20+=20=E8=A7=82=E5=AF=9F=E9=A1=B9=E2=91=A0=E5=8B=BE=E9=80=89?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB=20+=20=E6=BC=94=E7=BB=83=E6=A1=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 推送分层)。 --- package-lock.json | 21 +- package.json | 3 +- src/App.vue | 7 + src/api/module/data.ts | 148 ++++ src/api/module/dict.ts | 56 ++ src/api/module/fill.ts | 57 ++ src/api/module/form.ts | 116 +++ src/assets/mock/baseapi.ts | 80 +- src/assets/mock/dataapi.ts | 371 +++++++++ src/assets/mock/formapi.ts | 558 +++++++++++++ src/components/answer/AnswerEditModal.vue | 188 +++++ src/components/answer/AnswerValueView.vue | 179 +++++ src/components/designer/ComponentLibrary.vue | 122 +++ src/components/designer/DesignerCanvas.vue | 274 +++++++ src/components/designer/FieldPropPanel.vue | 463 +++++++++++ src/components/fields/checkbox/app.vue | 13 + src/components/fields/checkbox/pc.vue | 31 + src/components/fields/date/app.vue | 13 + src/components/fields/date/pc.vue | 30 + src/components/fields/image/app.vue | 13 + src/components/fields/image/pc.vue | 150 ++++ src/components/fields/index.ts | 56 ++ src/components/fields/multiselect/app.vue | 13 + src/components/fields/multiselect/pc.vue | 32 + src/components/fields/number/app.vue | 13 + src/components/fields/number/pc.vue | 44 ++ src/components/fields/phone/app.vue | 13 + src/components/fields/phone/pc.vue | 29 + src/components/fields/radio/app.vue | 13 + src/components/fields/radio/pc.vue | 31 + src/components/fields/select/app.vue | 13 + src/components/fields/select/pc.vue | 32 + src/components/fields/text/app.vue | 13 + src/components/fields/text/pc.vue | 29 + src/components/fields/textarea/app.vue | 13 + src/components/fields/textarea/pc.vue | 29 + src/components/fields/types.ts | 32 + src/components/fields/upload/app.vue | 13 + src/components/fields/upload/pc.vue | 135 ++++ src/components/form/FormConfigModal.vue | 321 ++++++++ src/components/layout.vue | 20 +- src/router/routes.ts | 47 +- src/store/index.ts | 38 +- src/types/answer.ts | 63 ++ src/types/data.ts | 140 ++++ src/types/form.ts | 142 ++++ src/types/formSchema.ts | 255 ++++++ src/utils/dataValue.ts | 297 +++++++ src/utils/designer/schema.ts | 547 +++++++++++++ src/utils/embed.ts | 113 +++ src/utils/hook/useFormCandidates.ts | 120 +++ src/utils/modulePerms.ts | 88 +++ src/views/AppContext.vue | 269 ++++++- src/views/Fill.vue | 527 ++++++++++++- src/views/My.vue | 278 ++++++- src/views/admin/AnswerDetail.vue | 246 ++++++ src/views/admin/Data.vue | 776 ++++++++++++++++++- src/views/admin/Dicts.vue | 423 +++++++++- src/views/admin/FormDesigner.vue | 479 ++++++++++++ src/views/admin/Forms.vue | 374 ++++++++- src/views/admin/Stats.vue | 299 ++++++- 61 files changed, 9236 insertions(+), 72 deletions(-) create mode 100644 src/api/module/data.ts create mode 100644 src/api/module/dict.ts create mode 100644 src/api/module/fill.ts create mode 100644 src/api/module/form.ts create mode 100644 src/assets/mock/dataapi.ts create mode 100644 src/assets/mock/formapi.ts create mode 100644 src/components/answer/AnswerEditModal.vue create mode 100644 src/components/answer/AnswerValueView.vue create mode 100644 src/components/designer/ComponentLibrary.vue create mode 100644 src/components/designer/DesignerCanvas.vue create mode 100644 src/components/designer/FieldPropPanel.vue create mode 100644 src/components/fields/checkbox/app.vue create mode 100644 src/components/fields/checkbox/pc.vue create mode 100644 src/components/fields/date/app.vue create mode 100644 src/components/fields/date/pc.vue create mode 100644 src/components/fields/image/app.vue create mode 100644 src/components/fields/image/pc.vue create mode 100644 src/components/fields/index.ts create mode 100644 src/components/fields/multiselect/app.vue create mode 100644 src/components/fields/multiselect/pc.vue create mode 100644 src/components/fields/number/app.vue create mode 100644 src/components/fields/number/pc.vue create mode 100644 src/components/fields/phone/app.vue create mode 100644 src/components/fields/phone/pc.vue create mode 100644 src/components/fields/radio/app.vue create mode 100644 src/components/fields/radio/pc.vue create mode 100644 src/components/fields/select/app.vue create mode 100644 src/components/fields/select/pc.vue create mode 100644 src/components/fields/text/app.vue create mode 100644 src/components/fields/text/pc.vue create mode 100644 src/components/fields/textarea/app.vue create mode 100644 src/components/fields/textarea/pc.vue create mode 100644 src/components/fields/types.ts create mode 100644 src/components/fields/upload/app.vue create mode 100644 src/components/fields/upload/pc.vue create mode 100644 src/components/form/FormConfigModal.vue create mode 100644 src/types/answer.ts create mode 100644 src/types/data.ts create mode 100644 src/types/form.ts create mode 100644 src/types/formSchema.ts create mode 100644 src/utils/dataValue.ts create mode 100644 src/utils/designer/schema.ts create mode 100644 src/utils/embed.ts create mode 100644 src/utils/hook/useFormCandidates.ts create mode 100644 src/utils/modulePerms.ts create mode 100644 src/views/admin/AnswerDetail.vue create mode 100644 src/views/admin/FormDesigner.vue diff --git a/package-lock.json b/package-lock.json index 39f212c..9c78843 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "view-ui-plus": "1.3.24", "vite-plugin-mock": "2.9.6", "vue": "^3.4.19", - "vue-router": "4" + "vue-router": "4", + "vuedraggable": "4.1.0" }, "devDependencies": { "@types/node": "^20.4.4", @@ -6852,6 +6853,12 @@ "node": ">=0.10.0" } }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", @@ -8661,6 +8668,18 @@ "typescript": "*" } }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "license": "MIT", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", diff --git a/package.json b/package.json index bbb16c2..18d353e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "view-ui-plus": "1.3.24", "vite-plugin-mock": "2.9.6", "vue": "^3.4.19", - "vue-router": "4" + "vue-router": "4", + "vuedraggable": "4.1.0" }, "devDependencies": { "@types/node": "^20.4.4", diff --git a/src/App.vue b/src/App.vue index 2ad6843..9089831 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,9 +8,11 @@ import { resolveMenuPage } from "@/utils/menuRoute"; import { buildNavTree, firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav"; import { Message } from "view-ui-plus"; import type { IModule } from "@/types/Basics"; +import { useDefaultStore } from "@/store"; const router = useRouter(); const route = useRoute(); +const store = useDefaultStore(); const isReady = ref(false); /** @@ -51,12 +53,16 @@ onMounted(async () => { // 1. 当前会话用户 const user: any = await baseApi.getUserInfo().send(); const { id, name, profilePicture } = user || {}; + // 存一份给页面用(如字典页判平台级维护权);权限判定仍在后端,这里只是不重复发一次请求 + store.setUser(user || null); // 2. 当前用户已授权的模块树(左导航数据源;path 空的权限锚点行也在内,由导航层跳过) const modList: IModule[] = (await baseApi.getUserMod().send()) || []; layoutData.userInfo = { id, name: name || "用户", avatar: profilePicture || "" }; layoutData.navList = buildNavTree(modList, resolveMenuPage); layoutData.isAlone = isSingleNav(layoutData.navList); + // 原始树留一份:按钮级权限(快应用的"导出"锚点)按它判,导航树已把锚点行过滤掉了 + store.setModules(modList); if (import.meta.env.DEV) { // 冒烟取证:原始树 vs 可渲染导航(path 空的权限锚点应当只出现在前者) @@ -86,6 +92,7 @@ onMounted(async () => { Message.error("载入用户信息失败,请检查后端服务是否正常!"); } // 降级只降「导航为空」,不造兜底菜单树——静默回落会把「平台未配模块/未授权」伪装成正常 + store.setUser(null); layoutData.userInfo = { name: "用户", avatar: "" }; layoutData.navList = []; layoutData.isAlone = true; diff --git a/src/api/module/data.ts b/src/api/module/data.ts new file mode 100644 index 0000000..74757a9 --- /dev/null +++ b/src/api/module/data.ts @@ -0,0 +1,148 @@ +/** + * 数据面接口(后端 `FormAnswerReadController` + `FormAnswerAdminController`, + * `/api/form/admin/data/**`,CAS 会话 + 数据管理/统计模块行 + 行级数据范围三层一起判)。 + * + * 写法沿 P49:`alovaInstance.Get(url, {params})` / `Post(url, body, {params})`。 + * + * 四处与直觉不同,改动前先读注释: + * 1. **列表是 POST + 请求体**(后端 `@RequestBody ListRequest`),不是 GET 带 query; + * 2. **翻页只有 cursor/order/size**,没有页码也没有总数(keyset,铁律②); + * 3. **`schId` 只在超管显式切目标域时带**(校级传别的值即被 FormAdminGuard 拒); + * 4. **导出不走 alova**:响应是 xlsx 二进制流,而 alova 的响应拦截器无条件 `response.json()` + * (`utils/alova/alova-core.ts`),拿它接文件流必炸——见 `exportBlob` 里的原生 fetch。 + */ +import { alovaInstance } from "@/utils/alova/alova-iview"; +import Utils from "@/utils"; +import conf from "@/assets/config/host.config"; +import type { + AnswerDetail, + BatchItemResult, + FilterField, + ListRequest, + ListView, + QueryRequest, + StatsView +} from "@/types/data"; + +const BASE = "/api/form/admin/data"; + +/** 目标域参数:只在超管显式选择上下文时带(undefined/空值剔除,避免 query 里出现空值) */ +type Scope = { schId?: number }; + +function paramsOf(params: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === "") continue; + out[k] = v; + } + return out; +} + +class DataApi { + /** 两段式列表(keyset 游标);行含当页值文档,列由前端按 schema 展(铁律①的对外形态) */ + list(body: ListRequest, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/list`, body, { params: paramsOf({ ...scope }) }); + } + + /** 详情:uuid 寻址 + 钉住版本的内联展开 schema(V5:URL 与参数里都只有 uuid) */ + detail(answerId: string, scope: Scope = {}) { + return alovaInstance.Get(`${BASE}/detail`, { params: paramsOf({ ...scope, answerId }) }); + } + + /** 筛选候选(searchable 且有算子;附件类已被后端排除,P28) */ + filterFields(formUuid: string, scope: Scope = {}) { + return alovaInstance.Get(`${BASE}/filterFields`, { + params: paramsOf({ ...scope, formUuid }) + }); + } + + /** 统计(总数/按状态/按字段分组;短 TTL 缓存与范围解析都在后端,前端不缓存不限定 scope) */ + stats(body: QueryRequest, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/stats`, body, { params: paramsOf({ ...scope }) }); + } + + /** 编辑值文档:按该答卷钉住的 version 校验(新 schema 字段不会在旧数据上凭空出现) */ + edit(answerId: string, values: Record, summary?: string, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/edit`, { answerId, values, summary }, { + params: paramsOf({ ...scope }) + }); + } + + /** 软删(唯一删除形态,无物理删除接口——V30);uuid 走 query */ + softDelete(answerId: string, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/softDelete`, {}, { params: paramsOf({ ...scope, answerId }) }); + } + + /** 改状态(填表人在接口层就没有这条路——V14) */ + changeStatus(answerId: string, status: string, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/changeStatus`, { answerId, status }, { + params: paramsOf({ ...scope }) + }); + } + + /** 批量软删:后端逐条校验、逐条留痕、逐条回结果(V39;单次上限 200,P29) */ + batchSoftDelete(answerIds: string[], scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/batch/softDelete`, { answerIds }, { + params: paramsOf({ ...scope }) + }); + } + + /** 批量改状态:同上 */ + batchChangeStatus(answerIds: string[], status: string, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/batch/changeStatus`, { answerIds, status }, { + params: paramsOf({ ...scope }) + }); + } +} + +export const dataApi = new DataApi(); + +/** + * 导出(xlsx 流式同步下载,方案 §5.5 铁律④:不设行数上限、不做异步任务与下载中心)。 + * + *

为什么是原生 fetch:后端这个端点直接写 OutputStream、不套 JSON 信封 + * ({@code FormAnswerReadController#export} 注释),而本仓的响应拦截器对每个响应都 + * `response.json()`——文件流过它必报解析错。这里只复用同样的会话头,绕开信封解包。

+ * + *

失败态仍是信封 JSON(全局异常处理器兜底),按 content-type 分流回错误文案。

+ */ +export async function exportBlob(body: QueryRequest, scope: Scope = {}): Promise { + const query = new URLSearchParams(); + if (scope.schId !== undefined && scope.schId !== null) query.set("schId", String(scope.schId)); + const url = `${conf.serviceHost}${BASE}/export${query.toString() ? "?" + query.toString() : ""}`; + const headers: Record = { + "Content-Type": "application/json", + // 与 alova 请求拦截同一组头:后端 CAS 过滤器按 yschapirequest 判"这是 API 请求" + yschapirequest: "1", + yschreferer: Utils.strToHexCharCode(window.location.href) + }; + const currentRole = localStorage.getItem("currentRole"); + if (currentRole) headers.currentRole = currentRole; + + const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), credentials: "include" }); + const type = response.headers.get("content-type") || ""; + if (!response.ok || type.includes("application/json")) { + // 错误态:信封 JSON(成功时是 spreadsheet 流,不会走到这支) + let msg = "导出失败"; + try { + const env = await response.json(); + msg = env?.msg || msg; + } catch { + /* 响应体不是 JSON 时保持默认文案 */ + } + throw new Error(msg); + } + return await response.blob(); +} + +/** 触发浏览器下载(文件名由后端响应头给,这里只负责挂上并点一下) */ +export function downloadBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/src/api/module/dict.ts b/src/api/module/dict.ts new file mode 100644 index 0000000..4ce5c85 --- /dev/null +++ b/src/api/module/dict.ts @@ -0,0 +1,56 @@ +/** + * 选项字典接口(后端 `FormAdminController` 的 /api/form/admin/dict/*,契约 §2.1)。 + * + * 两级模型在这里的落点: + * - `list` 只出**本层自己的**字典(管理 tab 用),平台级不在里面; + * - `selectable` 出「会话校 ∪ 平台级(sch_id=0)、同 code 本校遮蔽平台、条目只出未删」—— + * 设计器的引用选择器用它,所以软删条目天然选不到(旧答卷仍能原样重存,放行在提交侧); + * - 字典的目标域走**请求体里的 schId**(后端 @RequestBody,与表单端点的 query 不同), + * 0=平台级只有超管可写,校级传 0 会被 FormAdminGuard 拒。 + * + * 条目 value 是定位键:接口没有改 value 的通道,本端新建时生成、之后只读(契约 §2.1、P18/P19)。 + */ +import { alovaInstance } from "@/utils/alova/alova-iview"; +import type { DictItemRequest, DictItemView, DictSaveRequest, DictView } from "@/types/form"; + +const BASE = "/api/form/admin/dict"; + +type Scope = { schId?: number }; + +class DictApi { + /** 本层字典清单(含条目,管理 tab) */ + list(scope: Scope = {}) { + return alovaInstance.Get(`${BASE}/list`, { + params: scope.schId === undefined || scope.schId === null ? undefined : { schId: scope.schId } + }); + } + + /** 设计器可选字典(本校 ∪ 平台级,本校遮蔽,只出未删条目) */ + selectable(scope: Scope = {}) { + return alovaInstance.Get(`${BASE}/selectable`, { + params: scope.schId === undefined || scope.schId === null ? undefined : { schId: scope.schId } + }); + } + + /** 新建/改名:code 建后不可改(它是 schema 的引用键),软删行重新保存=恢复 */ + save(body: DictSaveRequest) { + return alovaInstance.Post(`${BASE}/save`, body); + } + + /** 字典软删(schema 里已有的引用在发布校验时会因不可解析而被拦) */ + remove(body: DictSaveRequest) { + return alovaInstance.Post(`${BASE}/delete`, body); + } + + /** 条目保存:value 定位,能改的只有 label 与 sort;对软删条目重新保存=放回可选集 */ + saveItem(body: DictItemRequest) { + return alovaInstance.Post(`${BASE}/item/save`, body); + } + + /** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */ + removeItem(body: DictItemRequest) { + return alovaInstance.Post(`${BASE}/item/delete`, body); + } +} + +export const dictApi = new DictApi(); diff --git a/src/api/module/fill.ts b/src/api/module/fill.ts new file mode 100644 index 0000000..aeb7329 --- /dev/null +++ b/src/api/module/fill.ts @@ -0,0 +1,57 @@ +/** + * 填报面 API(用户面,CAS 会话):resolve / submit / my-list / upload。 + * + * 沿 P49 写法:alovaInstance.Get(url, {params}) / Post(url, body, {params})。 + * 与后端 FormResolveController / FormAnswerController 对齐。 + */ +import { alovaInstance } from "@/utils/alova/alova-iview"; +import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer"; +import type { AnswerDetail, MyAnswerViewItem } from "@/types/data"; + +/** 解析当前发布版本(契约 §1:入口寻址) */ +export function resolveForm(code: string, appCode?: string) { + const params: Record = { code }; + if (appCode) params.appCode = appCode; + return alovaInstance.Get("/api/form/resolve", { params }).send(); +} + +/** 提交答卷(契约 §5) */ +export function submitAnswer(body: SubmitRequest) { + return alovaInstance.Post("/api/form/submit", body).send(); +} + +/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */ +export function listMyAnswers(formCode: string, appCode?: string) { + const params: Record = { formCode }; + if (appCode) params.appCode = appCode; + return alovaInstance.Get("/api/form/my/list", { params }).send(); +} + +/** 软删我的答卷(确认对话框后调用) */ +export function deleteMyAnswer(answerId: string) { + return alovaInstance.Post("/api/form/my/delete", { answerId }).send(); +} + +/** 编辑我的答卷(按钉住 version 校验) */ +export function editMyAnswer(answerId: string, values: Record, summary?: string) { + return alovaInstance.Post("/api/form/my/edit", { answerId, values, summary }).send(); +} + +// ==================== 我的填写两视图(T8,/my/submit 与 /my/about) ==================== +// 两个视图 = 两个端点、两个谓词,不是一个端点加 view 参数(方案 §6.3"鉴权分支容易写漏")。 +// 页面侧按路由分派,不做 query 参数分支;身份一律会话推导(V17),这里不传 userId。 + +/** 我提交的(submitter=会话用户,跨表单、不带值文档) */ +export function listMySubmitted() { + return alovaInstance.Get("/api/form/my/submit-list").send(); +} + +/** 关于我的(subject=会话用户,含管理员代填的那几条——V18 的另一半) */ +export function listAnswersAboutMe() { + return alovaInstance.Get("/api/form/my/about-list").send(); +} + +/** 我的答卷详情(钉住版本的 groups + 值文档,与数据管理面同一个 AnswerDetail 形状) */ +export function getMyAnswerDetail(answerId: string) { + return alovaInstance.Get("/api/form/my/detail", { params: { answerId } }).send(); +} diff --git a/src/api/module/form.ts b/src/api/module/form.ts new file mode 100644 index 0000000..d9b7a32 --- /dev/null +++ b/src/api/module/form.ts @@ -0,0 +1,116 @@ +/** + * 定义域管理端接口(后端 `FormAdminController`,/api/form/admin/*,CAS 会话 + 定义管理模块行把关)。 + * + * 写法沿 T5 定稿的 P49:`alovaInstance.Get(url, {params})` / `Post(url, body, {params})`—— + * 同一个 Method 对象、同一套拦截器与信封解包(Notice 的 `new Method` 在 alova 3.5 类型下不合法)。 + * + * 两条参数分工照后端原样,别自己发挥: + * - `uuid` 寻址表单(库内 id 从不外露,契约 §1/V5); + * - `schId` 只是**超管的目标域**选择(含 0=平台级),校级管理员传别的值直接被 FormAdminGuard 拒; + * 本端默认不传(会话校),只有超管切平台级上下文时才显式带。 + * publish/disable/enable/softDelete 的 uuid 走 query(后端是 @RequestParam,没有请求体)。 + */ +import { alovaInstance } from "@/utils/alova/alova-iview"; +import type { + FormConfigRequest, + FormCopyRequest, + FormCreateRequest, + FormSaveRequest, + FormView, + StatusDefsRequest, + VersionView +} from "@/types/form"; + +const BASE = "/api/form/admin"; + +/** 目标域参数:只在超管显式选择上下文时带(undefined 一律剔除,避免 query 里出现空值) */ +type Scope = { schId?: number }; + +function paramsOf(params: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === "") continue; + out[k] = v; + } + return out; +} + +class FormApi { + /** 表单列表(头表 + 版本指针,不含 schema 正文;无服务端分页,一期按校量级够用) */ + list(scope: Scope = {}, filter: { appCode?: string; status?: number } = {}) { + return alovaInstance.Get(`${BASE}/list`, { + params: paramsOf({ ...scope, ...filter }) + }); + } + + /** 单个表单头表(状态/版本指针/表单级配置/状态定义都在这里,schema 正文要另查版本行) */ + get(uuid: string, scope: Scope = {}) { + return alovaInstance.Get(`${BASE}/get`, { params: paramsOf({ ...scope, uuid }) }); + } + + /** 版本行清单(升版流水账:version/published/changeLog/publishTime) */ + versions(uuid: string, scope: Scope = {}) { + return alovaInstance.Get(`${BASE}/versions`, { params: paramsOf({ ...scope, uuid }) }); + } + + /** 单个版本行的 schema 正文(编辑回显;已发布行内容逐字节冻结,V7) */ + version(uuid: string, version: number, scope: Scope = {}) { + return alovaInstance.Get(`${BASE}/version`, { + params: paramsOf({ ...scope, uuid, version }) + }); + } + + /** 筛选候选列:只出显式 searchable 且当前发布版本里的字段,附件两类已被后端排除(P28) */ + filterFields(uuid: string, scope: Scope = {}) { + return alovaInstance.Get(`${BASE}/filterFields`, { params: paramsOf({ ...scope, uuid }) }); + } + + /** 新建草稿(建草稿即生成 uuid,跨版本不变;version 从 1 起) */ + create(body: FormCreateRequest, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/create`, body, { params: paramsOf({ ...scope }) }); + } + + /** 保存:草稿直改;已发布表单=开草稿行,头表 status/current_version 不动(V7、P20) */ + save(body: FormSaveRequest, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/save`, body, { params: paramsOf({ ...scope }) }); + } + + /** 发布:schema 契约校验 + 字段身份稳定 + 字典可解析,三道都过才把草稿行钉成版本并推指针 */ + publish(uuid: string, changeLog?: string, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/publish`, {}, { + params: paramsOf({ ...scope, uuid, changeLog }) + }); + } + + /** 停用:只断填报入口,数据可读可导(方案 §5.4) */ + disable(uuid: string, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/disable`, {}, { params: paramsOf({ ...scope, uuid }) }); + } + + /** 停用恢复:重新发布当前版本,不产生新版本行 */ + enable(uuid: string, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/enable`, {}, { params: paramsOf({ ...scope, uuid }) }); + } + + /** 软删(唯一删除形态,无物理删除接口) */ + softDelete(uuid: string, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/softDelete`, {}, { params: paramsOf({ ...scope, uuid }) }); + } + + /** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝,源表单零改动 */ + copy(body: FormCopyRequest, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/copy`, body, { params: paramsOf({ ...scope }) }); + } + + /** 表单级配置(不进版本快照,改它不升版):audience/限次/截止/报名号/分组标签 */ + config(body: FormConfigRequest, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/config`, body, { params: paramsOf({ ...scope }) }); + } + + /** 状态定义(V12):statusDefs 传 null=回默认「待处理/已处理」 */ + statusDefs(body: StatusDefsRequest, scope: Scope = {}) { + return alovaInstance.Post(`${BASE}/statusDefs`, body, { params: paramsOf({ ...scope }) }); + } +} + +export const formApi = new FormApi(); diff --git a/src/assets/mock/baseapi.ts b/src/assets/mock/baseapi.ts index 5955180..5af0e96 100644 --- a/src/assets/mock/baseapi.ts +++ b/src/assets/mock/baseapi.ts @@ -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); } diff --git a/src/assets/mock/dataapi.ts b/src/assets/mock/dataapi.ts new file mode 100644 index 0000000..56fccb5 --- /dev/null +++ b/src/assets/mock/dataapi.ts @@ -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; +} + +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(); + 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(); + 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); + } } +]; diff --git a/src/assets/mock/formapi.ts b/src/assets/mock/formapi.ts new file mode 100644 index 0000000..d6fc14b --- /dev/null +++ b/src/assets/mock/formapi.ts @@ -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(); + 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(); + 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); + } } +]; diff --git a/src/components/answer/AnswerEditModal.vue b/src/components/answer/AnswerEditModal.vue new file mode 100644 index 0000000..951d610 --- /dev/null +++ b/src/components/answer/AnswerEditModal.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/src/components/answer/AnswerValueView.vue b/src/components/answer/AnswerValueView.vue new file mode 100644 index 0000000..f2d3ddf --- /dev/null +++ b/src/components/answer/AnswerValueView.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/src/components/designer/ComponentLibrary.vue b/src/components/designer/ComponentLibrary.vue new file mode 100644 index 0000000..b4a0614 --- /dev/null +++ b/src/components/designer/ComponentLibrary.vue @@ -0,0 +1,122 @@ + + + + + \ No newline at end of file diff --git a/src/components/designer/DesignerCanvas.vue b/src/components/designer/DesignerCanvas.vue new file mode 100644 index 0000000..75e74f1 --- /dev/null +++ b/src/components/designer/DesignerCanvas.vue @@ -0,0 +1,274 @@ + + + + + \ No newline at end of file diff --git a/src/components/designer/FieldPropPanel.vue b/src/components/designer/FieldPropPanel.vue new file mode 100644 index 0000000..4e53c23 --- /dev/null +++ b/src/components/designer/FieldPropPanel.vue @@ -0,0 +1,463 @@ + + + + + \ No newline at end of file diff --git a/src/components/fields/checkbox/app.vue b/src/components/fields/checkbox/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/checkbox/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/checkbox/pc.vue b/src/components/fields/checkbox/pc.vue new file mode 100644 index 0000000..a5f6beb --- /dev/null +++ b/src/components/fields/checkbox/pc.vue @@ -0,0 +1,31 @@ + + + \ No newline at end of file diff --git a/src/components/fields/date/app.vue b/src/components/fields/date/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/date/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/date/pc.vue b/src/components/fields/date/pc.vue new file mode 100644 index 0000000..5134953 --- /dev/null +++ b/src/components/fields/date/pc.vue @@ -0,0 +1,30 @@ + + + \ No newline at end of file diff --git a/src/components/fields/image/app.vue b/src/components/fields/image/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/image/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/image/pc.vue b/src/components/fields/image/pc.vue new file mode 100644 index 0000000..27f3500 --- /dev/null +++ b/src/components/fields/image/pc.vue @@ -0,0 +1,150 @@ + + + + + \ No newline at end of file diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts new file mode 100644 index 0000000..349cb7d --- /dev/null +++ b/src/components/fields/index.ts @@ -0,0 +1,56 @@ +/** + * 控件注册表(V22 的第 5 条落点:控件目录按 pc.vue / app.vue 双端组织)。 + * + * 一期只注册 pc 形态的实现,app 形态是空槽位组件(方案 §4.2「二期纯增量,不是重构」)—— + * 调用方拿到 null 就代表该类型没有控件件(二期类型 address/user/users 或脏数据里的未知类型), + * 由画布显式降级呈现,不静默少一个字段。 + */ +import type { Component } from "vue"; +import checkboxApp from "./checkbox/app.vue"; +import checkboxPc from "./checkbox/pc.vue"; +import dateApp from "./date/app.vue"; +import datePc from "./date/pc.vue"; +import imageApp from "./image/app.vue"; +import imagePc from "./image/pc.vue"; +import multiselectApp from "./multiselect/app.vue"; +import multiselectPc from "./multiselect/pc.vue"; +import numberApp from "./number/app.vue"; +import numberPc from "./number/pc.vue"; +import phoneApp from "./phone/app.vue"; +import phonePc from "./phone/pc.vue"; +import radioApp from "./radio/app.vue"; +import radioPc from "./radio/pc.vue"; +import selectApp from "./select/app.vue"; +import selectPc from "./select/pc.vue"; +import textApp from "./text/app.vue"; +import textPc from "./text/pc.vue"; +import textareaApp from "./textarea/app.vue"; +import textareaPc from "./textarea/pc.vue"; +import uploadApp from "./upload/app.vue"; +import uploadPc from "./upload/pc.vue"; + +/** 渲染端:pc=一期实现,app=二期槽位 */ +export type WidgetEnd = "pc" | "app"; + +const WIDGETS: Record = { + text: { pc: textPc, app: textApp }, + textarea: { pc: textareaPc, app: textareaApp }, + number: { pc: numberPc, app: numberApp }, + date: { pc: datePc, app: dateApp }, + select: { pc: selectPc, app: selectApp }, + radio: { pc: radioPc, app: radioApp }, + checkbox: { pc: checkboxPc, app: checkboxApp }, + multiselect: { pc: multiselectPc, app: multiselectApp }, + phone: { pc: phonePc, app: phoneApp }, + image: { pc: imagePc, app: imageApp }, + upload: { pc: uploadPc, app: uploadApp } +}; + +export function fieldWidget(type: string, end: WidgetEnd = "pc"): Component | null { + return WIDGETS[type]?.[end] ?? null; +} + +/** 已建目录的控件类型(与一期放行 11 类同源,冒烟脚本拿它比对目录完整性) */ +export function widgetTypes(): string[] { + return Object.keys(WIDGETS); +} diff --git a/src/components/fields/multiselect/app.vue b/src/components/fields/multiselect/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/multiselect/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/multiselect/pc.vue b/src/components/fields/multiselect/pc.vue new file mode 100644 index 0000000..53b65f2 --- /dev/null +++ b/src/components/fields/multiselect/pc.vue @@ -0,0 +1,32 @@ + + + \ No newline at end of file diff --git a/src/components/fields/number/app.vue b/src/components/fields/number/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/number/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/number/pc.vue b/src/components/fields/number/pc.vue new file mode 100644 index 0000000..62576d3 --- /dev/null +++ b/src/components/fields/number/pc.vue @@ -0,0 +1,44 @@ + + + + + \ No newline at end of file diff --git a/src/components/fields/phone/app.vue b/src/components/fields/phone/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/phone/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/phone/pc.vue b/src/components/fields/phone/pc.vue new file mode 100644 index 0000000..475d03f --- /dev/null +++ b/src/components/fields/phone/pc.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/src/components/fields/radio/app.vue b/src/components/fields/radio/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/radio/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/radio/pc.vue b/src/components/fields/radio/pc.vue new file mode 100644 index 0000000..ec7f5cb --- /dev/null +++ b/src/components/fields/radio/pc.vue @@ -0,0 +1,31 @@ + + + \ No newline at end of file diff --git a/src/components/fields/select/app.vue b/src/components/fields/select/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/select/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/select/pc.vue b/src/components/fields/select/pc.vue new file mode 100644 index 0000000..5e1bb11 --- /dev/null +++ b/src/components/fields/select/pc.vue @@ -0,0 +1,32 @@ + + + \ No newline at end of file diff --git a/src/components/fields/text/app.vue b/src/components/fields/text/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/text/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/text/pc.vue b/src/components/fields/text/pc.vue new file mode 100644 index 0000000..1b340f2 --- /dev/null +++ b/src/components/fields/text/pc.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/src/components/fields/textarea/app.vue b/src/components/fields/textarea/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/textarea/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/textarea/pc.vue b/src/components/fields/textarea/pc.vue new file mode 100644 index 0000000..a1d6353 --- /dev/null +++ b/src/components/fields/textarea/pc.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/src/components/fields/types.ts b/src/components/fields/types.ts new file mode 100644 index 0000000..d3d74a4 --- /dev/null +++ b/src/components/fields/types.ts @@ -0,0 +1,32 @@ +/** + * 控件组件的公共入参(V22 的「控件目录 pc.vue/app.vue 双端」按本形状约定,T7 填报页复用同一批件)。 + * + * options 由调用方解析后传入:inline 字段取字段自己的数组,字典引用字段取 + * selectable 接口展开后的条目——控件本身不去猜字典,保证「字典是中心内部组织方式、 + * 对外/对渲染都是内联展开」(契约 §2.1)。 + */ +import type { DesignField, FieldOption } from "@/types/formSchema"; + +export interface FieldWidgetProps { + /** 契约字段对象(白名单属性) */ + field: DesignField; + /** 已展开的选项(非选项类控件传空数组即可) */ + options?: FieldOption[]; +} + +/** + * 预览态取值:defaultValue 的 JSON 类型必须与该控件的值形状一致(契约 §3, + * 后端 validateDefaultValue 已按这张表判),所以这里只做「按值形状收窄」, + * 形状不对就当没填——设计器不替脏数据兜底,也不让它崩。 + */ +export function textDefault(field: DesignField): string { + return typeof field.defaultValue === "string" ? field.defaultValue : ""; +} + +export function numberDefault(field: DesignField): number | undefined { + return typeof field.defaultValue === "number" ? field.defaultValue : undefined; +} + +export function stringArrayDefault(field: DesignField): string[] { + return Array.isArray(field.defaultValue) ? field.defaultValue.filter((v): v is string => typeof v === "string") : []; +} diff --git a/src/components/fields/upload/app.vue b/src/components/fields/upload/app.vue new file mode 100644 index 0000000..7a69f5d --- /dev/null +++ b/src/components/fields/upload/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/fields/upload/pc.vue b/src/components/fields/upload/pc.vue new file mode 100644 index 0000000..bca5c4d --- /dev/null +++ b/src/components/fields/upload/pc.vue @@ -0,0 +1,135 @@ + + + + + \ No newline at end of file diff --git a/src/components/form/FormConfigModal.vue b/src/components/form/FormConfigModal.vue new file mode 100644 index 0000000..5093dae --- /dev/null +++ b/src/components/form/FormConfigModal.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/src/components/layout.vue b/src/components/layout.vue index abf9b4f..5543d21 100644 --- a/src/components/layout.vue +++ b/src/components/layout.vue @@ -51,6 +51,9 @@ watch( () => route.fullPath, () => routeChange(route) ); +// 深链/刷新时 onBeforeMount 那一次导航树还是空的,之后没有路由变化来触发重算, +// 面包屑就会一直停在兜底分支的结果上;这里补一条「树到货了再算一次」 +watch(flatNav, () => routeChange(route)); onMounted(() => { readLogoUrl(); @@ -61,7 +64,7 @@ onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", read /** 面包屑/高亮:优先按 path 匹配导航项,匹配不到再退回 modId 对照 */ const routeChange = (to: any) => { const meta: any = to.meta || {}; - const { modId, title, parentId } = meta; + const { modId, title, parentId, parentPath } = meta; const navArr: Array<{ name: string; page?: string }> = []; const items = flatNav.value; @@ -78,6 +81,21 @@ const routeChange = (to: any) => { return; } + // 子页面(设计器这类)没有自己的模块行:按 meta.parentPath 把高亮与面包屑挂到父级导航行下, + // 免得进子页就变成「无高亮 + 无面包屑」——父级行仍在导航里,这不是新增权限口径 + const parentByPath: any = parentPath ? items.find((x: any) => x.page === parentPath) : null; + if (parentByPath) { + currPath.value = parentByPath.page || ""; + if (parentByPath.pid) { + const gp = navList.value.find((x) => x.id === parentByPath.pid); + if (gp) navArr.push({ name: gp.name }); + } + navArr.push({ name: parentByPath.name, page: parentByPath.page }); + navArr.push({ name: title }); + navPath.value = navArr; + return; + } + if (parentId) { const parent = items.find((x: any) => x.modId === parentId); if (parent) { diff --git a/src/router/routes.ts b/src/router/routes.ts index 0fdb3de..548c815 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -31,12 +31,30 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/admin/Forms.vue"), meta: { title: "表单设计" }, }, + { + // 设计器子页面:UserBase 侧没有这条模块行(手册 §2.2 只登记四个管理行 + 我的填写 + 权限锚点), + // 所以它不进左导航;高亮与面包屑由 layout 按 meta.parentPath 挂到「表单设计」行下。 + // uuid 走 query 而非 path 参数,menuRoute 的 ROUTE_PATHS 才能把它当静态 path 收进去。 + path: "/admin/forms/designer", + name: "fc_admin_form_designer", + component: () => import("@/views/admin/FormDesigner.vue"), + meta: { title: "表单设计器", parentPath: "/admin/forms" }, + }, { path: "/admin/data", name: "fc_admin_data", component: () => import("@/views/admin/Data.vue"), meta: { title: "数据管理" }, }, + { + // 答卷详情:数据面身份只用 uuid(V5),所以 answerId 走 query 而非 path 参数 + // (同设计器先例——带参 path 不被 menuRoute 的 ROUTE_PATHS 收,且 uuid 形态固定 32 位, + // 放 path 里除了暴露在地址段没有任何好处)。不进左导航,面包屑挂「数据管理」行下。 + path: "/admin/data/detail", + name: "fc_admin_data_detail", + component: () => import("@/views/admin/AnswerDetail.vue"), + meta: { title: "答卷详情", parentPath: "/admin/data" }, + }, { path: "/admin/stats", name: "fc_admin_stats", @@ -69,13 +87,40 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/AppContext.vue"), meta: { title: "应用表单" }, }, + { + // P77:快应用上下文内的答卷详情走应用自己的路径段,不把用户弹出应用壳 + // (T8 现状是恒推 /admin/data/detail——中心管理端那条路径)。 + // 形态二选一的取舍见 docs/T9-report.md:选子路由而非内嵌 Modal——地址里保留 uuid 可深链/刷新, + // 且详情页(钉版渲染 + 三态空值 + 已删字段降级)只有一份实现。 + // 带参 path 不进 menuRoute 的 ROUTE_PATHS(该表按"无 : 的静态 path"收集),故不写 parentPath。 + path: "/app/:appCode/data/detail", + name: "fc_app_data_detail", + component: () => import("@/views/admin/AnswerDetail.vue"), + meta: { title: "答卷详情" }, + }, /* ---------------- 我的填写(全员授权行) ---------------- */ + // 两视图=两个独立入口,不做 query 参数分支(方案 §6.3「鉴权分支容易写漏」、V18): + // /my/submit = 我提交的(submitter=会话用户);/my/about = 关于我的(subject=会话用户)。 + // /my 为兼容模块行 path(手册 §2.2 只登记了 /my 一行)保留,落在同一容器上, + // 由容器内的 Tab 在两个入口间切换(Tab 是 UI 导航,不是接口参数分支)。 { path: "/my", - name: "fc_my_fill", + name: "fc_my", component: () => import("@/views/My.vue"), meta: { title: "我的填写" }, }, + { + path: "/my/submit", + name: "fc_my_submit", + component: () => import("@/views/My.vue"), + meta: { title: "我提交的", parentPath: "/my" }, + }, + { + path: "/my/about", + name: "fc_my_about", + component: () => import("@/views/My.vue"), + meta: { title: "关于我的", parentPath: "/my" }, + }, // 兜底:外部深链/残留配置落到未登记路由时不白屏,回入口由壳决定首个可用页 { path: "/:pathMatch(.*)*", redirect: "/" }, ]; diff --git a/src/store/index.ts b/src/store/index.ts index b412a8e..4ae4e3b 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,17 +1,49 @@ import { defineStore } from "pinia"; +import type { IModule } from "@/types/Basics"; /** * 全局 store(notice/front 基线形态)。 - * 本轮 T5 壳未消费业务态,保留 userRole(school/平台)这一子应用通用位, - * 表单/答卷/设计器的态随 T6~T8 按需开切片,不提前建空壳。 + * T6 起开第一个业务切片:会话用户本体——字典管理页要按它判「能不能进平台级上下文维护」 + * (UserBase 侧只有 type==1 超管可写 sch_id=0 的行,方案 §6.5;后端 FormAdminGuard 同判)。 + * 这里存的是**展示与门禁提示用的会话身份**,不是权限来源:接口侧一律会话自取, + * 前端藏掉按钮不等于授权(契约 §7 身份行)。 */ export const useDefaultStore = defineStore("defaultStore", { state: () => ({ - userRole: "school" as string + userRole: "school" as string, + /** /api/base/GetUserInfo 原样下发的会话用户(未拿到时为 null) */ + user: null as Record | null, + /** + * /api/base/GetUserMod 的原始模块树(T8 起留一份给按钮级判定用)。 + * 必须是原始树而不是 `utils/menuNav.ts` 过滤后的导航树——导航按规则跳过了 + * path 空的权限锚点行,而按钮位(快应用的"导出")正是这类行(方案 §6.2)。 + * 判定函数在 `utils/modulePerms.ts`;这里只是避免同一棵树重复发一次请求。 + */ + modules: [] as IModule[] }), + getters: { + /** 超管(UserBase UserTypeEnum.SUPER_ADMIN=1):唯一能操作平台级行的身份 */ + isSuperAdmin(state): boolean { + return state.user?.type === 1; + }, + /** 会话校:管理端目标域提示用(平台级上下文时为空) */ + sessionSchId(state): number | null { + return state.user?.currentRole?.schId ?? null; + }, + /** 会话用户 id:判断"这条答卷是不是我提交的"(编辑/删除入口显隐)用 */ + sessionUserId(state): number | null { + return state.user?.id ?? null; + } + }, actions: { setUserRole(role: string) { this.userRole = role; + }, + setUser(user: Record | null) { + this.user = user || null; + }, + setModules(modules: IModule[] | null) { + this.modules = modules || []; } } }); diff --git a/src/types/answer.ts b/src/types/answer.ts new file mode 100644 index 0000000..b32c63a --- /dev/null +++ b/src/types/answer.ts @@ -0,0 +1,63 @@ +/** + * 答卷域类型定义(契约 §3/§5/§7)。 + * + * 与后端 AnswerModels/ReadModels/FormModels 对齐;前端只消费 uuid,id 绝不出现(V5)。 + */ + +/** 附件值形状(契约 §3:只存 id/fileName/key,不存 fileUrl) */ +export interface FileValue { + id: number; + fileName: string; + key: string; +} + +/** resolve 结果(契约 §1:入口寻址用的那一个) */ +export interface ResolvedForm { + formUuid: string; + code: string; + appCode: string; + name: string; + version: number; + audience: number; + fillLimit: number; + answerNoEnabled: number; + deadline: string | null; + deadlineReached: boolean; + statusDefs: Array<{ code: string; label: string; is_deleted: boolean }>; + groups: any; +} + +/** 提交请求(契约 §5:身份字段不进请求,会话推导) */ +export interface SubmitRequest { + formCode: string; + appCode?: string; + idempotencyKey: string; + subjectId?: number; + values: Record; +} + +/** 提交响应(契约 §5:{answerId, answerNo};幂等命中时 replayed=true) */ +export interface SubmitView { + answerId: string; + answerNo: string | null; + replayed?: boolean; +} + +/** 我的答卷列表项("我的提交"段) */ +export interface MyAnswerItem { + answerId: string; + formUuid: string; + version: number; + status: string; + statusLabel: string | null; + answerNo: string | null; + submitTime: string; + values: Record; +} + +/** 上传文件响应(契约 §3:{id, fileName, key}) */ +export interface FileView { + id: number; + fileName: string; + key: string; +} diff --git a/src/types/data.ts b/src/types/data.ts new file mode 100644 index 0000000..9dad4cc --- /dev/null +++ b/src/types/data.ts @@ -0,0 +1,140 @@ +/** + * 数据面出入参形状(逐字段对齐后端 `model/ReadModels.java` 与 `model/MyModels.java`)。 + * + * 三条对外纪律(后端模型注释里也写了,这里复述是给读前端代码的人): + * - **游标也是 uuid**:keyset 翻页参数 `cursor`=上一页末行的 answerId(uuid),服务端按 uk 换成库内 id; + * 自增 id 不出现在任何请求或响应里(契约 §1、V5、P39)。 + * - **没有页码概念**:列表响应不含 total/offset(铁律②禁裸 LIMIT offset),UI 只能"触底加载",不能跳页。 + * - **值文档原样回传**:三态空值与已删字段的降级显示由前端判(契约 §3),后端不改写、不补占位。 + */ + +/** 单个筛选条件;算子由后端按字段 type 定,前端只按 operator 出控件形态(契约 §2) */ +export interface FilterItem { + key: string; + /** 等值(EQ)与前缀模糊(PREFIX)用 */ + value?: string; + /** 区间(RANGE)下界(含):number 数值串、date 的 yyyy-MM-dd */ + from?: string; + /** 区间(RANGE)上界(含) */ + to?: string; +} + +/** 列表/统计/导出共用的查询条件 */ +export interface QueryRequest { + formUuid: string; + status?: string | null; + filters?: FilterItem[]; + /** 仅统计消费:按哪个 searchable 字段分组 */ + groupByField?: string | null; +} + +/** 列表请求(keyset:cursor + 方向 + 页大小) */ +export interface ListRequest extends QueryRequest { + /** 上一页末行 answerId(uuid);首屏不传 */ + cursor?: string | null; + /** 排序列恒为提交时间,只有方向 */ + order?: "asc" | "desc"; + /** 后端默认 20、上限 100 */ + size?: number; +} + +/** 列表行(后端两段式的第二段已把当页值文档取回,列由前端按 schema 展——铁律①的对外形态) */ +export interface AnswerRow { + answerId: string; + formUuid: string; + version: number; + status: string; + /** null=该 code 不在状态定义内(已废弃),前端标"已废弃:code"(方案 §5.3①) */ + statusLabel: string | null; + submitterId: number; + subjectId: number | null; + answerNo: string | null; + /** yyyy-MM-dd HH:mm:ss,一期唯一排序键 */ + submitTime: string; + values: Record; +} + +/** 详情:行 + 钉住版本内联展开后的 groups(渲染与校验同源,编辑弹层直接吃它) */ +export interface AnswerDetail extends AnswerRow { + appCode: string; + code: string; + formName: string; + groups: Array<{ key: string; label: string; fields: any[] }>; +} + +/** 列表结果:rows + 下一页游标;生效范围一并回传(前端只显示,不据此过滤) */ +export interface ListView { + rows: AnswerRow[]; + nextCursor: string | null; + size: number; + /** 枚举名数组:ALL/TEACHING_CLASS/SELF_SUBMITTED/ABOUT_ME */ + scopes: string[]; +} + +/** 筛选候选列(searchable 且有算子;附件类被后端排除——P28) */ +export interface FilterField { + key: string; + label: string; + type: string; + operator: "EQ" | "RANGE" | "PREFIX"; +} + +/** 一个分组桶 */ +export interface CountItem { + value: string; + label: string | null; + count: number; +} + +/** 按字段分组的结果集 */ +export interface FieldGroups { + key: string; + label: string; + items: CountItem[]; +} + +/** 统计结果(V11:总数/按状态/按字段分组全由接口给,前端不做任何聚合) */ +export interface StatsView { + total: number; + byStatus: CountItem[]; + byField: FieldGroups[]; + /** true=命中后端短 TTL 缓存(仅升版失效,容忍 N 分钟延迟——铁律④) */ + cached?: boolean; +} + +/** 批量逐条结果(一条一行,失败不影响其它条——每条独立事务,V39) */ +export interface BatchItemResult { + answerId: string; + success: boolean; + msg: string | null; +} + +/** "我的填写"两视图的行(跨表单,带表单身份、不带值文档) */ +export interface MyAnswerViewItem { + answerId: string; + formUuid: string; + appCode: string; + code: string; + /** 归属表单取不到时后端给 null,前端降级显示 code */ + formName: string | null; + version: number; + status: string; + statusLabel: string | null; + submitterId: number; + subjectId: number | null; + answerNo: string | null; + submitTime: string; +} + +/** 数据范围闭集四枚举的展示名(契约 §8;与后端 FormEnums.DataScope 的 label 同源) */ +export const DATA_SCOPE_LABELS: Record = { + ALL: "全部", + TEACHING_CLASS: "任教班级", + SELF_SUBMITTED: "仅本人提交", + ABOUT_ME: "关于我" +}; + +/** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */ +export function scopeLabel(name: string): string { + return DATA_SCOPE_LABELS[name] ?? name; +} diff --git a/src/types/form.ts b/src/types/form.ts new file mode 100644 index 0000000..2fcea41 --- /dev/null +++ b/src/types/form.ts @@ -0,0 +1,142 @@ +/** + * 定义域出入参形状(逐字段对齐后端 `model/FormModels.java`,T2 已验收的出参不外露库内 id)。 + * + * 前端只用 uuid 寻址表单、用 (域, code) 寻址字典、用 (dictCode, value) 寻址字典条目—— + * 这三把键都是契约 §1/§10 的稳定身份,别在页面上拼 id。 + */ +/** 表单状态(后端 FormEnums.FormStatus):0 草稿 / 1 已发布 / 2 停用 */ +export const FORM_STATUS = { + DRAFT: 0, + PUBLISHED: 1, + DISABLED: 2 +} as const; + +/** 面向人群(后端 FormEnums.Audience):1 面向学生 / 2 面向教师 */ +export const AUDIENCE = { + STUDENT: 1, + TEACHER: 2 +} as const; + +export const AUDIENCE_OPTIONS = [ + { value: AUDIENCE.STUDENT, label: "面向学生" }, + { value: AUDIENCE.TEACHER, label: "面向教师" } +]; + +/** 表单项(头表 + 版本指针,不含 schema 正文) */ +export interface FormView { + formUuid: string; + schId: number; + appCode: string; + code: string; + name: string; + status: number; + /** DRAFT/PUBLISHED/DISABLED 枚举名,页面直接用它判分支,不再自己映射数字 */ + statusName: string; + /** 当前发布版本,null=从未发布 */ + currentVersion: number | null; + /** 待发布的草稿版本号,null=无草稿(P20:UI 必须区分这两根指针) */ + draftVersion: number | null; + audience: number; + fillLimit: number; + /** yyyy-MM-dd HH:mm:ss,null=不限 */ + deadline: string | null; + answerNoEnabled: number; + groupTag: string | null; + statusDefs: StatusDefView[] | null; + createTime: string; + updateTime: string; +} + +/** 版本行(schemaJson 只有单版本查询才回填) */ +export interface VersionView { + formUuid: string; + version: number; + /** true=已发布(该行内容逐字节冻结,V7) */ + published: boolean; + changeLog: string | null; + publishTime: string | null; + createTime: string; + updateTime: string; + schemaJson?: string | null; +} + +/** 状态定义条目:存储形状 [{code,label,is_deleted}](契约 §10) */ +export interface StatusDefView { + code: string; + label: string; + is_deleted: boolean; +} + +/** 字典(两级:schId=0 平台级 / 其余校级) */ +export interface DictView { + schId: number; + /** true=平台级:学校可见、可用、不可改(契约 §2.1) */ + platformLevel: boolean; + code: string; + name: string; + items: DictItemView[]; +} + +/** 字典条目:value 不可变;deleted=true 时设计器不可新选,但旧答卷提交仍放行 */ +export interface DictItemView { + value: string; + label: string; + sort: number; + deleted: boolean; +} + +export interface FormCreateRequest { + /** 缺省 form-center(中心自己的表单) */ + appCode?: string; + code: string; + name: string; + schemaJson?: string; +} + +export interface FormSaveRequest { + uuid: string; + /** 仅「从未发布」时可改;发布后不可改(契约 §1) */ + code?: string; + name?: string; + schemaJson?: string; + changeLog?: string; +} + +export interface FormConfigRequest { + uuid: string; + audience?: number; + fillLimit?: number; + /** yyyy-MM-dd(后端补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串=不限 */ + deadline?: string | null; + answerNoEnabled?: number; + groupTag?: string | null; +} + +export interface FormCopyRequest { + uuid: string; + appCode?: string; + code: string; + name: string; +} + +export interface StatusDefsRequest { + uuid: string; + /** null=回默认「待处理/已处理」(后端 updateStatusDefs 口径) */ + statusDefs: StatusDefView[] | null; +} + +export interface DictSaveRequest { + /** 仅超管可用(0=平台级字典);校级管理员传别值被后端拒 */ + schId?: number; + code: string; + name: string; +} + +export interface DictItemRequest { + schId?: number; + dictCode: string; + /** 定位键:新建时由设计器生成,之后接口没有改 value 的通道 */ + value: string; + label: string; + sort?: number; +} diff --git a/src/types/formSchema.ts b/src/types/formSchema.ts new file mode 100644 index 0000000..d57658f --- /dev/null +++ b/src/types/formSchema.ts @@ -0,0 +1,255 @@ +/** + * 表单 schema 契约常量(前端唯一来源)。 + * + * 与后端 `common/FormSchemaValidator.java` 同源:14 类控件一次冻结、一期放行 11 类、 + * 字段属性白名单 19 项、group/option 的结构白名单、key 与 code 的正则、value 与 label 的长度上限。 + * 两端各自的清单在这里都写了出处行号,改契约时必须同时改两处—— + * t6-designer-smoke.sh 的「白名单前后端同源」断言直接拿这份常量与后端常量逐字比对,漂移即红。 + */ + +/** 14 类控件命名一次冻结(契约 §2、方案 §4.2) */ +export const FROZEN_TYPES = [ + "text", + "textarea", + "number", + "date", + "select", + "radio", + "upload", + "checkbox", + "multiselect", + "phone", + "address", + "image", + "user", + "users" +] as const; + +/** 一期放行 11 类:address/user/users 二期,控件面板不得出现(后端发布校验同样拒) */ +export const PHASE1_TYPES = [ + "text", + "textarea", + "number", + "date", + "select", + "radio", + "upload", + "checkbox", + "multiselect", + "phone", + "image" +] as const; + +/** 二期类型:面板不列、画布若从历史 schema 读到则显式标记(不可编辑发布) */ +export const PHASE2_TYPES: readonly string[] = FROZEN_TYPES.filter( + (t) => !(PHASE1_TYPES as readonly string[]).includes(t) +); + +/** 需要 options 的四类(inline 数组与字典引用二选一,且 inline 不得空数组——后端 validateOptions) */ +export const OPTION_TYPES = ["select", "radio", "checkbox", "multiselect"] as const; + +/** 字段属性白名单 19 项(契约 §2:白名单外一律拒绝;valueSchema 不在名单内,回传必须剥掉) */ +export const ALLOWED_FIELD_PROPS = [ + "key", + "label", + "type", + "required", + "pattern", + "min", + "max", + "maxLength", + "tip", + "placeholder", + "options", + "rows", + "defaultValue", + "accept", + "maxSize", + "maxFiles", + "precision", + "unit", + "searchable" +] as const; + +/** 分组结构白名单(P17 定稿:group 属性只锁 key/label/fields,svc-hall 的 title 形态不再用) */ +export const ALLOWED_GROUP_PROPS = ["key", "label", "fields"] as const; + +/** 选项条目白名单(契约 §2:{label,value},字符串数组被拒) */ +export const ALLOWED_OPTION_PROPS = ["label", "value"] as const; + +/** 字典引用白名单(契约 §2.1:options 只能是 {source:"dict:{code}"} 这一个键) */ +export const ALLOWED_DICT_REF_PROPS = ["source"] as const; + +/** 字段/分组 key:字母/数字/下划线 ≤64(契约 §2;group.key 与 field.key 共用唯一命名空间,P17) */ +export const KEY_PATTERN = /^[A-Za-z0-9_]{1,64}$/; + +/** 表单 code:URL-safe 子集(P16 定稿,前端校验与后端 CODE_PATTERN 同规则) */ +export const CODE_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; + +/** 字典引用形态:dict:{code},code 取 KEY_PATTERN(契约 §2.1) */ +export const DICT_SOURCE_PATTERN = /^dict:[A-Za-z0-9_]{1,64}$/; + +/** 选项 value 上限 64(P18,与 form_dict_item.value 列宽同口径),label 上限 128 */ +export const MAX_OPTION_VALUE_LEN = 64; +export const MAX_LABEL_LEN = 128; + +/** precision 上界 6:answer_index.v_num 是 DECIMAL(20,6),超出即索引侧丢精度(后端同判) */ +export const MAX_PRECISION = 6; + +/** 名称/字典名上限:form.name 与 form_dict.name 均 varchar(128)(契约 §10) */ +export const MAX_NAME_LEN = 128; + +/** change_log 上限:form_version.change_log varchar(500)(契约 §10) */ +export const MAX_CHANGE_LOG_LEN = 500; + +/** + * 无检索算子的类型(P28):upload/image 既不写 answer_index 也不进筛选候选, + * 设计器必须把它们排除出可勾选 searchable 的集合——否则给出一个勾了却恒返回空集的筛选项。 + */ +export const NO_SEARCHABLE_TYPES = ["upload", "image"] as const; + +/** 附件值形状(契约 §3 / 方案 §4.3②):只存 {id,fileName,key},绝对 URL 进答卷=环境耦合 */ +export interface FileValue { + id: number; + fileName: string; + key: string; +} + +/** 单文件 ≤50MB、每字段 ≤9 个(契约 §3;字段级 maxSize/accept 沿白名单) */ +export const MAX_FILE_SIZE_MB = 50; +export const MAX_FILES_PER_FIELD = 9; + +/** + * 附件扩展名预设(P28:accept 白名单不得含 .svg——上传侧黑名单同口径, + * 图片字段给 svg 等于把「图片」变成可承载脚本的入口)。 + */ +export const IMAGE_ACCEPT = ".jpg,.jpeg,.png,.gif,.webp,.bmp"; +export const FILE_ACCEPT = ".doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf,.txt,.zip,.rar,.jpg,.jpeg,.png,.gif"; + +/** accept 里禁止出现的扩展名(大小写不敏感) */ +export const FORBIDDEN_ACCEPTS = [".svg"]; + +/** 选项条目:label 随便改,value 建一次即不可变(答案存 value——契约 §2、方案 §4.3①) */ +export interface FieldOption { + label: string; + value: string; +} + +/** 字典引用形态 */ +export interface DictRef { + source: string; +} + +export type FieldOptions = FieldOption[] | DictRef; + +/** 需要选项的字段:inline 或字典引用二选一 */ +export function isDictRef(options: unknown): options is DictRef { + return !!options && !Array.isArray(options) && typeof options === "object"; +} + +/** 字典引用 → code(非引用返回 null) */ +export function dictRefCode(options: unknown): string | null { + if (!isDictRef(options)) return null; + const source = String((options as DictRef).source || ""); + return DICT_SOURCE_PATTERN.test(source) ? source.slice("dict:".length) : null; +} + +/** 属性面板/画布编辑态字段:白名单属性 + 内部 _uid(序列化时剔除) */ +export interface DesignField { + _uid: string; + key: string; + label: string; + type: string; + required?: boolean; + pattern?: string; + min?: number; + max?: number; + maxLength?: number; + tip?: string; + placeholder?: string; + options?: FieldOptions; + rows?: number; + defaultValue?: unknown; + accept?: string; + maxSize?: number; + maxFiles?: number; + precision?: number; + unit?: string; + searchable?: boolean; +} + +/** 编辑态分组(P17:必带 key + label) */ +export interface DesignGroup { + _uid: string; + key: string; + label: string; + fields: DesignField[]; +} + +/** 控件目录:设计器左栏渲染源(只列一期 11 类) */ +export interface ControlCatalogItem { + type: string; + name: string; + /** base=基础字段,enhance=增强字段(附件/手机这类带平台约束的) */ + section: "base" | "enhance"; +} + +export const CONTROL_CATALOG: ControlCatalogItem[] = [ + { type: "text", name: "单行文本", section: "base" }, + { type: "textarea", name: "多行文本", section: "base" }, + { type: "number", name: "数字", section: "base" }, + { type: "date", name: "日期", section: "base" }, + { type: "select", name: "下拉选择", section: "base" }, + { type: "radio", name: "单选", section: "base" }, + { type: "checkbox", name: "多选", section: "base" }, + { type: "multiselect", name: "下拉多选", section: "base" }, + { type: "phone", name: "手机号", section: "enhance" }, + { type: "image", name: "图片", section: "enhance" }, + { type: "upload", name: "附件", section: "enhance" } +]; + +/** 控件中文名(画布卡片/属性面板标题;二期三类也给出,便于历史 schema 的降级呈现) */ +export const CONTROL_NAMES: Record = { + text: "单行文本", + textarea: "多行文本", + number: "数字", + date: "日期", + select: "下拉选择", + radio: "单选", + checkbox: "多选", + multiselect: "下拉多选", + phone: "手机号", + image: "图片", + upload: "附件", + address: "地址(二期)", + user: "选人(二期)", + users: "多选人(二期)" +}; + +/** 值形状说明(契约 §3 的表):属性面板脚注用,让编辑的人知道答卷里存的是什么 */ +export const VALUE_SHAPE_HINT: Record = { + text: "字符串", + textarea: "字符串(只读呈现保留换行)", + phone: "字符串", + number: "数字或 null(空不是空串)", + date: "YYYY-MM-DD 字符串", + select: "选项 value(字符串)", + radio: "选项 value(字符串)", + checkbox: "选项 value 数组,展示按 options 定义序", + multiselect: "选项 value 数组,展示按 options 定义序", + image: "[{id,fileName,key}]", + upload: "[{id,fileName,key}]" +}; + +export function isOptionType(type: string): boolean { + return (OPTION_TYPES as readonly string[]).includes(type); +} + +export function isPhase1Type(type: string): boolean { + return (PHASE1_TYPES as readonly string[]).includes(type); +} + +/** searchable 是否可勾(P28:附件两类排除在可勾选集合外) */ +export function searchableAllowed(type: string): boolean { + return !(NO_SEARCHABLE_TYPES as readonly string[]).includes(type); +} diff --git a/src/utils/dataValue.ts b/src/utils/dataValue.ts new file mode 100644 index 0000000..b0388a2 --- /dev/null +++ b/src/utils/dataValue.ts @@ -0,0 +1,297 @@ +/** + * 值呈现与列表列的纯函数集(契约 §3 值形状 + 方案 §5.2/§7.3 的列表列策略)。 + * + * 为什么单独成文件且零运行时依赖(只 import type): + * - **渲染与校验同源**:数据列表、答卷详情、编辑弹层、我的填写四处都吃这一份呈现规则, + * 写三遍必然漂移(哪一处的选项序不一致就成了"统计对不上列表"的假 bug); + * - **可脱离浏览器断言**:冒烟脚本用 esbuild 单转本文件后在 node 里跑(同 T5 的 menuNav.ts 取证法), + * V8/V40 的判据(已删字段降级、截断、选项定义序)不依赖起服务。 + * + * 红线:值来自任意用户输入,**全仓禁 v-html**(方案 §2.3)——这里只产字符串, + * 换行等排版由 CSS(pre-wrap)承载,绝不把值拼成 HTML。 + */ +import type { DesignField, FieldOption } from "@/types/formSchema"; +import type { AnswerRow } from "@/types/data"; + +/** 三态空值的统一占位(契约 §3:字段在 schema 但值为空 / values 无此 key,都显示它) */ +export const EMPTY_TEXT = "—"; + +/** 已删字段的降级前缀(V8:不静默丢弃,原始 key 与原始值都要看得见) */ +export const REMOVED_PREFIX = "(已移除字段)"; + +/** 废弃状态的前缀(方案 §5.3①:删状态定义后历史答卷显示状态名并标废弃) */ +export const DEPRECATED_STATUS_PREFIX = "已废弃:"; + +/** 单元格截断阈值(方案 §7.3②:超长截断 + tooltip 全文) */ +export const CELL_TEXT_LIMIT = 40; + +/** 附件值形状(契约 §3:只有 id/fileName/key,没有 fileUrl) */ +export interface FileEntry { + id: number; + fileName: string; + key: string; +} + +/** 列表列:最新 schema 字段 ∪ 历史值里出现过的 key(方案 §5.2 的对外形态) */ +export interface DataCol { + key: string; + label: string; + /** null=该 key 不在最新 schema 内(已删字段),降级显示 */ + field: DesignField | null; + removed: boolean; + /** 呈现用的类型;已删字段没有类型,按 text 走 */ + type: string; + /** 该列在当前已加载行里的取值密度(0~1),用来给"只有历史行才有值"的列提示 */ + filledRatio: number; +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** 空值判定:null/undefined/空串/空数组都算"没填"(number 的 0 与布尔 false 不算空) */ +export function isEmptyValue(value: unknown): boolean { + if (value === null || value === undefined) return true; + if (typeof value === "string") return value.trim() === ""; + if (Array.isArray(value)) return value.length === 0; + return false; +} + +/** 是不是附件值数组(元素带 fileName——脏数据里的对象数组也按这个形状兜) */ +export function isFileArray(value: unknown): value is FileEntry[] { + return Array.isArray(value) && value.length > 0 && value.every((x) => isPlainObject(x) && "fileName" in (x as object)); +} + +/** 附件列的"N 个文件"(方案 §7.3④:附件/图片列默认折成徽标,不铺文件名) */ +export function attachmentCount(value: unknown): number { + return Array.isArray(value) ? value.length : 0; +} + +export function attachmentNames(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((x) => (isPlainObject(x) ? String((x as Record).fileName ?? "") : String(x))); +} + +/** 字段的选项集:读接口的 schema 已由后端把字典引用内联展开(契约 §2.1),这里只认数组形态 */ +export function optionsOf(field: DesignField | null): FieldOption[] { + const raw = field?.options; + return Array.isArray(raw) ? raw : []; +} + +/** 单值按 options 回查 label;查不到就显示原 value(脏值不静默吞,P19 的边界在提交侧,呈现侧要能看出来) */ +export function optionLabel(field: DesignField | null, value: unknown): string { + const hit = optionsOf(field).find((o) => o.value === String(value)); + return hit ? hit.label : String(value); +} + +/** 按 options 定义序排(契约 §3:多选展示顺序按 options 定义序,不按勾选序) */ +export function sortedByOptionOrder(field: DesignField | null, values: unknown[]): string[] { + const order = optionsOf(field).map((o) => o.value); + const texts = values + .map((v) => String(v)) + .slice() + .sort((a, b) => { + const ia = order.indexOf(a); + const ib = order.indexOf(b); + // 不在选项集里的脏值排最后,彼此保持原序 + if (ia === -1 && ib === -1) return a < b ? -1 : 1; + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + return texts.map((t) => (order.includes(t) ? optionLabel(field, t) : t)); +} + +/** 手机号只读态脱敏(契约 §3:脱敏是只读呈现独有,编辑态不脱敏) */ +export function maskPhone(value: string): string { + const v = value.trim(); + if (!/^1\d{10}$/.test(v)) return v; + return `${v.slice(0, 3)}****${v.slice(7)}`; +} + +/** number 按 precision 格式化 + unit 后缀(契约 §3;量级不在前端补校验——P54 裁决) */ +export function formatNumber(field: DesignField | null, value: unknown): string { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return String(value); + const precision = field?.precision; + const text = typeof precision === "number" && precision >= 0 ? n.toFixed(precision) : String(n); + return field?.unit ? `${text}${field.unit}` : text; +} + +/** + * 按契约 §3 把单个字段值转成展示串(只读呈现)。 + * 附件类不在这里铺文件名(列表要"N 个文件"徽标、详情要可点列表),返回计数文案。 + */ +export function formatFieldValue( + field: DesignField | null, + value: unknown, + opts: { maskPhone?: boolean } = {} +): string { + if (isEmptyValue(value)) return EMPTY_TEXT; + const type = field?.type ?? "text"; + switch (type) { + case "number": + return formatNumber(field, value); + case "select": + case "radio": + return optionLabel(field, value); + case "checkbox": + case "multiselect": + return Array.isArray(value) ? sortedByOptionOrder(field, value).join("、") : String(value); + case "image": + case "upload": + return `${attachmentCount(value)} 个文件`; + case "phone": + return opts.maskPhone === false ? String(value) : maskPhone(String(value)); + default: + // text / textarea / date 与二期类型(address/user/users 的值形状还没定死)都按原样字符串 + return Array.isArray(value) ? value.join("、") : String(value); + } +} + +/** 已删字段的原始值串:数组/对象给紧凑 JSON,标量给原值(不丢信息,也不铺一坨不可读的转义) */ +export function rawValueText(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** 三态空值 + 已删字段降级(契约 §3 的最后一条:values 有 key 但 schema 已删 → 点名降级) */ +export function cellText(field: DesignField | null, key: string, values: Record | null | undefined, + opts: { maskPhone?: boolean } = {}): string { + const has = isPlainObject(values) && Object.prototype.hasOwnProperty.call(values, key); + const value = has ? (values as Record)[key] : undefined; + if (!field) { + // schema 已删这条 key:值还在就降级显示,值本来就没填过则按空态(不能假装它是个空字段——它已经不存在) + return has && !isEmptyValue(value) ? `${REMOVED_PREFIX}${key}: ${rawValueText(value)}` : EMPTY_TEXT; + } + if (!has || isEmptyValue(value)) return EMPTY_TEXT; + return formatFieldValue(field, value, opts); +} + +/** 超长截断 + 保留全文给 tooltip(V40) */ +export function clipText(text: string, limit = CELL_TEXT_LIMIT): { shown: string; truncated: boolean; full: string } { + const oneLine = text.replace(/\s*\n\s*/g, " ").trim(); + if (oneLine.length <= limit) return { shown: oneLine, truncated: false, full: text }; + return { shown: `${oneLine.slice(0, limit)}…`, truncated: true, full: text }; +} + +/** 状态列:后端给 null label 即该 code 已不在定义内 → 标废弃(V13 的呈现半边) */ +export function statusDisplay(status: string, label: string | null): string { + if (label) return label; + return status ? `${DEPRECATED_STATUS_PREFIX}${status}` : EMPTY_TEXT; +} + +/** schema groups(后端下发的展开形态)摊平成字段列表,保持数组序即展示序(契约 §2:无 orderNum) */ +export function flattenFields(groups: unknown): DesignField[] { + if (!Array.isArray(groups)) return []; + const out: DesignField[] = []; + for (const group of groups) { + const fields = isPlainObject(group) ? (group as Record).fields : undefined; + if (!Array.isArray(fields)) continue; + for (const f of fields) { + if (isPlainObject(f) && typeof f.key === "string") out.push(f as unknown as DesignField); + } + } + return out; +} + +/** + * 列表列 = 最新 schema 字段 ∪ 当页值文档里出现过但 schema 已删的 key(方案 §5.2)。 + * 已删列排在最新列之后——它们是历史残留,不该插进当前表单的字段序里误导读表的人。 + */ +export function deriveColumns(schemaFields: DesignField[], rows: AnswerRow[]): DataCol[] { + const byKey = new Map(); + schemaFields.forEach((f) => byKey.set(f.key, f)); + const cols: DataCol[] = schemaFields.map((f) => ({ + key: f.key, + label: f.label || f.key, + field: f, + removed: false, + type: f.type, + filledRatio: 0 + })); + const removedOrder: string[] = []; + const removedFilled = new Map(); + const filledCount = new Map(); + for (const row of rows) { + const values = isPlainObject(row.values) ? (row.values as Record) : {}; + for (const key of Object.keys(values)) { + if (byKey.has(key)) { + if (!isEmptyValue(values[key])) filledCount.set(key, (filledCount.get(key) ?? 0) + 1); + continue; + } + if (!removedOrder.includes(key)) { + removedOrder.push(key); + removedFilled.set(key, 0); + } + if (!isEmptyValue(values[key])) removedFilled.set(key, (removedFilled.get(key) ?? 0) + 1); + } + } + const total = rows.length || 1; + cols.forEach((c) => (c.filledRatio = (filledCount.get(c.key) ?? 0) / total)); + removedOrder.forEach((key) => cols.push({ + key, + label: `${REMOVED_PREFIX}${key}`, + field: null, + removed: true, + type: "text", + filledRatio: (removedFilled.get(key) ?? 0) / total + })); + return cols; +} + +/** 默认可见列:前 N 列里已填过的 + 全部 searchable 列(方案 §7.3①) */ +export function defaultVisibleKeys(cols: DataCol[], firstN = 5): string[] { + const keys: string[] = []; + cols.slice(0, firstN).forEach((c) => keys.push(c.key)); + cols.forEach((c) => { + if (c.field?.searchable === true && !keys.includes(c.key)) keys.push(c.key); + }); + return keys; +} + +/** + * 可见列记忆的存储键(V40"按表单 code 记忆")。 + * 带 appCode:同 code 在不同归属应用下是两张表单(契约 §1 唯一键含 app_code),不能共用一份列配置。 + */ +export function columnMemoryKey(appCode: string, code: string): string { + return `fc:data-cols:${appCode || "form-center"}/${code}`; +} + +export function loadColumnMemory(key: string): string[] | null { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : null; + } catch { + return null; + } +} + +export function saveColumnMemory(key: string, keys: string[]): void { + try { + localStorage.setItem(key, JSON.stringify(keys)); + } catch { + /* 隐私模式/配额写不进:记忆是增强项,不阻塞列表 */ + } +} + +/** + * 记忆与当前列集合的交集 + 新列补齐(改了列配置或表单升版加了字段后,老记忆不该吞掉新列, + * 也不该留着已经不存在于列集合里的 key)。 + */ +export function reconcileVisibleKeys(cols: DataCol[], remembered: string[] | null): string[] { + const all = cols.map((c) => c.key); + if (!remembered || !remembered.length) return defaultVisibleKeys(cols); + const kept = remembered.filter((k) => all.includes(k)); + const added = all.filter((k) => !remembered.includes(k)); + // 全新列跟在记忆后,已删字段列不进默认(记忆里有它就保留) + return [...kept, ...added.filter((k) => !cols.find((c) => c.key === k)?.removed)]; +} diff --git a/src/utils/designer/schema.ts b/src/utils/designer/schema.ts new file mode 100644 index 0000000..d268caa --- /dev/null +++ b/src/utils/designer/schema.ts @@ -0,0 +1,547 @@ +/** + * 设计器 schema 工具:编辑态生成、白名单序列化、本地契约校验。 + * + * 三条纪律写在这里,组件不各写一份: + * 1. **产出的对象只含契约白名单属性**(pickField 逐字段过滤,内部 `_uid` 与空串一律不落)—— + * 空串尤其危险:`pattern:""` 到后端是「能编译的空正则」,会把所有非空值判成格式不正确。 + * 2. **value 生成即不可变**(P19):选项只暴露 label 输入框,value 由 nextOptionValue 生成后 + * 只读;改 label 绝不动 value,否则历史答卷的筛选与统计口径当场断裂(方案 §4.3①)。 + * 3. **本地校验与后端 FormSchemaValidator 同规则**(报错文案同构):设计器里的红线 + * (key 字符集、白名单、options 形状、precision 上界…)在保存前就拦住, + * 但不替代后端——后端仍是唯一权威,前端只是少跑一趟冤枉路。 + */ +import type { DesignField, DesignGroup, FieldOption, FieldOptions } from "@/types/formSchema"; +import { + ALLOWED_FIELD_PROPS, + CODE_PATTERN, + DICT_SOURCE_PATTERN, + FILE_ACCEPT, + FORBIDDEN_ACCEPTS, + FROZEN_TYPES, + IMAGE_ACCEPT, + KEY_PATTERN, + MAX_FILES_PER_FIELD, + MAX_LABEL_LEN, + MAX_NAME_LEN, + MAX_OPTION_VALUE_LEN, + MAX_PRECISION, + MAX_FILE_SIZE_MB, + PHASE1_TYPES, + isOptionType, + isDictRef, + searchableAllowed +} from "@/types/formSchema"; + +let uidSeq = 0; + +/** 内部唯一 id:拖拽与 v-for 的渲染 key,非契约字段,序列化时必须剔除 */ +export function genUid(): string { + uidSeq += 1; + return `uid_${Date.now().toString(36)}_${uidSeq}`; +} + +/** 控件类型 → key 词根(生成的 key 只是默认可改值,最终唯一性由命名空间集合判) */ +const TYPE_KEY_BASE: Record = { + text: "text", + textarea: "textarea", + number: "number", + date: "date", + select: "select", + radio: "radio", + checkbox: "check", + multiselect: "multi", + phone: "phone", + upload: "file", + image: "img" +}; + +/** + * key 生成器:group.key 与 field.key 共用一个唯一命名空间(P17 定稿), + * 所以占用集必须一次收齐分组与字段,否则会出现「分组 base 抢了字段 base」。 + */ +export function makeNextKey(used: Set, base = "field"): (type?: string) => string { + return (type?: string) => { + const root = type ? TYPE_KEY_BASE[type] || base : base; + let n = 1; + let k = `${root}_${n}`; + while (used.has(k)) { + n += 1; + k = `${root}_${n}`; + } + used.add(k); + return k; + }; +} + +/** 收集全表单已占用的 key(分组 + 字段,共享命名空间) */ +export function collectUsedKeys(groups: DesignGroup[]): Set { + const set = new Set(); + for (const g of groups || []) { + if (g.key) set.add(g.key); + for (const f of g.fields || []) { + if (f.key) set.add(f.key); + } + } + return set; +} + +/** 重复占用的 key(分组与字段之间、字段与字段之间同判) */ +export function duplicateKeys(groups: DesignGroup[]): Set { + const counts = new Map(); + for (const g of groups || []) { + for (const k of [g.key, ...(g.fields || []).map((f) => f.key)]) { + if (!k) continue; + counts.set(k, (counts.get(k) || 0) + 1); + } + } + return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([k]) => k)); +} + +/** + * 选项 value 生成器(P18/P19):非空、≤64、同字段内唯一,形态 `_`。 + * + * 为什么不用 label 当 value:那是 svc-hall 的旧形态(label 即 value),老师把「高二年级」 + * 改成「高二」时历史统计立刻打穿;而且同名 label 会撞出重复 value。 + * 序号取现有最大值 +1,删掉中间的选项也不会回收出重复值。 + * 字典条目用同一个生成器(prefix 传 "val"),保证「设计器给的 value 一律建后不改」。 + */ +export function nextOptionValue(existing: string[], prefix = "opt"): string { + const re = new RegExp(`^${prefix}_(\\d+)$`); + let max = 0; + for (const v of existing || []) { + const m = re.exec(String(v)); + if (m) max = Math.max(max, Number(m[1])); + } + let candidate = `${prefix}_${max + 1}`; + while ((existing || []).includes(candidate)) { + max += 1; + candidate = `${prefix}_${max + 1}`; + } + return candidate.slice(0, MAX_OPTION_VALUE_LEN); +} + +/** 新建一个选项条目:value 一次性生成,之后只读 */ +export function makeOption(existing: string[], label = "", prefix = "opt"): FieldOption { + return { label, value: nextOptionValue(existing, prefix) }; +} + +/** 控件默认显示名(点击添加时作为 label 初值) */ +function defaultLabel(type: string): string { + const map: Record = { + text: "单行文本", + textarea: "多行文本", + number: "数字", + date: "日期", + select: "下拉选择", + radio: "单选", + checkbox: "多选", + multiselect: "下拉多选", + phone: "手机号", + upload: "附件", + image: "图片" + }; + return map[type] || type; +} + +/** + * 生成字段初值。只铺「契约允许且确有默认意义」的属性: + * maxSize 单位 MB(后端值校验按 MB),图片/附件的 accept 预设都不含 .svg(P28)。 + */ +export function defaultField(type: string, key: string, label?: string): DesignField { + const f: DesignField = { _uid: genUid(), key, label: label || defaultLabel(type), type }; + switch (type) { + case "textarea": + f.rows = 5; + f.maxLength = 1000; + f.placeholder = "请输入"; + break; + case "select": + case "multiselect": + f.options = seedOptionList(); + f.placeholder = "请选择"; + break; + case "radio": + case "checkbox": + f.options = seedOptionList(); + break; + case "phone": + f.placeholder = "请输入手机号"; + f.tip = "请输入 1 开头的 11 位手机号"; + f.pattern = "^1[3-9]\\d{9}$"; + f.maxLength = 11; + break; + case "upload": + f.accept = FILE_ACCEPT; + f.maxSize = MAX_FILE_SIZE_MB; + f.maxFiles = MAX_FILES_PER_FIELD; + break; + case "image": + f.accept = IMAGE_ACCEPT; + f.maxSize = MAX_FILE_SIZE_MB; + f.maxFiles = MAX_FILES_PER_FIELD; + break; + default: + break; + } + return f; +} + +/** 选项类控件的初始三项(value 即生成,后续 label 再怎么改都不回写 value) */ +export function seedOptionList(): FieldOption[] { + const values: string[] = []; + return ["选项1", "选项2", "选项3"].map((label) => { + const option = makeOption(values, label); + values.push(option.value); + return option; + }); +} + +/** + * 新建分组(P17:key 与 label 都在初值里给全)。 + * 首组沿用契约 §2 示例的 base/基本信息,之后的组按序号给 key 与 label, + * key 从共享命名空间里取,所以不会和已有字段撞名。 + */ +export function defaultGroup(used: Set, index: number): DesignGroup { + const key = index === 0 && !used.has("base") ? "base" : makeNextKey(used, "group")(); + used.add(key); + return { _uid: genUid(), key, label: index === 0 ? "基本信息" : `分组${index + 1}`, fields: [] }; +} + +/** 深拷贝一份编辑态(复制字段用;_uid 由调用方重assign) */ +export function cloneField(f: DesignField): DesignField { + const copy = JSON.parse(JSON.stringify({ ...f, _uid: "" })) as DesignField; + copy._uid = genUid(); + return copy; +} + +function isEmptyValue(v: unknown): boolean { + if (v === undefined || v === null) return true; + if (typeof v === "string") return v.trim() === ""; + return false; +} + +/** + * 序列化为契约字段对象:只输出白名单 19 项,空串/null 一律剔除。 + * 注意 0 / false / 空数组都要保留——`precision:0`、`required:false` 是有语义的值。 + */ +export function pickField(f: DesignField): Record { + const out: Record = {}; + for (const prop of ALLOWED_FIELD_PROPS) { + const v = (f as unknown as Record)[prop]; + if (isEmptyValue(v)) continue; + out[prop] = v; + } + return out; +} + +/** 序列化分组(group 属性白名单锁 key/label/fields,别的一律不落——P17) */ +export function pickGroup(g: DesignGroup): Record { + return { key: g.key, label: g.label, fields: (g.fields || []).map(pickField) }; +} + +/** 产出 schema_json(数组序即展示序,无 orderNum) */ +export function buildSchemaJson(groups: DesignGroup[]): string { + return JSON.stringify({ groups: (groups || []).map(pickGroup) }); +} + +/** 设计器预览用的 schema 对象(与保存同一份,避免「预览过了保存被拒」两套口径) */ +export function buildSchema(groups: DesignGroup[]): { groups: Record[] } { + return JSON.parse(buildSchemaJson(groups)); +} + +/** 后端 schema_json → 编辑态(补 _uid;白名单外的键原样丢弃,防止脏数据回流到保存体) */ +export function restoreSchema(schemaJson: string | null | undefined): DesignGroup[] { + let parsed: any; + try { + parsed = JSON.parse(schemaJson || "{}"); + } catch { + return []; + } + const groups = Array.isArray(parsed?.groups) ? parsed.groups : []; + return groups.map((g: any) => ({ + _uid: genUid(), + key: typeof g?.key === "string" ? g.key : "", + label: typeof g?.label === "string" ? g.label : "", + fields: (Array.isArray(g?.fields) ? g.fields : []).map((raw: any) => { + const f: DesignField = { _uid: genUid(), key: "", label: "", type: "" }; + if (raw && typeof raw === "object") { + for (const prop of ALLOWED_FIELD_PROPS) { + if (prop in raw) (f as unknown as Record)[prop] = raw[prop]; + } + } + if (typeof f.key !== "string") f.key = String(f.key ?? ""); + if (typeof f.label !== "string") f.label = String(f.label ?? ""); + if (typeof f.type !== "string") f.type = String(f.type ?? ""); + return f; + }) + })); +} + +export function allFields(groups: DesignGroup[]): DesignField[] { + return (groups || []).flatMap((g) => g.fields || []); +} + +export function countFields(groups: DesignGroup[]): number { + return (groups || []).reduce((n, g) => n + (g.fields || []).length, 0); +} + +/** + * 收集「字段 key → 选项 value 有序数组」(V2 的快照断言点): + * 改 label 之后这份快照必须逐字节不变,否则说明设计器把 value 跟着 label 走了。 + * 字典引用没有内联 value,记 null 表示「值集由字典侧决定,设计器改不动」。 + */ +export function optionValueSnapshot(groups: DesignGroup[]): Record { + const out: Record = {}; + for (const f of allFields(groups)) { + if (!isOptionType(f.type)) continue; + const options = f.options as FieldOptions | undefined; + if (!options) continue; + out[f.key] = isDictRef(options) ? null : (options as FieldOption[]).map((o) => o.value); + } + return out; +} + +/** accept 里是否混入禁用扩展名(P28:附件白名单不得含 .svg) */ +export function forbiddenAcceptIn(accept?: unknown): string | null { + if (typeof accept !== "string" || !accept.trim()) return null; + const parts = accept.split(",").map((p) => p.trim().toLowerCase()); + return FORBIDDEN_ACCEPTS.find((bad) => parts.includes(bad)) || null; +} + +/** 发布过的字段身份(key → type):同 key 换 type 是后端红线,UI 与本地校验都按这份判 */ +export function typesByKeys(schemaJson: string | null | undefined): Map { + const map = new Map(); + for (const g of restoreSchema(schemaJson)) { + for (const f of g.fields) { + if (f.key) map.set(f.key, f.type); + } + } + return map; +} + +export interface ValidateContext { + /** 表单 code(P16 同规则);新建/未发布时才判,发布后 code 不可改由 UI 锁输入 */ + code?: string; + /** 表单名称 */ + name?: string; + /** 当前发布版本的 schema_json:同 key 不得换 type(后端 assertFieldTypesStable) */ + publishedSchemaJson?: string | null; +} + +/** 单个属性的类型判据(与后端 validateCountProps/validateTextProps/validateBoolean 同口径) */ +function checkPropShape(f: DesignField): string[] { + const errors: string[] = []; + const at = (msg: string) => errors.push(`字段「${f.key}」${msg}`); + const node = f as unknown as Record; + + for (const prop of ["required", "searchable"]) { + const v = node[prop]; + if (v !== undefined && v !== null && typeof v !== "boolean") at(`${prop} 必须是 true/false`); + } + const pattern = node.pattern; + if (typeof pattern === "string" && pattern.trim()) { + try { + new RegExp(pattern); + } catch { + at(`pattern 不可编译:${pattern}`); + } + } + const min = node.min; + const max = node.max; + if (min !== undefined && min !== null && typeof min !== "number") at("min 必须是数字"); + if (max !== undefined && max !== null && typeof max !== "number") at("max 必须是数字"); + if (typeof min === "number" && typeof max === "number" && min > max) at("min 不得大于 max"); + + for (const [prop, floor] of [ + ["maxLength", 1], + ["rows", 1], + ["maxSize", 0], + ["maxFiles", 0] + ] as [string, number][]) { + const v = node[prop]; + if (v !== undefined && v !== null && (!Number.isInteger(v) || Number(v) < floor)) { + at(`${prop} 必须是 ≥${floor} 的整数`); + } + } + for (const prop of ["tip", "placeholder", "accept"]) { + const v = node[prop]; + if (v !== undefined && v !== null && typeof v !== "string") at(`${prop} 必须是字符串`); + } + + const precision = node.precision; + if (precision !== undefined && precision !== null) { + if (!Number.isInteger(precision) || Number(precision) < 0) at("precision 必须是非负整数"); + else if (Number(precision) > MAX_PRECISION) at(`precision 不得大于 ${MAX_PRECISION}(索引列 v_num DECIMAL(20,6))`); + } + const unit = node.unit; + if (unit !== undefined && unit !== null && (typeof unit !== "string" || unit.trim() === "")) { + at("unit 必须是非空字符串"); + } + return errors; +} + +/** options 契约(后端 validateOptions 的前端镜像:字符串数组被拒、value 非空 ≤64 且同字段唯一、字典引用形态) */ +function checkOptions(f: DesignField): string[] { + const errors: string[] = []; + const at = (msg: string) => errors.push(`字段「${f.key}」${msg}`); + const options = f.options as FieldOptions | undefined | null; + + if (options === undefined || options === null) { + if (isOptionType(f.type)) at("select/radio/checkbox/multiselect 必须提供 options"); + return errors; + } + if (isDictRef(options)) { + const keys = Object.keys(options); + if (keys.length !== 1 || keys[0] !== "source") at("字典引用形态只允许一个键 source"); + const source = String((options as { source?: unknown }).source || ""); + if (!DICT_SOURCE_PATTERN.test(source)) { + at(`字典引用形态必须是 "source":"dict:{code}"(code 限字母/数字/下划线):${source}`); + } + return errors; + } + if (!Array.isArray(options)) { + at("options 必须是 {label,value} 对象数组或字典引用对象"); + return errors; + } + if (isOptionType(f.type) && options.length === 0) at("options 不能为空数组"); + + const values = new Set(); + for (const option of options as unknown[]) { + if (!option || typeof option !== "object" || Array.isArray(option)) { + at(`options 必须是 {label,value} 对象数组,字符串数组被拒(当前值:${String(option)})`); + continue; + } + const o = option as Record; + const extra = Object.keys(o).filter((k) => k !== "label" && k !== "value"); + if (extra.length) at(`options 条目属性不在契约白名单内:${extra.join(",")}`); + const label = o.label; + const value = o.value; + if (typeof label !== "string" || label.trim() === "") at("options 条目缺少 label"); + else if (label.length > MAX_LABEL_LEN) at(`options 条目 label 过长(≤${MAX_LABEL_LEN})`); + if (typeof value !== "string" || value.trim() === "") { + at("options 条目缺少 value(答案存 value,label 可随便改)"); + } else if (value.length > MAX_OPTION_VALUE_LEN) { + at(`options 条目 value 过长(≤${MAX_OPTION_VALUE_LEN}):${value}`); + } else if (values.has(value)) { + at(`options 条目 value 重复:${value}`); + } else { + values.add(value); + } + } + return errors; +} + +/** defaultValue 的 JSON 类型须与契约 §3 该控件的值形状一致(后端 validateDefaultValue 镜像) */ +function checkDefaultValue(f: DesignField): string[] { + const errors: string[] = []; + const at = (msg: string) => errors.push(`字段「${f.key}」${msg}`); + const v = (f as unknown as Record).defaultValue; + if (v === undefined || v === null) return errors; + switch (f.type) { + case "number": + if (typeof v !== "number") at("defaultValue 对 number 必须是数字(空值请留空而非 \"\")"); + break; + case "date": + if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v)) at('defaultValue 对 date 必须是 "YYYY-MM-DD"'); + break; + case "checkbox": + case "multiselect": + if (!Array.isArray(v)) at(`defaultValue 对 ${f.type} 必须是数组`); + else if (!v.every((x) => typeof x === "string")) at(`defaultValue 对 ${f.type} 必须是字符串数组(存 value 数组)`); + break; + case "upload": + case "image": + if (!Array.isArray(v)) at(`defaultValue 对 ${f.type} 必须是数组`); + break; + default: + if (typeof v !== "string") at(`defaultValue 对 ${f.type} 必须是字符串`); + break; + } + return errors; +} + +function checkField(f: DesignField, usedKeys: Set): string[] { + const errors: string[] = []; + const at = (msg: string) => errors.push(`字段「${f.key || "未命名"}」${msg}`); + + if (!f.key || !KEY_PATTERN.test(f.key)) at(`字段 key 仅限字母/数字/下划线(≤64 位):${f.key}`); + else if (usedKeys.has(f.key)) at(`字段 key 重复:${f.key}`); + usedKeys.add(f.key); + + if (!f.label || !f.label.trim()) at("字段缺少 label"); + else if (f.label.length > MAX_LABEL_LEN) at(`label 过长(≤${MAX_LABEL_LEN})`); + + if (!f.type) at("字段缺少 type"); + else if (!(FROZEN_TYPES as readonly string[]).includes(f.type)) at(`字段类型未冻结:${f.type}`); + else if (!(PHASE1_TYPES as readonly string[]).includes(f.type)) at(`该类型二期开放,一期不可用:${f.type}`); + + errors.push(...checkPropShape(f)); + errors.push(...checkOptions(f)); + errors.push(...checkDefaultValue(f)); + + const badAccept = forbiddenAcceptIn(f.accept); + if (badAccept) at(`附件 accept 不得包含 ${badAccept}(P28:设计器不给上传入口放行矢量图)`); + if (f.searchable === true && !searchableAllowed(f.type)) { + at(`${f.type} 没有检索算子,不能勾 searchable(P28)`); + } + return errors; +} + +/** + * 单字段错误(属性面板实时显示用):传入当前占用 key 集合的副本, + * 免得把一个字段自己的 key 当成「重复」判掉。 + */ +export function fieldErrors(field: DesignField, usedKeys: Set): string[] { + const others = new Set(usedKeys); + others.delete(field.key); + return checkField(field, others); +} + +/** + * 本地契约校验:返回错误列表(空=通过)。 + * 结构与后端 validateSchema 同层次(root → group → field),文案同构,便于把前端拦截与后端拒绝对照看。 + */ +export function validateDesign(groups: DesignGroup[], ctx: ValidateContext = {}): string[] { + const errors: string[] = []; + if (ctx.name !== undefined) { + const name = String(ctx.name || "").trim(); + if (!name) errors.push("表单名称不能为空"); + else if (name.length > MAX_NAME_LEN) errors.push(`表单名称过长(≤${MAX_NAME_LEN})`); + } + if (ctx.code !== undefined && !CODE_PATTERN.test(String(ctx.code || "").trim())) { + errors.push(`表单 code 仅限字母/数字/下划线/中划线(≤64 位):${ctx.code}`); + } + if (!groups || groups.length === 0) errors.push('表单结构必须是 {"groups":[…]},且至少有一个分组'); + + const usedKeys = new Set(); + for (const g of groups || []) { + if (!g.key || !KEY_PATTERN.test(g.key)) errors.push(`分组 key 仅限字母/数字/下划线(≤64 位):${g.key}`); + else if (usedKeys.has(g.key)) errors.push(`分组 key 与字段 key 冲突:${g.key}`); + usedKeys.add(g.key); + if (!g.label || !String(g.label).trim()) errors.push(`分组缺少 label:${g.key}`); + if (!Array.isArray(g.fields)) errors.push(`每个分组必须含 fields 数组:${g.key}`); + else for (const f of g.fields) errors.push(...checkField(f, usedKeys)); + } + + if (countFields(groups) === 0 && errors.length === 0) errors.push("表单至少要有一个字段"); + + // 跨版本字段身份:同 key 换 type 是后端 assertFieldTypesStable 的拒绝项 + const publishedTypes = ctx.publishedSchemaJson ? typesByKeys(ctx.publishedSchemaJson) : null; + if (publishedTypes) { + for (const f of allFields(groups)) { + const old = publishedTypes.get(f.key); + if (old && old !== f.type) { + errors.push(`字段「${f.key}」key 已发布过,type 不可变更(${old}→${f.type});要换语义请另起 key(改 key=换字段,契约 §2)`); + } + } + } + return errors; +} + +/** 是否 code 合法(表单级新建/副本弹窗的输入框即时校验,P16) */ +export function isValidCode(code: string): boolean { + return CODE_PATTERN.test(String(code || "").trim()); +} + +/** 一期未放行(含二期三类)的类型集合,画布卡片用来标灰 */ +export const PHASE1_SET = new Set(PHASE1_TYPES); diff --git a/src/utils/embed.ts b/src/utils/embed.ts new file mode 100644 index 0000000..a070b89 --- /dev/null +++ b/src/utils/embed.ts @@ -0,0 +1,113 @@ +/** + * embed 通道工具(契约 §7 + 方案附录 B:修四坑后的 1.5 embed 子契约草案)。 + * + * 三条纪律: + * 1. 双向显式 origin 校验(禁 "*")——旧 FormManage 的 "*" 是反面教材; + * 2. 消息一律带 proto:"fc", v:1——协议版本一期即带,防跨版本误解析; + * 3. 仅承诺同站嵌入(平台单域名多路径架构天然成立)。 + */ + +export interface EmbedMessage { + proto: "fc"; + v: 1; + type: string; + [key: string]: any; +} + +/** 允许的 origin 白名单(同站嵌入:平台单域名多路径) */ +const ALLOWED_ORIGINS = new Set([ + window.location.origin, + // 开发环境可能跨端口 + "http://localhost:8089", + "http://127.0.0.1:8089" +]); + +/** 校验 origin 是否允许(同站嵌入) */ +export function isValidOrigin(origin: string): boolean { + if (!origin) return false; + // 同站:协议+域名+端口完全一致 + if (ALLOWED_ORIGINS.has(origin)) return true; + // 生产环境:同域名即可(平台单域名多路径) + try { + const targetUrl = new URL(origin); + const selfUrl = new URL(window.location.origin); + return targetUrl.hostname === selfUrl.hostname; + } catch { + return false; + } +} + +/** 发送消息到父窗口(显式 targetOrigin,禁 "*") */ +export function postToParent(message: Omit, targetOrigin?: string) { + if (window.self === window.top) return; + const target = targetOrigin || (document.referrer || window.location.origin); + if (!isValidOrigin(target)) { + console.warn("[embed] 拒绝发送消息到非白名单 origin:", target); + return; + } + const fullMessage: EmbedMessage = { + proto: "fc", + v: 1, + type: message.type, + ...message + }; + window.parent.postMessage(fullMessage, target); +} + +/** 高度上报(ResizeObserver + 防抖) */ +export function startHeightReporting(container: HTMLElement, debounceMs = 100) { + if (window.self === window.top) return () => {}; + let timer: number | null = null; + let lastHeight = 0; + + const report = () => { + const height = container.scrollHeight; + if (height !== lastHeight) { + lastHeight = height; + postToParent({ type: "height", height }); + } + }; + + const observer = new ResizeObserver(() => { + if (timer) clearTimeout(timer); + timer = window.setTimeout(report, debounceMs); + }); + + observer.observe(container); + report(); + + return () => { + observer.disconnect(); + if (timer) clearTimeout(timer); + }; +} + +/** 提交结果上报 */ +export function reportSubmitResult(result: { success: boolean; answerId?: string; answerNo?: string; error?: string }) { + postToParent({ + type: result.success ? "success" : "error", + ...result + }); +} + +/** 登录引导协议(embed 形态未登录时不白屏) */ +export function reportNeedLogin() { + postToParent({ type: "needLogin" }); +} + +/** 监听来自父窗口的消息(显式 origin 校验) */ +export function listenFromParent(handler: (message: EmbedMessage, origin: string) => void) { + const listener = (event: MessageEvent) => { + if (!isValidOrigin(event.origin)) { + console.warn("[embed] 拒绝来自非白名单 origin 的消息:", event.origin); + return; + } + const data = event.data; + if (!data || data.proto !== "fc" || data.v !== 1) { + return; + } + handler(data, event.origin); + }; + window.addEventListener("message", listener); + return () => window.removeEventListener("message", listener); +} diff --git a/src/utils/hook/useFormCandidates.ts b/src/utils/hook/useFormCandidates.ts new file mode 100644 index 0000000..e3257c8 --- /dev/null +++ b/src/utils/hook/useFormCandidates.ts @@ -0,0 +1,120 @@ +/** + * 数据面页面的"选哪张表单"共用件(数据列表 / 统计页两处同形,快应用上下文第三种候选来源)。 + * + * 两条来路都是合规的,区别只在账号被授权了哪一行模块: + * - 中心上下文:`/api/form/admin/list`(要"表单设计"模块行)→ 一次拿全本校表单清单; + * - 快应用上下文:只认该应用模块树里的 `/f/{code}` 行,逐个 `resolve` 换 uuid + * (定义编辑权不在快应用,方案 §3.4;而 resolve 是读接口的唯一入口,契约 §1)。 + * + * 清单取不到(只授权数据管理、没授权表单设计的账号)时**不假装是空列表**: + * 报错文案留给页面显示,并留一条"按 code 直达"的口子——它同样只经 resolve,不绕过任何守卫。 + */ +import { ref } from "vue"; +import { formApi } from "@/api/module/form"; +import { resolveForm } from "@/api/module/fill"; +import { flattenFields } from "@/utils/dataValue"; +import type { DesignField } from "@/types/formSchema"; +import type { StatusDefView } from "@/types/form"; + +export interface FormCandidate { + formUuid: string; + code: string; + name: string; + appCode: string; +} + +/** 选中表单的当前发布版本信息(列定义与状态定义都吃它) */ +export interface CurrentForm extends FormCandidate { + version: number; + fields: DesignField[]; + statusDefs: StatusDefView[] | null; +} + +export type ContextGetter = () => "center" | "app"; +export type AppCodeGetter = () => string; +export type CodesGetter = () => string[]; + +export function useFormCandidates(context: ContextGetter, appCode: AppCodeGetter, codes: CodesGetter) { + const candidates = ref([]); + const candidateError = ref(""); + const current = ref(null); + const formUuid = ref(""); + + async function loadCurrent(uuid: string): Promise { + const candidate = candidates.value.find((c) => c.formUuid === uuid); + if (!candidate) { + current.value = null; + return; + } + try { + const resolved = await resolveForm(candidate.code, candidate.appCode); + current.value = { + formUuid: resolved.formUuid, + code: resolved.code, + appCode: resolved.appCode, + name: resolved.name, + version: resolved.version, + fields: flattenFields(resolved.groups), + statusDefs: resolved.statusDefs || null + }; + } catch (e: any) { + current.value = null; + candidateError.value = `当前版本解析失败(${candidate.code}):${e?.message || e}`; + } + } + + async function loadCandidates(): Promise { + if (context() === "app") { + const out: FormCandidate[] = []; + for (const code of codes()) { + try { + const resolved = await resolveForm(code, appCode()); + out.push({ formUuid: resolved.formUuid, code: resolved.code, name: resolved.name, appCode: resolved.appCode }); + } catch (e: any) { + console.warn(`[T8] 快应用 ${appCode()} 模块行 code=${code} resolve 失败:${e?.message || e}`); + } + } + candidates.value = out; + candidateError.value = out.length ? "" : "该应用的模块行里没有可解析的已发布表单"; + return; + } + try { + const list = (await formApi.list().send()) || []; + candidates.value = list + .filter((f) => f.currentVersion !== null && f.currentVersion !== undefined) + .map((f) => ({ formUuid: f.formUuid, code: f.code, name: f.name, appCode: f.appCode })); + candidateError.value = ""; + } catch (e: any) { + candidates.value = []; + candidateError.value = e?.message || "表单清单取不到"; + } + } + + async function selectForm(uuid: string): Promise { + formUuid.value = uuid; + await loadCurrent(uuid); + } + + /** 按 code 直达(清单不可用时的口子):成功返回选中的 uuid */ + async function pickByCode(code: string): Promise { + const owner = appCode() || "form-center"; + try { + const resolved = await resolveForm(code, owner); + if (!candidates.value.some((c) => c.formUuid === resolved.formUuid)) { + candidates.value = [...candidates.value, { + formUuid: resolved.formUuid, + code: resolved.code, + name: resolved.name, + appCode: resolved.appCode + }]; + } + candidateError.value = ""; + return resolved.formUuid; + } catch (e: any) { + candidateError.value = `按 code 解析失败:${e?.message || e}`; + return null; + } + } + + return { candidates, candidateError, current, formUuid, loadCandidates, loadCurrent, selectForm, pickByCode }; +} diff --git a/src/utils/modulePerms.ts b/src/utils/modulePerms.ts new file mode 100644 index 0000000..d957a5a --- /dev/null +++ b/src/utils/modulePerms.ts @@ -0,0 +1,88 @@ +/** + * 按钮级/页面级权限的**前端呈现**判定(方案 §6.1 第②层、§6.2 权限锚点、V26 的前端半句)。 + * + * 输入永远是 `GetUserMod` 返回的原始模块树(含 path 空的权限锚点行)—— + * 不是 `utils/menuNav.ts` 过滤后的导航树:导航按规则跳过了 path 空节点, + * 拿过滤后的树来判按钮,锚点行永远不在里面,判据就成了猜。 + * + * 三条纪律: + * 1. **这里只决定"按钮画不画"**,接口侧一律会话自取(契约 §7 身份行)——藏按钮不等于授权; + * 2. **中心与快应用两种上下文的导出载体不同形**(手册 §2.2 中心表没有导出行、方案 §6.2 + * 快应用表的导出行是 path 空锚点),所以按上下文分派,不硬造一个 `fc:export:*` 协议值 + * (契约 §8 一期只定义了 `fc:scope:all` 一个值); + * 3. **快应用上下文拿不到锚点就是不给**(fail-closed),不做"没配就当有"的宽定。 + */ +import type { IModule } from "@/types/Basics"; + +/** 深度优先展平(顺序不承诺,只做集合判定) */ +export function flattenModules(tree: IModule[] | null | undefined): IModule[] { + const out: IModule[] = []; + for (const node of tree ?? []) { + if (!node) continue; + out.push(node); + out.push(...flattenModules(node.children)); + } + return out; +} + +/** 授权页面行 path 集合(path 空的权限锚点不在内)——与后端 FormModuleKeys.paths 同一判据 */ +export function authorizedPaths(tree: IModule[] | null | undefined): string[] { + return flattenModules(tree) + .map((m) => String(m.page ?? "").trim()) + .filter((p) => p !== ""); +} + +/** 权限锚点行(path 为空的节点)——契约 §8"锚点行形态"注记 / P47:只认 path 空节点 */ +export function anchorRows(tree: IModule[] | null | undefined): IModule[] { + return flattenModules(tree).filter((m) => String(m.page ?? "").trim() === ""); +} + +/** 是否被授权了某个页面行(path 精确匹配) */ +export function hasPage(tree: IModule[] | null | undefined, path: string): boolean { + return authorizedPaths(tree).includes(path); +} + +/** 是否存在某个权限锚点行(按行名称匹配——快应用"导出"行的形态就是 path 空 + 名称"导出") */ +export function hasAnchorNamed(tree: IModule[] | null | undefined, name: string): boolean { + const want = name.trim(); + return anchorRows(tree).some((m) => String(m.name ?? "").trim() === want); +} + +export type DataExportContext = "center" | "app"; + +/** + * 导出按钮显隐(V26 前端半句:"无导出授权者前端无按钮";接口拒是 T4 已验的另一半)。 + * + * - `center`:中心管理端。手册 §2.2 的中心模块行表里没有导出行,后端 T4 的导出端点 + * 按 `/admin/data` 页面行把关,所以前端同口径按页面行判——不自造一个中心侧没有的锚点。 + * - `app`:快应用上下文。方案 §6.2 的快应用典型配法里"导出"是 path 空的按钮级锚点行, + * 授权了才在树里出现;拿不到该锚点就不画按钮(fail-closed)。 + */ +export function canExport(tree: IModule[] | null | undefined, context: DataExportContext): boolean { + if (context === "app") return hasAnchorNamed(tree, "导出"); + return hasPage(tree, "/admin/data"); +} + +/** + * 快应用树里的填报目标(模块行 path 形如 `/f/{code}`,方案 §6.2 表第一行)。 + * 数据/统计页要的是表单 uuid,而 uuid 只能由 code 经 resolve 得到(契约 §1"resolve 是读接口的唯一入口")—— + * 快应用上下文不授权定义管理行,拿不到表单列表,这条是唯一的合规取数路径。 + */ +export function fillCodesOfModules(tree: IModule[] | null | undefined): string[] { + const codes: string[] = []; + for (const p of authorizedPaths(tree)) { + const m = /^\/f\/([^/?#]+)/.exec(p); + if (m && !codes.includes(m[1])) codes.push(m[1]); + } + return codes; +} + +/** + * 受限身份可见集的来源(V26 第三半句的前端侧口径): + * 学生身份只见"填报"路由 = 导航树里只有填报行——这是模块授权的直接结果, + * 前端不再自己按角色过滤一遍(任务书 T8:范围以后端为准,前端不自行过滤)。 + * 本函数只做"树里有几项可跳转"的取证辅助,供断言用。 + */ +export function navigableCount(tree: IModule[] | null | undefined): number { + return authorizedPaths(tree).length; +} diff --git a/src/views/AppContext.vue b/src/views/AppContext.vue index 95fff8b..3d05bbf 100644 --- a/src/views/AppContext.vue +++ b/src/views/AppContext.vue @@ -1,39 +1,272 @@ diff --git a/src/views/Fill.vue b/src/views/Fill.vue index 3eaba26..2694c4a 100644 --- a/src/views/Fill.vue +++ b/src/views/Fill.vue @@ -1,30 +1,394 @@ @@ -32,8 +396,131 @@ const embedded = window.self !== window.top; diff --git a/src/views/My.vue b/src/views/My.vue index f4bfad3..86ac391 100644 --- a/src/views/My.vue +++ b/src/views/My.vue @@ -1,7 +1,281 @@ + + diff --git a/src/views/admin/AnswerDetail.vue b/src/views/admin/AnswerDetail.vue new file mode 100644 index 0000000..1cc9754 --- /dev/null +++ b/src/views/admin/AnswerDetail.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/src/views/admin/Data.vue b/src/views/admin/Data.vue index 1ee43fd..e816445 100644 --- a/src/views/admin/Data.vue +++ b/src/views/admin/Data.vue @@ -1,11 +1,775 @@ + + + + diff --git a/src/views/admin/Dicts.vue b/src/views/admin/Dicts.vue index c90f397..d2be795 100644 --- a/src/views/admin/Dicts.vue +++ b/src/views/admin/Dicts.vue @@ -1,11 +1,422 @@ + + diff --git a/src/views/admin/FormDesigner.vue b/src/views/admin/FormDesigner.vue new file mode 100644 index 0000000..2144981 --- /dev/null +++ b/src/views/admin/FormDesigner.vue @@ -0,0 +1,479 @@ + + + + + \ No newline at end of file diff --git a/src/views/admin/Forms.vue b/src/views/admin/Forms.vue index f018906..0b95777 100644 --- a/src/views/admin/Forms.vue +++ b/src/views/admin/Forms.vue @@ -1,11 +1,373 @@ + + diff --git a/src/views/admin/Stats.vue b/src/views/admin/Stats.vue index 38afb2e..d037ba7 100644 --- a/src/views/admin/Stats.vue +++ b/src/views/admin/Stats.vue @@ -1,11 +1,298 @@ + +