P76(T8 验收遗留):快应用上下文的导出按钮此前恒按中心会话树判"导出"锚点 (Data.vue exportable 读 store.modules),锚点判定不生效(fail-closed 方向, 有授权也不画按钮)。本轮把 AppContext 拉到的**目标应用树**经 modules prop 下传, Data.vue 的权限判据改为 permsTree(app=应用树 / center=会话树),canExport 纯函数不动。 Stats.vue 未接:本页无锚点判定也无 store.modules 引用,加 prop 是死代码(差异记 T9 报告)。 P77(T8 验收遗留):应用上下文内的"详情"不再跳回中心路由 /admin/data/detail。 形态二选一取**子路由 /app/:appCode/data/detail**(同一个 AnswerDetail.vue): 地址保留 uuid 可深链/刷新、详情页只有一份实现、back() 本就按 app 回应用数据视图, 写 parentPath 反而造假(menuRoute 的 ROUTE_PATHS 只收静态 path,应用段导航在页内轻导航)。 goDetail 按 props.context 分派;back() 改读 route.params.appCode,撤掉 query.app 双轨。 观察项①(T8 验收记档):确认跨触底页勾选**会被清空**——view-ui-plus 的 data 监听器 用 makeObjBaseData 重建 objData,_isChecked 只认行上的 _checked(点选不回写行), 整组替换后勾选视觉与 getSelection 归零而"已选 N 条"仍累计。新增 withSelectionState() 在 reload/loadMore 两条装载路径按 selected 回填 _checked,selected 为唯一事实源。 演练桩(本地件,dev:mock 生效):baseapi 增 fc-drill 快应用模块树(方案 §6.2 五行含 导入/导出两个 path 空锚点),dataapi 增 /api/form/resolve 桩(快应用数据视图唯一的 合规取数路径是 code→resolve→uuid,契约 §1)。其余外指 appCode 仍旧返回空树(P3 口径)。 门禁:npm run build ✓ / npm run type-check 0 错 ✓; t1~t9 全量复跑:t5 13/0/1、t6 22/0/2、t7 12/0/0、t8 21/0/1、t9 12/0/1 (skip 均为 headless Edge --dump-dom 吐 0 字节的 P73 环境事实,不假通过)。 本提交含 T6~T8 三轮堆在工作区的既有改动(沿 T8 验收 §5 推送分层)。
This commit is contained in:
Generated
+20
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 数据面接口(后端 `FormAnswerReadController` + `FormAnswerAdminController`,
|
||||
* `/api/form/admin/data/**`,CAS 会话 + 数据管理/统计模块行 + 行级数据范围三层一起判)。
|
||||
*
|
||||
* 写法沿 P49:`alovaInstance.Get<T>(url, {params})` / `Post<T>(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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<ListView>(`${BASE}/list`, body, { params: paramsOf({ ...scope }) });
|
||||
}
|
||||
|
||||
/** 详情:uuid 寻址 + 钉住版本的内联展开 schema(V5:URL 与参数里都只有 uuid) */
|
||||
detail(answerId: string, scope: Scope = {}) {
|
||||
return alovaInstance.Get<AnswerDetail>(`${BASE}/detail`, { params: paramsOf({ ...scope, answerId }) });
|
||||
}
|
||||
|
||||
/** 筛选候选(searchable 且有算子;附件类已被后端排除,P28) */
|
||||
filterFields(formUuid: string, scope: Scope = {}) {
|
||||
return alovaInstance.Get<FilterField[]>(`${BASE}/filterFields`, {
|
||||
params: paramsOf({ ...scope, formUuid })
|
||||
});
|
||||
}
|
||||
|
||||
/** 统计(总数/按状态/按字段分组;短 TTL 缓存与范围解析都在后端,前端不缓存不限定 scope) */
|
||||
stats(body: QueryRequest, scope: Scope = {}) {
|
||||
return alovaInstance.Post<StatsView>(`${BASE}/stats`, body, { params: paramsOf({ ...scope }) });
|
||||
}
|
||||
|
||||
/** 编辑值文档:按该答卷钉住的 version 校验(新 schema 字段不会在旧数据上凭空出现) */
|
||||
edit(answerId: string, values: Record<string, unknown>, summary?: string, scope: Scope = {}) {
|
||||
return alovaInstance.Post<void>(`${BASE}/edit`, { answerId, values, summary }, {
|
||||
params: paramsOf({ ...scope })
|
||||
});
|
||||
}
|
||||
|
||||
/** 软删(唯一删除形态,无物理删除接口——V30);uuid 走 query */
|
||||
softDelete(answerId: string, scope: Scope = {}) {
|
||||
return alovaInstance.Post<void>(`${BASE}/softDelete`, {}, { params: paramsOf({ ...scope, answerId }) });
|
||||
}
|
||||
|
||||
/** 改状态(填表人在接口层就没有这条路——V14) */
|
||||
changeStatus(answerId: string, status: string, scope: Scope = {}) {
|
||||
return alovaInstance.Post<void>(`${BASE}/changeStatus`, { answerId, status }, {
|
||||
params: paramsOf({ ...scope })
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量软删:后端逐条校验、逐条留痕、逐条回结果(V39;单次上限 200,P29) */
|
||||
batchSoftDelete(answerIds: string[], scope: Scope = {}) {
|
||||
return alovaInstance.Post<BatchItemResult[]>(`${BASE}/batch/softDelete`, { answerIds }, {
|
||||
params: paramsOf({ ...scope })
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量改状态:同上 */
|
||||
batchChangeStatus(answerIds: string[], status: string, scope: Scope = {}) {
|
||||
return alovaInstance.Post<BatchItemResult[]>(`${BASE}/batch/changeStatus`, { answerIds, status }, {
|
||||
params: paramsOf({ ...scope })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const dataApi = new DataApi();
|
||||
|
||||
/**
|
||||
* 导出(xlsx 流式同步下载,方案 §5.5 铁律④:不设行数上限、不做异步任务与下载中心)。
|
||||
*
|
||||
* <p>为什么是原生 fetch:后端这个端点直接写 OutputStream、不套 JSON 信封
|
||||
* ({@code FormAnswerReadController#export} 注释),而本仓的响应拦截器对每个响应都
|
||||
* `response.json()`——文件流过它必报解析错。这里只复用同样的会话头,绕开信封解包。</p>
|
||||
*
|
||||
* <p>失败态仍是信封 JSON(全局异常处理器兜底),按 content-type 分流回错误文案。</p>
|
||||
*/
|
||||
export async function exportBlob(body: QueryRequest, scope: Scope = {}): Promise<Blob> {
|
||||
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<string, string> = {
|
||||
"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);
|
||||
}
|
||||
@@ -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<DictView[]>(`${BASE}/list`, {
|
||||
params: scope.schId === undefined || scope.schId === null ? undefined : { schId: scope.schId }
|
||||
});
|
||||
}
|
||||
|
||||
/** 设计器可选字典(本校 ∪ 平台级,本校遮蔽,只出未删条目) */
|
||||
selectable(scope: Scope = {}) {
|
||||
return alovaInstance.Get<DictView[]>(`${BASE}/selectable`, {
|
||||
params: scope.schId === undefined || scope.schId === null ? undefined : { schId: scope.schId }
|
||||
});
|
||||
}
|
||||
|
||||
/** 新建/改名:code 建后不可改(它是 schema 的引用键),软删行重新保存=恢复 */
|
||||
save(body: DictSaveRequest) {
|
||||
return alovaInstance.Post<DictView>(`${BASE}/save`, body);
|
||||
}
|
||||
|
||||
/** 字典软删(schema 里已有的引用在发布校验时会因不可解析而被拦) */
|
||||
remove(body: DictSaveRequest) {
|
||||
return alovaInstance.Post<void>(`${BASE}/delete`, body);
|
||||
}
|
||||
|
||||
/** 条目保存:value 定位,能改的只有 label 与 sort;对软删条目重新保存=放回可选集 */
|
||||
saveItem(body: DictItemRequest) {
|
||||
return alovaInstance.Post<DictItemView>(`${BASE}/item/save`, body);
|
||||
}
|
||||
|
||||
/** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */
|
||||
removeItem(body: DictItemRequest) {
|
||||
return alovaInstance.Post<void>(`${BASE}/item/delete`, body);
|
||||
}
|
||||
}
|
||||
|
||||
export const dictApi = new DictApi();
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 填报面 API(用户面,CAS 会话):resolve / submit / my-list / upload。
|
||||
*
|
||||
* 沿 P49 写法:alovaInstance.Get<T>(url, {params}) / Post<T>(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<string, string> = { code };
|
||||
if (appCode) params.appCode = appCode;
|
||||
return alovaInstance.Get<ResolvedForm>("/api/form/resolve", { params }).send();
|
||||
}
|
||||
|
||||
/** 提交答卷(契约 §5) */
|
||||
export function submitAnswer(body: SubmitRequest) {
|
||||
return alovaInstance.Post<SubmitView>("/api/form/submit", body).send();
|
||||
}
|
||||
|
||||
/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
|
||||
export function listMyAnswers(formCode: string, appCode?: string) {
|
||||
const params: Record<string, string> = { formCode };
|
||||
if (appCode) params.appCode = appCode;
|
||||
return alovaInstance.Get<MyAnswerItem[]>("/api/form/my/list", { params }).send();
|
||||
}
|
||||
|
||||
/** 软删我的答卷(确认对话框后调用) */
|
||||
export function deleteMyAnswer(answerId: string) {
|
||||
return alovaInstance.Post<void>("/api/form/my/delete", { answerId }).send();
|
||||
}
|
||||
|
||||
/** 编辑我的答卷(按钉住 version 校验) */
|
||||
export function editMyAnswer(answerId: string, values: Record<string, any>, summary?: string) {
|
||||
return alovaInstance.Post<SubmitView>("/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<MyAnswerViewItem[]>("/api/form/my/submit-list").send();
|
||||
}
|
||||
|
||||
/** 关于我的(subject=会话用户,含管理员代填的那几条——V18 的另一半) */
|
||||
export function listAnswersAboutMe() {
|
||||
return alovaInstance.Get<MyAnswerViewItem[]>("/api/form/my/about-list").send();
|
||||
}
|
||||
|
||||
/** 我的答卷详情(钉住版本的 groups + 值文档,与数据管理面同一个 AnswerDetail 形状) */
|
||||
export function getMyAnswerDetail(answerId: string) {
|
||||
return alovaInstance.Get<AnswerDetail>("/api/form/my/detail", { params: { answerId } }).send();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 定义域管理端接口(后端 `FormAdminController`,/api/form/admin/*,CAS 会话 + 定义管理模块行把关)。
|
||||
*
|
||||
* 写法沿 T5 定稿的 P49:`alovaInstance.Get<T>(url, {params})` / `Post<T>(url, body, {params})`——
|
||||
* 同一个 Method 对象、同一套拦截器与信封解包(Notice 的 `new Method<T>` 在 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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<FormView[]>(`${BASE}/list`, {
|
||||
params: paramsOf({ ...scope, ...filter })
|
||||
});
|
||||
}
|
||||
|
||||
/** 单个表单头表(状态/版本指针/表单级配置/状态定义都在这里,schema 正文要另查版本行) */
|
||||
get(uuid: string, scope: Scope = {}) {
|
||||
return alovaInstance.Get<FormView>(`${BASE}/get`, { params: paramsOf({ ...scope, uuid }) });
|
||||
}
|
||||
|
||||
/** 版本行清单(升版流水账:version/published/changeLog/publishTime) */
|
||||
versions(uuid: string, scope: Scope = {}) {
|
||||
return alovaInstance.Get<VersionView[]>(`${BASE}/versions`, { params: paramsOf({ ...scope, uuid }) });
|
||||
}
|
||||
|
||||
/** 单个版本行的 schema 正文(编辑回显;已发布行内容逐字节冻结,V7) */
|
||||
version(uuid: string, version: number, scope: Scope = {}) {
|
||||
return alovaInstance.Get<VersionView>(`${BASE}/version`, {
|
||||
params: paramsOf({ ...scope, uuid, version })
|
||||
});
|
||||
}
|
||||
|
||||
/** 筛选候选列:只出显式 searchable 且当前发布版本里的字段,附件两类已被后端排除(P28) */
|
||||
filterFields(uuid: string, scope: Scope = {}) {
|
||||
return alovaInstance.Get<string[]>(`${BASE}/filterFields`, { params: paramsOf({ ...scope, uuid }) });
|
||||
}
|
||||
|
||||
/** 新建草稿(建草稿即生成 uuid,跨版本不变;version 从 1 起) */
|
||||
create(body: FormCreateRequest, scope: Scope = {}) {
|
||||
return alovaInstance.Post<FormView>(`${BASE}/create`, body, { params: paramsOf({ ...scope }) });
|
||||
}
|
||||
|
||||
/** 保存:草稿直改;已发布表单=开草稿行,头表 status/current_version 不动(V7、P20) */
|
||||
save(body: FormSaveRequest, scope: Scope = {}) {
|
||||
return alovaInstance.Post<FormView>(`${BASE}/save`, body, { params: paramsOf({ ...scope }) });
|
||||
}
|
||||
|
||||
/** 发布:schema 契约校验 + 字段身份稳定 + 字典可解析,三道都过才把草稿行钉成版本并推指针 */
|
||||
publish(uuid: string, changeLog?: string, scope: Scope = {}) {
|
||||
return alovaInstance.Post<FormView>(`${BASE}/publish`, {}, {
|
||||
params: paramsOf({ ...scope, uuid, changeLog })
|
||||
});
|
||||
}
|
||||
|
||||
/** 停用:只断填报入口,数据可读可导(方案 §5.4) */
|
||||
disable(uuid: string, scope: Scope = {}) {
|
||||
return alovaInstance.Post<FormView>(`${BASE}/disable`, {}, { params: paramsOf({ ...scope, uuid }) });
|
||||
}
|
||||
|
||||
/** 停用恢复:重新发布当前版本,不产生新版本行 */
|
||||
enable(uuid: string, scope: Scope = {}) {
|
||||
return alovaInstance.Post<FormView>(`${BASE}/enable`, {}, { params: paramsOf({ ...scope, uuid }) });
|
||||
}
|
||||
|
||||
/** 软删(唯一删除形态,无物理删除接口) */
|
||||
softDelete(uuid: string, scope: Scope = {}) {
|
||||
return alovaInstance.Post<void>(`${BASE}/softDelete`, {}, { params: paramsOf({ ...scope, uuid }) });
|
||||
}
|
||||
|
||||
/** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝,源表单零改动 */
|
||||
copy(body: FormCopyRequest, scope: Scope = {}) {
|
||||
return alovaInstance.Post<FormView>(`${BASE}/copy`, body, { params: paramsOf({ ...scope }) });
|
||||
}
|
||||
|
||||
/** 表单级配置(不进版本快照,改它不升版):audience/限次/截止/报名号/分组标签 */
|
||||
config(body: FormConfigRequest, scope: Scope = {}) {
|
||||
return alovaInstance.Post<FormView>(`${BASE}/config`, body, { params: paramsOf({ ...scope }) });
|
||||
}
|
||||
|
||||
/** 状态定义(V12):statusDefs 传 null=回默认「待处理/已处理」 */
|
||||
statusDefs(body: StatusDefsRequest, scope: Scope = {}) {
|
||||
return alovaInstance.Post<FormView>(`${BASE}/statusDefs`, body, { params: paramsOf({ ...scope }) });
|
||||
}
|
||||
}
|
||||
|
||||
export const formApi = new FormApi();
|
||||
@@ -91,6 +91,78 @@ const OWN_MODULES: IModule[] = [
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* 第一个快应用的模块树(方案 §6.2 表的典型配法,T9 演练桩)。
|
||||
*
|
||||
* 只在 `appCode=fc-drill` 时下发,用来在本机把 **P76(导出锚点按应用树判)/ P77(应用内详情形态)/
|
||||
* V19(快应用独立模块树渲染左导航)** 跑到可实拍的程度——真会话取证要等 H2/H4(执行手册 §2 人工项)。
|
||||
* 其余外指 appCode 仍旧返回空树:那是 H4 未上线时的既定口径(P3),桩不能替平台把没上线的能力假装成有。
|
||||
*/
|
||||
const DRILL_APP_MODULES: IModule[] = [
|
||||
{
|
||||
id: 9201,
|
||||
modId: 9201,
|
||||
name: "填报",
|
||||
parentId: 0,
|
||||
page: "/f/drill-honour",
|
||||
level: 1,
|
||||
icon: "edit",
|
||||
orderNumber: 1,
|
||||
usable: true,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 9202,
|
||||
modId: 9202,
|
||||
name: "数据查看",
|
||||
parentId: 0,
|
||||
page: "/app/fc-drill/data",
|
||||
level: 1,
|
||||
icon: "category",
|
||||
orderNumber: 2,
|
||||
usable: true,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 9203,
|
||||
modId: 9203,
|
||||
name: "统计",
|
||||
parentId: 0,
|
||||
page: "/app/fc-drill/stats",
|
||||
level: 1,
|
||||
icon: "tongji",
|
||||
orderNumber: 3,
|
||||
usable: true,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
// 1.5 预留位:path 空锚点(方案 §6.2 表第四行)——不进导航
|
||||
id: 9204,
|
||||
modId: 9204,
|
||||
name: "导入",
|
||||
parentId: 0,
|
||||
page: "",
|
||||
level: 1,
|
||||
icon: "",
|
||||
orderNumber: 4,
|
||||
usable: true,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
// 按钮级权限锚点:导出按钮显隐按它判(V26 第一半句,P76 修的就是"判的是哪棵树")
|
||||
id: 9205,
|
||||
modId: 9205,
|
||||
name: "导出",
|
||||
parentId: 0,
|
||||
page: "",
|
||||
level: 1,
|
||||
icon: "",
|
||||
orderNumber: 5,
|
||||
usable: true,
|
||||
children: []
|
||||
}
|
||||
];
|
||||
|
||||
export default [
|
||||
{
|
||||
url: "/api/base/GetUserInfo",
|
||||
@@ -101,15 +173,21 @@ export default [
|
||||
account: "mockadmin",
|
||||
name: "Mock 管理员",
|
||||
profilePicture: "",
|
||||
// type=1 是 UserBase UserTypeEnum.SUPER_ADMIN:字典页的平台级维护上下文按它判
|
||||
// (FormAdminGuard.isSuperAdmin 同一条);mock 里给成超管,两级 CRUD 两条路径都能实拍
|
||||
type: 1,
|
||||
currentRole: { schId: 3260, roleId: 7 },
|
||||
school: { id: 3260, name: "云海附属中学" }
|
||||
})
|
||||
},
|
||||
{
|
||||
url: "/api/base/GetUserMod",
|
||||
method: "get",
|
||||
// 与后端同一条判定:appCode 指向别的应用 → 空树(H4 未上线口径,P3:前端不得回落本应用树)
|
||||
// 与后端同一条判定:appCode 指向别的应用 → 空树(H4 未上线口径,P3:前端不得回落本应用树)。
|
||||
// 唯一例外 fc-drill=T9 快应用演练桩(模块树见 DRILL_APP_MODULES),用于本机实拍 P76/P77/V19
|
||||
response: ({ query }: any) => {
|
||||
const target = String(query?.appCode ?? "").trim();
|
||||
if (target === "fc-drill") return ok(DRILL_APP_MODULES);
|
||||
if (target && target.toLowerCase() !== "form-center") return ok([]);
|
||||
return ok(OWN_MODULES);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 数据管理面 / 统计 / 我的填写三组端点的本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 生效)。
|
||||
*
|
||||
* 定位(沿 P48 的降级取证口径):本地 CAS/UserBase 未起、库里没有 form-center 应用行,
|
||||
* 真会话进不了 `/api/form/admin/data/*`;本文件让数据列表、详情、统计、我的两视图在浏览器里
|
||||
* 可点、可截图取证。**不替代后端权威**——scope/批量/统计的聚合语义由 T4 单测覆盖,
|
||||
* 前端以"对接形状"取证为准(任务书 T8 原文),所以这里的响应形状逐字段对齐
|
||||
* `ReadModels` / `MyModels`,不做任何加工。
|
||||
*
|
||||
* 种子的三个刻意设计(对应 T8 三个断言):
|
||||
* ① **V5**:所有响应里的答卷身份只有 `answerId`(32 位 uuid)、表单身份只有 `formUuid`
|
||||
* (32 位 uuid),**不出现库内自增 id**(submitterId/subjectId 是平台用户 id,不是本库主键);
|
||||
* ② **V8**:seed schema 已删字段 `legacy_class` 的索引行还在、值文档里还在——
|
||||
* 列表列要降级显示"(已移除字段)legacy_class",按历史字段筛选仍要能出结果;
|
||||
* ③ **V18**:两条答卷的 submitter/subject 交叉(管理员代填场景),
|
||||
* 用来断言"我提交的"与"关于我的"两视图分别可见。
|
||||
*
|
||||
* 游标(keyset):`cursor`=上一页末行 answerId(uuid),服务端按它定位后取下一页——
|
||||
* 这里用 uuid 在数组里的下标模拟,形状与后端一致(前端不感知差异)。
|
||||
*/
|
||||
import type { AnswerDetail, AnswerRow, BatchItemResult, FilterField, ListView, MyAnswerViewItem, StatsView } from "@/types/data";
|
||||
|
||||
const ok = (data: any) => ({ code: 0, msg: "", data });
|
||||
const bizErr = (code: number, msg: string) => ({ code, msg, data: null });
|
||||
|
||||
const APP = "form-center";
|
||||
const CODE = "honor_apply";
|
||||
const FORM_UUID = "mock0000000000000000000000000001";
|
||||
/** 当前发布版本(v2);种子答卷钉在 v1 或 v2,用来验"按钉住版本渲染" */
|
||||
const PUBLISHED_VERSION = 2;
|
||||
|
||||
/** v1 钉住版本的 schema(有 legacy_class,没有 nation) */
|
||||
const GROUPS_V1 = [
|
||||
{
|
||||
key: "base",
|
||||
label: "基本信息",
|
||||
fields: [
|
||||
{ key: "name", label: "学生姓名", type: "text", required: true, searchable: true, maxLength: 32 },
|
||||
{ key: "grade", label: "年级", type: "select", required: true, searchable: true, options: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }] },
|
||||
// 已在 v2 被删除的字段:历史值仍在(V8)
|
||||
{ key: "legacy_class", label: "原班级(已删)", type: "text", searchable: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/** v2 当前发布版本的 schema(删了 legacy_class,加了 nation + birthday) */
|
||||
const GROUPS_V2 = [
|
||||
{
|
||||
key: "base",
|
||||
label: "基本信息",
|
||||
fields: [
|
||||
{ key: "name", label: "学生姓名", type: "text", required: true, searchable: true, maxLength: 32 },
|
||||
{ key: "grade", label: "年级", type: "select", required: true, searchable: true, options: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }] },
|
||||
{ key: "nation", label: "民族", type: "select", searchable: true, options: [{ label: "汉族", value: "han" }, { label: "回族", value: "hui" }] },
|
||||
{ key: "birthday", label: "出生日期", type: "date", searchable: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/** 筛选候选:当前发布版本里 searchable 且有算子的字段(历史已删字段不出现在这里——后端同口径) */
|
||||
const FILTER_FIELDS: FilterField[] = [
|
||||
{ key: "name", label: "学生姓名", type: "text", operator: "PREFIX" },
|
||||
{ key: "grade", label: "年级", type: "select", operator: "EQ" },
|
||||
{ key: "nation", label: "民族", type: "select", operator: "EQ" },
|
||||
{ key: "birthday", label: "出生日期", type: "date", operator: "RANGE" }
|
||||
];
|
||||
|
||||
/** 会话用户(与 baseapi.ts 的 GetUserInfo 一致):id=186532 */
|
||||
const SESSION_USER = 186532;
|
||||
/** 被代填的学生(用来验 V18:代填后两视图分别可见) */
|
||||
const STUDENT = 20260001;
|
||||
|
||||
interface MockAnswer {
|
||||
answerId: string;
|
||||
version: number;
|
||||
status: string;
|
||||
submitterId: number;
|
||||
subjectId: number | null;
|
||||
answerNo: string | null;
|
||||
submitTime: string;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const seed: MockAnswer[] = [
|
||||
{
|
||||
// 管理员(会话用户)代填给 STUDENT:在"我提交的"里可见(提交人是我),也在 STUDENT 的"关于我的"里可见
|
||||
answerId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
|
||||
version: PUBLISHED_VERSION,
|
||||
status: "pending",
|
||||
submitterId: SESSION_USER,
|
||||
subjectId: STUDENT,
|
||||
answerNo: "honor_apply-20261014-0001",
|
||||
submitTime: "2026-10-14 09:12:33",
|
||||
values: {
|
||||
name: "张同学",
|
||||
grade: "opt_1",
|
||||
nation: "han",
|
||||
birthday: "2010-03-09",
|
||||
// 历史已删字段的残留值(V8:降级显示,不静默丢弃)
|
||||
legacy_class: "高二(3)班"
|
||||
}
|
||||
},
|
||||
{
|
||||
// 另一位学生自填:不在"我提交的"里,也不在"关于我的"里(用来验两视图的谓词各自成立)
|
||||
answerId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2",
|
||||
version: 1,
|
||||
status: "processed",
|
||||
submitterId: STUDENT,
|
||||
subjectId: null,
|
||||
answerNo: null,
|
||||
submitTime: "2026-10-13 15:40:02",
|
||||
values: { name: "李同学", grade: "opt_2", legacy_class: "高二(5)班" }
|
||||
},
|
||||
{
|
||||
// 又一条本人提交的(无代填主体):验"仅本人提交"范围文案里的行
|
||||
answerId: "ccccccccccccccccccccccccccccccc3",
|
||||
version: PUBLISHED_VERSION,
|
||||
status: "pending",
|
||||
submitterId: SESSION_USER,
|
||||
subjectId: null,
|
||||
answerNo: null,
|
||||
submitTime: "2026-10-12 08:05:11",
|
||||
values: { name: "王同学", grade: "opt_1", nation: "hui" }
|
||||
}
|
||||
];
|
||||
|
||||
function statusLabel(code: string): string | null {
|
||||
if (code === "pending") return "待处理";
|
||||
if (code === "processed") return "已处理";
|
||||
return null; // 已废弃 code
|
||||
}
|
||||
|
||||
function toRow(a: MockAnswer): AnswerRow {
|
||||
return {
|
||||
answerId: a.answerId,
|
||||
formUuid: FORM_UUID,
|
||||
version: a.version,
|
||||
status: a.status,
|
||||
statusLabel: statusLabel(a.status),
|
||||
submitterId: a.submitterId,
|
||||
subjectId: a.subjectId,
|
||||
answerNo: a.answerNo,
|
||||
submitTime: a.submitTime,
|
||||
values: a.values
|
||||
};
|
||||
}
|
||||
|
||||
/** 值匹配(简化:EQ 精确、PREFIX 前缀、RANGE 按日期串比较——只够驱动 UI 取证) */
|
||||
function matchFilter(a: MockAnswer, f: any): boolean {
|
||||
const v = a.values[f.key];
|
||||
if (v === undefined || v === null) return false;
|
||||
const text = String(v);
|
||||
if (f.from || f.to) {
|
||||
if (f.from && text < String(f.from)) return false;
|
||||
if (f.to && text > String(f.to)) return false;
|
||||
return true;
|
||||
}
|
||||
if (f.value === undefined || f.value === null || f.value === "") return true;
|
||||
const spec = FILTER_FIELDS.find((x) => x.key === f.key);
|
||||
if (spec?.operator === "PREFIX") return text.startsWith(String(f.value));
|
||||
return text === String(f.value);
|
||||
}
|
||||
|
||||
function filtered(body: any): MockAnswer[] {
|
||||
const inScope = (a: MockAnswer) => a; // mock 不做范围(后端做,T4 已验)
|
||||
const list = seed.map(inScope)
|
||||
.filter((a) => !body?.status || a.status === body.status)
|
||||
.filter((a) => !Array.isArray(body?.filters) || body.filters.every((f: any) => matchFilter(a, f)));
|
||||
return list.sort((a, b) => (body?.order === "asc" ? (a.submitTime < b.submitTime ? -1 : 1) : a.submitTime > b.submitTime ? -1 : 1));
|
||||
}
|
||||
|
||||
function bodyOf(raw: any): any {
|
||||
return typeof raw?.body === "string" ? JSON.parse(raw.body || "{}") : raw?.body || {};
|
||||
}
|
||||
|
||||
function queryValue(query: any, key: string): string {
|
||||
const v = query?.[key];
|
||||
return Array.isArray(v) ? String(v[0] ?? "") : String(v ?? "");
|
||||
}
|
||||
|
||||
function detailOf(answerId: string): AnswerDetail | null {
|
||||
const a = seed.find((x) => x.answerId === answerId);
|
||||
if (!a) return null;
|
||||
return {
|
||||
...toRow(a),
|
||||
appCode: APP,
|
||||
code: CODE,
|
||||
formName: "三好学生申报",
|
||||
// 钉住版本内联展开的 groups:v1 的点拿到 v1 的 schema(新字段不在旧数据上凭空出现)
|
||||
groups: a.version === 1 ? GROUPS_V1 : GROUPS_V2
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
// ==================== 读接口唯一入口 resolve(契约 §1) ====================
|
||||
// T9 快应用演练桩:中心上下文与快应用上下文都靠它把模块行里的 code 换成 uuid
|
||||
// (useFormCandidates 的 app 分支)。桩只有一份表单数据,所以**任何 code 都解析到同一张表**,
|
||||
// 回显请求里的 code/appCode——这是本机 UI 取证用,不替代后端 resolve 的任何判据。
|
||||
{
|
||||
url: "/api/form/resolve",
|
||||
method: "get",
|
||||
response: ({ query }: any) =>
|
||||
ok({
|
||||
formUuid: FORM_UUID,
|
||||
code: queryValue(query, "code") || CODE,
|
||||
appCode: queryValue(query, "appCode") || APP,
|
||||
name: "三好学生申报",
|
||||
version: 2,
|
||||
audience: 1,
|
||||
fillLimit: 0,
|
||||
answerNoEnabled: 0,
|
||||
deadline: null,
|
||||
deadlineReached: false,
|
||||
statusDefs: [
|
||||
{ code: "pending", label: "待处理", is_deleted: false },
|
||||
{ code: "processed", label: "已处理", is_deleted: false }
|
||||
],
|
||||
groups: GROUPS_V2
|
||||
})
|
||||
},
|
||||
// ==================== 数据面:列表 / 详情 / 筛选候选 / 统计 ====================
|
||||
{ url: "/api/form/admin/data/list", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const all = filtered(body);
|
||||
const size = Number(body?.size ?? 20);
|
||||
const cursor = body?.cursor ? String(body.cursor) : "";
|
||||
const start = cursor ? all.findIndex((a) => a.answerId === cursor) + 1 : 0;
|
||||
const page = all.slice(start, start + size);
|
||||
const hasMore = start + size < all.length;
|
||||
const view: ListView = {
|
||||
rows: page.map(toRow),
|
||||
nextCursor: hasMore ? page[page.length - 1].answerId : null,
|
||||
size,
|
||||
// 范围枚举名(前端只展示,不过滤)
|
||||
scopes: ["ALL"]
|
||||
};
|
||||
return ok(view);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/detail", method: "get", response: ({ query }: any) => {
|
||||
const d = detailOf(queryValue(query, "answerId"));
|
||||
return d ? ok(d) : bizErr(3, "答卷不在你的数据范围内");
|
||||
} },
|
||||
{ url: "/api/form/admin/data/filterFields", method: "get", response: () => ok(FILTER_FIELDS) },
|
||||
{ url: "/api/form/admin/data/stats", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const all = filtered(body);
|
||||
const byStatusMap = new Map<string, number>();
|
||||
all.forEach((a) => byStatusMap.set(a.status, (byStatusMap.get(a.status) || 0) + 1));
|
||||
const byStatus = [...byStatusMap.entries()].map(([value, count]) => ({ value, label: statusLabel(value), count }));
|
||||
const byField = [];
|
||||
if (body?.groupByField) {
|
||||
const key = String(body.groupByField);
|
||||
const map = new Map<string, number>();
|
||||
all.forEach((a) => {
|
||||
const v = a.values[key];
|
||||
if (v !== undefined && v !== null && String(v) !== "") map.set(String(v), (map.get(String(v)) || 0) + 1);
|
||||
});
|
||||
const spec = FILTER_FIELDS.find((f) => f.key === key);
|
||||
const options = ((({ grade: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }], nation: [{ label: "汉族", value: "han" }, { label: "回族", value: "hui" }] }) as any)[key] || []) as Array<{ label: string; value: string }>;
|
||||
byField.push({
|
||||
key,
|
||||
label: spec?.label || key,
|
||||
items: [...map.entries()].map(([value, count]) => ({
|
||||
value,
|
||||
label: options.find((o) => o.value === value)?.label ?? null,
|
||||
count
|
||||
}))
|
||||
});
|
||||
}
|
||||
const view: StatsView = { total: all.length, byStatus, byField, cached: false };
|
||||
return ok(view);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/edit", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const a = seed.find((x) => x.answerId === body.answerId);
|
||||
if (!a) return bizErr(3, "答卷不在你的数据范围内");
|
||||
// 按钉住版本拒绝未知 key(后端 rejectUnknownKeys 的同口径)
|
||||
const fields = (a.version === 1 ? GROUPS_V1 : GROUPS_V2)[0].fields.map((f: any) => f.key);
|
||||
for (const k of Object.keys(body.values || {})) {
|
||||
if (!fields.includes(k)) return bizErr(2, `未知字段 key:${k}(按钉住版本 v${a.version} 校验)`);
|
||||
}
|
||||
a.values = { ...a.values, ...body.values };
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/softDelete", method: "post", response: ({ query }: any) => {
|
||||
const id = queryValue(query, "answerId");
|
||||
const idx = seed.findIndex((x) => x.answerId === id);
|
||||
if (idx < 0) return bizErr(3, "答卷不在你的数据范围内");
|
||||
seed.splice(idx, 1);
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/changeStatus", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const a = seed.find((x) => x.answerId === body.answerId);
|
||||
if (!a) return bizErr(3, "答卷不在你的数据范围内");
|
||||
a.status = String(body.status);
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/batch/softDelete", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const results: BatchItemResult[] = (body.answerIds || []).map((id: string) => {
|
||||
const idx = seed.findIndex((x) => x.answerId === id);
|
||||
if (idx < 0) return { answerId: id, success: false, msg: "不在你的数据范围内" };
|
||||
seed.splice(idx, 1);
|
||||
return { answerId: id, success: true, msg: null };
|
||||
});
|
||||
return ok(results);
|
||||
} },
|
||||
{ url: "/api/form/admin/data/batch/changeStatus", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const results: BatchItemResult[] = (body.answerIds || []).map((id: string) => {
|
||||
const a = seed.find((x) => x.answerId === id);
|
||||
if (!a) return { answerId: id, success: false, msg: "不在你的数据范围内" };
|
||||
a.status = String(body.status);
|
||||
return { answerId: id, success: true, msg: null };
|
||||
});
|
||||
return ok(results);
|
||||
} },
|
||||
|
||||
// ==================== 我的填写两视图(V18) ====================
|
||||
{ url: "/api/form/my/submit-list", method: "get", response: () => {
|
||||
const items: MyAnswerViewItem[] = seed
|
||||
.filter((a) => a.submitterId === SESSION_USER)
|
||||
.map((a) => ({
|
||||
answerId: a.answerId,
|
||||
formUuid: FORM_UUID,
|
||||
appCode: APP,
|
||||
code: CODE,
|
||||
formName: "三好学生申报",
|
||||
version: a.version,
|
||||
status: a.status,
|
||||
statusLabel: statusLabel(a.status),
|
||||
submitterId: a.submitterId,
|
||||
subjectId: a.subjectId,
|
||||
answerNo: a.answerNo,
|
||||
submitTime: a.submitTime
|
||||
}));
|
||||
return ok(items);
|
||||
} },
|
||||
{ url: "/api/form/my/about-list", method: "get", response: () => {
|
||||
// 会话用户是被代填人(subject)的那几条:mock 里把 SESSION_USER 当成被代填主体来驱动页面
|
||||
const items: MyAnswerViewItem[] = seed
|
||||
.filter((a) => a.subjectId === STUDENT || a.subjectId === SESSION_USER)
|
||||
.map((a) => ({
|
||||
answerId: a.answerId,
|
||||
formUuid: FORM_UUID,
|
||||
appCode: APP,
|
||||
code: CODE,
|
||||
formName: "三好学生申报",
|
||||
version: a.version,
|
||||
status: a.status,
|
||||
statusLabel: statusLabel(a.status),
|
||||
submitterId: a.submitterId,
|
||||
subjectId: a.subjectId,
|
||||
answerNo: a.answerNo,
|
||||
submitTime: a.submitTime
|
||||
}));
|
||||
return ok(items);
|
||||
} },
|
||||
{ url: "/api/form/my/detail", method: "get", response: ({ query }: any) => {
|
||||
const d = detailOf(queryValue(query, "answerId"));
|
||||
return d ? ok(d) : bizErr(3, "答卷不存在或不属于你");
|
||||
} },
|
||||
{ url: "/api/form/my/delete", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const idx = seed.findIndex((x) => x.answerId === body.answerId && x.submitterId === SESSION_USER);
|
||||
if (idx < 0) return bizErr(3, "只能删除自己提交的答卷");
|
||||
seed.splice(idx, 1);
|
||||
return ok(null);
|
||||
} }
|
||||
];
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* 定义域与字典的本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 即 --mode mock 生效)。
|
||||
*
|
||||
* 定位(沿 P48 的降级取证口径):本地 CAS/UserBase 未起、库里没有 form-center 应用行,
|
||||
* 真会话进不了 `/api/form/admin/*`;本文件让设计器与字典页在浏览器里可点、可截图取证,
|
||||
* 并**不**替代后端权威——schema 契约的判定在 `utils/designer/schema.ts` 与后端
|
||||
* `FormSchemaValidator` 两侧同源,t6 冒烟脚本拿真后端校验器跑导出的 schema 复核。
|
||||
*
|
||||
* 状态是进程内内存:一次 dev:mock 生命周期内 create→save→publish→copy 的推进可复现,
|
||||
* 重启即回种子数据。种子刻意做成「已发布 v1 + 待发布草稿 v2」,用来验 P20 的两根指针区分。
|
||||
*/
|
||||
import type {
|
||||
DictItemView,
|
||||
DictSaveRequest,
|
||||
DictView,
|
||||
FormConfigRequest,
|
||||
FormCopyRequest,
|
||||
FormCreateRequest,
|
||||
FormSaveRequest,
|
||||
FormView,
|
||||
StatusDefsRequest,
|
||||
VersionView
|
||||
} from "@/types/form";
|
||||
import { AUDIENCE, FORM_STATUS } from "@/types/form";
|
||||
|
||||
const ok = (data: any) => ({ code: 0, msg: "", data });
|
||||
const bizErr = (code: number, msg: string) => ({ code, msg, data: null });
|
||||
|
||||
const SCHOOL = 3260;
|
||||
const PLATFORM = 0;
|
||||
const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const KEY_RE = /^[A-Za-z0-9_]{1,64}$/;
|
||||
/** 后端 updateStatusDefs 传 null 时回落的默认集合(FormStatusDefs.DEFAULT_DEFS_JSON 同形态) */
|
||||
const DEFAULT_STATUS_DEFS = JSON.parse(
|
||||
'[{"code":"pending","label":"待处理","is_deleted":false},{"code":"processed","label":"已处理","is_deleted":false}]'
|
||||
);
|
||||
|
||||
interface MockForm extends FormView {
|
||||
/** 版本行:version → schema 原文(已发布行不再改写,V7) */
|
||||
rows: { version: number; schemaJson: string; changeLog: string | null; publishTime: string | null }[];
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
interface MockDict extends DictView {
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
let uuidSeq = 0;
|
||||
function newUuid(): string {
|
||||
uuidSeq += 1;
|
||||
const base = Date.now().toString(16) + uuidSeq.toString(16).padStart(4, "0");
|
||||
return (base + "0".repeat(32)).slice(0, 32).replace(/[^0-9a-f]/g, "0");
|
||||
}
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
const SEED_SCHEMA_V1 = JSON.stringify({
|
||||
groups: [
|
||||
{
|
||||
key: "base",
|
||||
label: "基本信息",
|
||||
fields: [
|
||||
{ key: "name", label: "学生姓名", type: "text", required: true, maxLength: 32 },
|
||||
{ key: "grade", label: "年级", type: "select", required: true, searchable: true, options: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
const SEED_SCHEMA_V2 = JSON.stringify({
|
||||
groups: [
|
||||
{
|
||||
key: "base",
|
||||
label: "基本信息",
|
||||
fields: [
|
||||
{ key: "name", label: "学生姓名", type: "text", required: true, maxLength: 32 },
|
||||
{ key: "grade", label: "年级", type: "select", required: true, searchable: true, options: [{ label: "高二年级", value: "opt_1" }, { label: "高三年级", value: "opt_2" }] },
|
||||
// v2 草稿新增:字典引用活字段 + 可筛日期(P28 之外才可勾)
|
||||
{ key: "nation", label: "民族", type: "select", options: { source: "dict:nation" } },
|
||||
{ key: "birthday", label: "出生日期", type: "date", searchable: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const forms: MockForm[] = [
|
||||
{
|
||||
formUuid: "mock0000000000000000000000000001",
|
||||
schId: SCHOOL,
|
||||
appCode: "form-center",
|
||||
code: "honor_apply",
|
||||
name: "三好学生申报",
|
||||
status: FORM_STATUS.PUBLISHED,
|
||||
statusName: "PUBLISHED",
|
||||
currentVersion: 1,
|
||||
draftVersion: 2,
|
||||
audience: AUDIENCE.STUDENT,
|
||||
fillLimit: 1,
|
||||
deadline: null,
|
||||
answerNoEnabled: 0,
|
||||
groupTag: null,
|
||||
statusDefs: DEFAULT_STATUS_DEFS,
|
||||
createTime: now(),
|
||||
updateTime: now(),
|
||||
rows: [
|
||||
{ version: 1, schemaJson: SEED_SCHEMA_V1, changeLog: "首版发布", publishTime: now() },
|
||||
{ version: 2, schemaJson: SEED_SCHEMA_V2, changeLog: "加民族/出生日期", publishTime: null }
|
||||
],
|
||||
deleted: false
|
||||
}
|
||||
];
|
||||
|
||||
const dicts: MockDict[] = [
|
||||
{
|
||||
schId: PLATFORM,
|
||||
platformLevel: true,
|
||||
code: "nation",
|
||||
name: "民族",
|
||||
items: [
|
||||
{ value: "han", label: "汉族", sort: 1, deleted: false },
|
||||
{ value: "hui", label: "回族", sort: 2, deleted: false },
|
||||
{ value: "man", label: "满族", sort: 3, deleted: false },
|
||||
// 软删条目:设计器不可新选,旧答卷的该 value 提交仍放行
|
||||
{ value: "other_old", label: "其它(旧)", sort: 4, deleted: true }
|
||||
],
|
||||
deleted: false
|
||||
},
|
||||
{
|
||||
schId: PLATFORM,
|
||||
platformLevel: true,
|
||||
code: "gender",
|
||||
name: "性别",
|
||||
items: [
|
||||
{ value: "male", label: "男", sort: 1, deleted: false },
|
||||
{ value: "female", label: "女", sort: 2, deleted: false }
|
||||
],
|
||||
deleted: false
|
||||
},
|
||||
{
|
||||
schId: SCHOOL,
|
||||
platformLevel: false,
|
||||
code: "subject",
|
||||
name: "获奖学科(本校)",
|
||||
items: [
|
||||
{ value: "math", label: "数学", sort: 1, deleted: false },
|
||||
{ value: "phy", label: "物理", sort: 2, deleted: false }
|
||||
],
|
||||
deleted: false
|
||||
}
|
||||
];
|
||||
|
||||
function view(f: MockForm): FormView {
|
||||
return {
|
||||
formUuid: f.formUuid,
|
||||
schId: f.schId,
|
||||
appCode: f.appCode,
|
||||
code: f.code,
|
||||
name: f.name,
|
||||
status: f.status,
|
||||
statusName: f.statusName,
|
||||
currentVersion: f.currentVersion,
|
||||
draftVersion: f.draftVersion,
|
||||
audience: f.audience,
|
||||
fillLimit: f.fillLimit,
|
||||
deadline: f.deadline,
|
||||
answerNoEnabled: f.answerNoEnabled,
|
||||
groupTag: f.groupTag,
|
||||
statusDefs: f.statusDefs ? JSON.parse(JSON.stringify(f.statusDefs)) : null,
|
||||
createTime: f.createTime,
|
||||
updateTime: f.updateTime
|
||||
};
|
||||
}
|
||||
|
||||
function draftOf(f: MockForm) {
|
||||
return f.rows.filter((r) => !r.publishTime).map((r) => r.version).sort((a, b) => b - a)[0] ?? null;
|
||||
}
|
||||
|
||||
function syncPointers(f: MockForm) {
|
||||
f.draftVersion = draftOf(f);
|
||||
f.updateTime = now();
|
||||
}
|
||||
|
||||
function findForm(uuid: string): MockForm | undefined {
|
||||
return forms.find((x) => x.formUuid === uuid && !x.deleted);
|
||||
}
|
||||
|
||||
/** 草稿行直改;没有草稿行就开 max+1 新行,头表指针不动(后端 save 的三条分支) */
|
||||
function writeDraft(f: MockForm, schemaJson: string, changeLog?: string) {
|
||||
const draft = f.rows.find((r) => !r.publishTime && r.version === draftOf(f));
|
||||
if (draft) {
|
||||
draft.schemaJson = schemaJson;
|
||||
if (changeLog !== undefined) draft.changeLog = changeLog;
|
||||
} else {
|
||||
const version = Math.max(...f.rows.map((r) => r.version)) + 1;
|
||||
f.rows.push({ version, schemaJson, changeLog: changeLog || null, publishTime: null });
|
||||
}
|
||||
syncPointers(f);
|
||||
}
|
||||
|
||||
function schemaFields(schemaJson: string): any[] {
|
||||
try {
|
||||
const parsed = JSON.parse(schemaJson || "{}");
|
||||
return (parsed.groups || []).flatMap((g: any) => g.fields || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 字典两级解析:同 code 本校遮蔽平台级(契约 §1 校域不变式的字典侧) */
|
||||
function resolveDict(code: string, schId = SCHOOL): MockDict | undefined {
|
||||
return dicts.find((d) => d.code === code && d.schId === schId && !d.deleted)
|
||||
|| dicts.find((d) => d.code === code && d.schId === PLATFORM && !d.deleted);
|
||||
}
|
||||
|
||||
/** 发布前的字典可解析性(后端 assertRefsResolvable:字典缺失或无未删条目即拒) */
|
||||
function assertDictRefs(schemaJson: string): string | null {
|
||||
for (const f of schemaFields(schemaJson)) {
|
||||
const source = f.options && !Array.isArray(f.options) ? String(f.options.source || "") : "";
|
||||
if (!source.startsWith("dict:")) continue;
|
||||
const dict = resolveDict(source.slice(5));
|
||||
if (!dict) return `字典引用无法解析:${source}`;
|
||||
if (!dict.items.some((i) => !i.deleted)) return `字典 ${dict.code} 没有可用条目,不能发布`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 跨版本字段身份:同 key 不得换 type(后端 assertFieldTypesStable) */
|
||||
function assertTypeStable(f: MockForm, schemaJson: string): string | null {
|
||||
if (f.currentVersion === null) return null;
|
||||
const published = f.rows.find((r) => r.version === f.currentVersion);
|
||||
if (!published) return null;
|
||||
const oldTypes = new Map(schemaFields(published.schemaJson).map((x) => [x.key, x.type]));
|
||||
for (const field of schemaFields(schemaJson)) {
|
||||
const old = oldTypes.get(field.key);
|
||||
if (old && old !== field.type) return `字段「${field.key}」key 已发布过,type 不可变更(${old}→${field.type})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queryValue(query: any, key: string): string {
|
||||
const v = query?.[key];
|
||||
return Array.isArray(v) ? String(v[0] ?? "") : String(v ?? "");
|
||||
}
|
||||
|
||||
function bodyOf(raw: any): any {
|
||||
return typeof raw?.body === "string" ? JSON.parse(raw.body || "{}") : raw?.body || {};
|
||||
}
|
||||
|
||||
/** GET 端点的目标域:不带 schId=会话校(mock 里固定 3260) */
|
||||
function targetSchId(query: any): number {
|
||||
const s = queryValue(query, "schId");
|
||||
return s === "" ? SCHOOL : Number(s);
|
||||
}
|
||||
|
||||
/** 字典端点的目标域在**请求体**里(后端 @RequestBody DictSaveRequest/DictItemRequest 带 schId) */
|
||||
function dictForm(raw: any): number {
|
||||
const body = bodyOf(raw);
|
||||
return body?.schId === undefined || body?.schId === null ? SCHOOL : Number(body.schId);
|
||||
}
|
||||
|
||||
export default [
|
||||
{ url: "/api/form/admin/list", method: "get", response: ({ query }: any) => {
|
||||
const schId = queryValue(query, "schId") === "" ? SCHOOL : Number(queryValue(query, "schId"));
|
||||
const appCode = queryValue(query, "appCode");
|
||||
const status = queryValue(query, "status");
|
||||
const list = forms
|
||||
.filter((f) => !f.deleted && f.schId === schId)
|
||||
.filter((f) => !appCode || f.appCode === appCode)
|
||||
.filter((f) => status === "" || f.status === Number(status))
|
||||
.map(view);
|
||||
return ok(list);
|
||||
} },
|
||||
{ url: "/api/form/admin/get", method: "get", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
return f ? ok(view(f)) : bizErr(2, "表单未找到");
|
||||
} },
|
||||
{ url: "/api/form/admin/versions", method: "get", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
const rows: VersionView[] = f.rows.map((r) => ({
|
||||
formUuid: f.formUuid,
|
||||
version: r.version,
|
||||
published: !!r.publishTime,
|
||||
changeLog: r.changeLog,
|
||||
publishTime: r.publishTime,
|
||||
createTime: now(),
|
||||
updateTime: now()
|
||||
}));
|
||||
return ok(rows);
|
||||
} },
|
||||
{ url: "/api/form/admin/version", method: "get", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
const row = f?.rows.find((r) => r.version === Number(queryValue(query, "version")));
|
||||
if (!f || !row) return bizErr(2, "版本不存在");
|
||||
return ok({
|
||||
formUuid: f.formUuid,
|
||||
version: row.version,
|
||||
published: !!row.publishTime,
|
||||
changeLog: row.changeLog,
|
||||
publishTime: row.publishTime,
|
||||
createTime: now(),
|
||||
updateTime: now(),
|
||||
schemaJson: row.schemaJson
|
||||
} as VersionView);
|
||||
} },
|
||||
{ url: "/api/form/admin/filterFields", method: "get", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f || f.currentVersion === null) return ok([]);
|
||||
const row = f.rows.find((r) => r.version === f.currentVersion);
|
||||
const keys = schemaFields(row?.schemaJson || "")
|
||||
.filter((x: any) => x.searchable === true && x.type !== "upload" && x.type !== "image")
|
||||
.map((x: any) => String(x.key));
|
||||
return ok(keys);
|
||||
} },
|
||||
{ url: "/api/form/admin/create", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as FormCreateRequest;
|
||||
if (!CODE_RE.test(String(body.code || "").trim())) return bizErr(2, `表单 code 非法:${body.code}`);
|
||||
if (!String(body.name || "").trim()) return bizErr(2, "表单名称不能为空");
|
||||
const code = String(body.code).trim();
|
||||
if (forms.some((f) => !f.deleted && f.schId === SCHOOL && f.appCode === (body.appCode || "form-center") && f.code === code)) {
|
||||
return bizErr(2, `同应用下 code 已存在:${code}`);
|
||||
}
|
||||
const schemaJson = body.schemaJson && body.schemaJson.trim() ? body.schemaJson : '{"groups":[]}';
|
||||
const form: MockForm = {
|
||||
formUuid: newUuid(),
|
||||
schId: SCHOOL,
|
||||
appCode: body.appCode || "form-center",
|
||||
code,
|
||||
name: String(body.name).trim(),
|
||||
status: FORM_STATUS.DRAFT,
|
||||
statusName: "DRAFT",
|
||||
currentVersion: null,
|
||||
draftVersion: 1,
|
||||
audience: AUDIENCE.STUDENT,
|
||||
fillLimit: 0,
|
||||
deadline: null,
|
||||
answerNoEnabled: 0,
|
||||
groupTag: null,
|
||||
statusDefs: DEFAULT_STATUS_DEFS,
|
||||
createTime: now(),
|
||||
updateTime: now(),
|
||||
rows: [{ version: 1, schemaJson, changeLog: null, publishTime: null }],
|
||||
deleted: false
|
||||
};
|
||||
forms.push(form);
|
||||
return ok(view(form));
|
||||
} },
|
||||
{ url: "/api/form/admin/save", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as FormSaveRequest;
|
||||
const f = findForm(body.uuid);
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (body.name && String(body.name).trim()) f.name = String(body.name).trim();
|
||||
if (body.code && body.code.trim() !== f.code) {
|
||||
if (f.currentVersion !== null) return bizErr(5, "code 发布后不可改(改 code=换表单,契约 §1)");
|
||||
if (!CODE_RE.test(body.code.trim())) return bizErr(2, `表单 code 非法:${body.code}`);
|
||||
f.code = body.code.trim();
|
||||
}
|
||||
if (body.schemaJson && body.schemaJson.trim()) {
|
||||
try {
|
||||
JSON.parse(body.schemaJson);
|
||||
} catch {
|
||||
return bizErr(2, "表单结构不是合法 JSON");
|
||||
}
|
||||
const dictErr = assertDictRefs(body.schemaJson);
|
||||
if (dictErr) return bizErr(2, dictErr);
|
||||
const typeErr = assertTypeStable(f, body.schemaJson);
|
||||
if (typeErr) return bizErr(2, typeErr);
|
||||
writeDraft(f, body.schemaJson, body.changeLog);
|
||||
}
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/publish", method: "post", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
const draft = f.rows.find((r) => !r.publishTime && r.version === draftOf(f));
|
||||
if (!draft) return bizErr(5, "没有待发布的草稿版本;已发布表单请先到设计器保存升版");
|
||||
const dictErr = assertDictRefs(draft.schemaJson);
|
||||
if (dictErr) return bizErr(2, dictErr);
|
||||
const typeErr = assertTypeStable(f, draft.schemaJson);
|
||||
if (typeErr) return bizErr(2, typeErr);
|
||||
const changeLog = queryValue(query, "changeLog");
|
||||
draft.publishTime = now();
|
||||
if (changeLog) draft.changeLog = changeLog;
|
||||
f.currentVersion = draft.version;
|
||||
f.status = FORM_STATUS.PUBLISHED;
|
||||
f.statusName = "PUBLISHED";
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/disable", method: "post", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (f.status !== FORM_STATUS.PUBLISHED) return bizErr(5, "仅已发布的表单可停用");
|
||||
f.status = FORM_STATUS.DISABLED;
|
||||
f.statusName = "DISABLED";
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/enable", method: "post", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (f.status !== FORM_STATUS.DISABLED) return bizErr(5, "仅停用的表单可恢复;草稿请走发布");
|
||||
f.status = FORM_STATUS.PUBLISHED;
|
||||
f.statusName = "PUBLISHED";
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/softDelete", method: "post", response: ({ query }: any) => {
|
||||
const f = findForm(queryValue(query, "uuid"));
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
f.deleted = true;
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/copy", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as FormCopyRequest;
|
||||
const source = findForm(body.uuid);
|
||||
if (!source) return bizErr(2, "源表单未找到");
|
||||
if (!CODE_RE.test(String(body.code || "").trim())) return bizErr(2, `副本 code 非法:${body.code}`);
|
||||
if (forms.some((f) => !f.deleted && f.code === body.code.trim())) return bizErr(2, "同应用下 code 已存在");
|
||||
// 副本取源表单最新版本行(后端 copySourceVersion 同口径:有草稿取草稿,否则取当前发布版)
|
||||
const srcRow = source.rows.find((r) => !r.publishTime && r.version === draftOf(source))
|
||||
|| source.rows.find((r) => r.version === source.currentVersion)
|
||||
|| source.rows[source.rows.length - 1];
|
||||
const copy: MockForm = {
|
||||
...JSON.parse(JSON.stringify(source)),
|
||||
formUuid: newUuid(),
|
||||
code: body.code.trim(),
|
||||
name: String(body.name || source.name).trim(),
|
||||
status: FORM_STATUS.DRAFT,
|
||||
statusName: "DRAFT",
|
||||
currentVersion: null,
|
||||
draftVersion: 1,
|
||||
deadline: null,
|
||||
rows: [{ version: 1, schemaJson: srcRow.schemaJson, changeLog: `另存为副本,源表单 ${source.appCode}/${source.code} v${srcRow.version}`, publishTime: null }],
|
||||
deleted: false
|
||||
};
|
||||
forms.push(copy);
|
||||
return ok(view(copy));
|
||||
} },
|
||||
{ url: "/api/form/admin/config", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as FormConfigRequest;
|
||||
const f = findForm(body.uuid);
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (body.audience !== undefined) {
|
||||
if (body.audience !== AUDIENCE.STUDENT && body.audience !== AUDIENCE.TEACHER) return bizErr(2, "audience 只能是 1 或 2");
|
||||
f.audience = body.audience;
|
||||
}
|
||||
if (body.fillLimit !== undefined) {
|
||||
if (body.fillLimit !== 0 && body.fillLimit !== 1) return bizErr(2, "fillLimit 只能是 0 或 1");
|
||||
f.fillLimit = body.fillLimit;
|
||||
}
|
||||
if (body.answerNoEnabled !== undefined) {
|
||||
if (body.answerNoEnabled !== 0 && body.answerNoEnabled !== 1) return bizErr(2, "answerNoEnabled 只能是 0 或 1");
|
||||
if (body.answerNoEnabled === 1 && f.code.length > 49) return bizErr(2, "开启报名号要求 code ≤49 位");
|
||||
f.answerNoEnabled = body.answerNoEnabled;
|
||||
}
|
||||
if (body.deadline !== undefined) f.deadline = body.deadline ? String(body.deadline) : null;
|
||||
if (body.groupTag !== undefined) f.groupTag = body.groupTag ? String(body.groupTag) : null;
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
{ url: "/api/form/admin/statusDefs", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as StatusDefsRequest;
|
||||
const f = findForm(body.uuid);
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (body.statusDefs === null || body.statusDefs === undefined) {
|
||||
f.statusDefs = JSON.parse(JSON.stringify(DEFAULT_STATUS_DEFS));
|
||||
return ok(view(f));
|
||||
}
|
||||
if (!Array.isArray(body.statusDefs) || body.statusDefs.length === 0) return bizErr(2, "状态定义必须是非空 JSON 数组");
|
||||
const codes = new Set<string>();
|
||||
for (const def of body.statusDefs) {
|
||||
const code = String(def?.code || "");
|
||||
// 后端 FormStatusDefs.normalize:code 非空、≤64、唯一(含软删)、label 非空 ≤128、至少留一个未废弃
|
||||
if (!code.trim() || code.length > 64) return bizErr(2, `状态 code 非法(非空且 ≤64):${code}`);
|
||||
if (codes.has(code)) return bizErr(2, `状态 code 重复:${code}`);
|
||||
codes.add(code);
|
||||
const label = String(def?.label || "");
|
||||
if (!label.trim() || label.length > 128) return bizErr(2, `状态 label 非法(非空且 ≤128):${code}`);
|
||||
}
|
||||
if (!body.statusDefs.some((d) => !d.is_deleted)) return bizErr(2, "至少要保留一个可用状态");
|
||||
f.statusDefs = body.statusDefs.map((d) => ({ code: d.code, label: d.label, is_deleted: !!d.is_deleted }));
|
||||
syncPointers(f);
|
||||
return ok(view(f));
|
||||
} },
|
||||
|
||||
// ==================== 选项字典 ====================
|
||||
{ url: "/api/form/admin/dict/list", method: "get", response: ({ query }: any) => {
|
||||
const schId = targetSchId(query);
|
||||
return ok(dicts.filter((d) => !d.deleted && d.schId === schId).map((d) => ({ ...d, items: d.items.map((i) => ({ ...i })) })));
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/selectable", method: "get", response: ({ query }: any) => {
|
||||
const schId = targetSchId(query);
|
||||
const byCode = new Map<string, MockDict>();
|
||||
dicts
|
||||
.filter((d) => !d.deleted && (d.schId === schId || d.schId === PLATFORM))
|
||||
.forEach((d) => {
|
||||
const held = byCode.get(d.code);
|
||||
if (!held || d.schId === schId) byCode.set(d.code, d);
|
||||
});
|
||||
const list: DictView[] = [...byCode.values()].map((d) => ({
|
||||
...d,
|
||||
// 设计器候选=未删条目(软删条目不可新选,契约 §2.1)
|
||||
items: d.items.filter((i) => !i.deleted).map((i) => ({ ...i }))
|
||||
}));
|
||||
return ok(list);
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/save", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as DictSaveRequest;
|
||||
const schId = dictForm(ctx);
|
||||
const code = String(body.code || "").trim();
|
||||
if (!KEY_RE.test(code)) return bizErr(2, `字典 code 仅限字母/数字/下划线(≤64 位):${code}`);
|
||||
if (!String(body.name || "").trim()) return bizErr(2, "字典名称不能为空");
|
||||
let dict = dicts.find((d) => d.schId === schId && d.code === code);
|
||||
if (!dict) {
|
||||
dict = { schId, platformLevel: schId === PLATFORM, code, name: String(body.name).trim(), items: [], deleted: false };
|
||||
dicts.push(dict);
|
||||
} else {
|
||||
// code 建后不可改(它是 schema 的引用键),这里只改名
|
||||
dict.name = String(body.name).trim();
|
||||
dict.deleted = false;
|
||||
}
|
||||
return ok({ ...dict, items: dict.items.map((i) => ({ ...i })) } as DictView);
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/delete", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx) as DictSaveRequest;
|
||||
const dict = dicts.find((d) => d.schId === dictForm(ctx) && d.code === String(body.code || "").trim());
|
||||
if (!dict) return bizErr(2, "字典未找到");
|
||||
dict.deleted = true;
|
||||
return ok(null);
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/item/save", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const dict = dicts.find((d) => d.schId === dictForm(ctx) && d.code === String(body.dictCode || ""));
|
||||
if (!dict) return bizErr(2, `字典未找到:${body.dictCode}`);
|
||||
const value = String(body.value || "").trim();
|
||||
if (!value) return bizErr(2, "字典条目 value 不能为空");
|
||||
if (value.length > 64) return bizErr(2, `字典条目 value 过长(≤64):${value}`);
|
||||
if (!String(body.label || "").trim()) return bizErr(2, "字典条目 label 不能为空");
|
||||
const hit = dict.items.find((i) => i.value === value);
|
||||
const item: DictItemView = hit || { value, label: "", sort: 0, deleted: false };
|
||||
// value 是定位键:接口上没有改 value 的通道,能改的只有 label/sort(软删条目被重新编辑=放回可选集)
|
||||
item.label = String(body.label).trim();
|
||||
if (body.sort !== undefined && body.sort !== null) item.sort = Number(body.sort);
|
||||
item.deleted = false;
|
||||
if (!hit) dict.items.push(item);
|
||||
return ok({ ...item });
|
||||
} },
|
||||
{ url: "/api/form/admin/dict/item/delete", method: "post", response: (ctx: any) => {
|
||||
const body = bodyOf(ctx);
|
||||
const dict = dicts.find((d) => d.schId === dictForm(ctx) && d.code === String(body.dictCode || ""));
|
||||
const item = dict?.items.find((i) => i.value === String(body.value || "").trim());
|
||||
if (!item) return bizErr(2, `字典条目不存在:${body.dictCode}/${body.value}`);
|
||||
item.deleted = true;
|
||||
return ok(null);
|
||||
} }
|
||||
];
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 答卷编辑弹层(数据列表与管理/我的两视图共用)。
|
||||
*
|
||||
* 三条硬约束:
|
||||
* 1. **按该答卷钉住的 version 渲染**:入参是详情接口下发的 `groups`(钉住版本内联展开后的形状),
|
||||
* 不是当前发布版本——升版后新增的字段不会在这条旧答卷的弹层里出现(V8 同族,任务书 T8 原文)。
|
||||
* 2. **按同版本校验提交**:校验权威在后端 `FormValueValidator`(它对着钉住版本 `rejectUnknownKeys`
|
||||
* + 必填 + 值形状 + 字典放行集 + 附件归属),前端**不复制一份校验器**——复制了就会有第二套口径,
|
||||
* 与后端漂移时表现为"前端能过、后端拒"或反之。被拒时把后端点名的字段与原因原样显示。
|
||||
* 3. **值文档只带钉住版本里存在的 key**:后端对未知 key 一律 PARAM_ERROR,
|
||||
* 所以历史残留 key 不随保存回传,弹层里点名告知会丢(不静默)。
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { fieldWidget } from "@/components/fields";
|
||||
import { flattenFields, isFileArray } from "@/utils/dataValue";
|
||||
import { dataApi } from "@/api/module/data";
|
||||
import { editMyAnswer } from "@/api/module/fill";
|
||||
import type { AnswerDetail } from "@/types/data";
|
||||
import type { DesignField } from "@/types/formSchema";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
detail: AnswerDetail | null;
|
||||
/** admin=数据管理面(/api/form/admin/data/edit);my=本人编辑(/api/form/my/edit) */
|
||||
mode?: "admin" | "my";
|
||||
}>(),
|
||||
{ mode: "admin" }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "saved", answerId: string): void;
|
||||
}>();
|
||||
|
||||
/** 编辑中的值文档:控件的 modelValue 类型按控件各异(string/number/数组/附件对象数组),这里保持宽松 */
|
||||
const values = ref<Record<string, any>>({});
|
||||
const summary = ref("");
|
||||
const saving = ref(false);
|
||||
|
||||
/** 钉住版本的字段清单(数组序即展示序,契约 §2) */
|
||||
const fields = computed<DesignField[]>(() => flattenFields(props.detail?.groups));
|
||||
|
||||
/** 值文档里有、钉住版本 schema 里没有的 key:不随保存回传(后端 rejectUnknownKeys),点名提示 */
|
||||
const droppedKeys = computed(() => {
|
||||
const known = new Set(fields.value.map((f) => f.key));
|
||||
return Object.keys(props.detail?.values || {}).filter((k) => !known.has(k));
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.detail,
|
||||
(detail) => {
|
||||
const picked: Record<string, any> = {};
|
||||
if (detail) {
|
||||
for (const field of flattenFields(detail.groups)) {
|
||||
const raw = (detail.values || {})[field.key];
|
||||
// 附件/多选的空值给成数组,控件按数组消费;其它空值保持 undefined(后端按"未填"判)
|
||||
if (isFileArray(raw)) picked[field.key] = raw;
|
||||
else if (Array.isArray(raw)) picked[field.key] = raw.slice();
|
||||
else if (raw !== null && raw !== undefined) picked[field.key] = raw;
|
||||
}
|
||||
}
|
||||
values.value = picked;
|
||||
summary.value = "";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function widgetFor(type: string) {
|
||||
return fieldWidget(type, "pc");
|
||||
}
|
||||
|
||||
function setValue(key: string, value: unknown) {
|
||||
values.value = { ...values.value, [key]: value };
|
||||
}
|
||||
|
||||
const errorText = ref("");
|
||||
|
||||
async function submit() {
|
||||
if (!props.detail || saving.value) return;
|
||||
saving.value = true;
|
||||
errorText.value = "";
|
||||
try {
|
||||
if (props.mode === "my") await editMyAnswer(props.detail.answerId, values.value, summary.value || undefined);
|
||||
else await dataApi.edit(props.detail.answerId, values.value, summary.value || undefined).send();
|
||||
Message.success(`已按钉住版本 v${props.detail.version} 保存`);
|
||||
emit("saved", props.detail.answerId);
|
||||
emit("close");
|
||||
} catch (e: any) {
|
||||
// 后端的 msg 已点名字段与原因(契约 §6:PARAM_ERROR + msg 指明字段)
|
||||
errorText.value = e?.message || "保存失败";
|
||||
Message.error(errorText.value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-if="detail" :model-value="true" :title="`编辑答卷(钉住版本 v${detail.version})`" :width="720"
|
||||
:mask-closable="false" @on-cancel="emit('close')">
|
||||
<div class="edit-head">
|
||||
<span class="meta">答卷 <code>{{ detail.answerId }}</code></span>
|
||||
<span class="meta">{{ detail.formName }}|{{ detail.appCode }}/{{ detail.code }}</span>
|
||||
<span class="meta">提交时间 {{ detail.submitTime }}</span>
|
||||
</div>
|
||||
<Alert show-icon type="info" class="pin-tip">
|
||||
本弹层按该答卷<b>提交时钉住的 v{{ detail.version }}</b> 渲染与校验;当前发布版本新增的字段不会出现在这里,
|
||||
保存也不会把它们写进这条历史答卷(任务书 T8 的"新 schema 字段不得在旧数据上凭空出现")。
|
||||
</Alert>
|
||||
<Alert v-if="droppedKeys.length" show-icon type="warning" class="pin-tip">
|
||||
值文档里有 {{ droppedKeys.length }} 个不属于该版本的遗留 key({{ droppedKeys.join("、") }}),
|
||||
后端对未知 key 一律拒绝,本次保存不会把它们回传。
|
||||
</Alert>
|
||||
|
||||
<div class="edit-fields">
|
||||
<div v-for="field in fields" :key="field.key" class="edit-field">
|
||||
<label class="field-label">
|
||||
{{ field.label || field.key }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<component :is="widgetFor(field.type)" :field="field" :options="Array.isArray(field.options) ? field.options : []"
|
||||
:model-value="values[field.key]" :readonly="false"
|
||||
@update:model-value="(val: unknown) => setValue(field.key, val)" />
|
||||
<div v-if="field.tip" class="field-tip">{{ field.tip }}</div>
|
||||
</div>
|
||||
<div v-if="!fields.length" class="field-tip">该版本没有可编辑字段。</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<label class="field-label">改动说明(进 answer_op_log)</label>
|
||||
<Input v-model="summary" type="textarea" :rows="2" placeholder="如:学生姓名录错,按学籍号核对后修正" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="emit('close')">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="submit">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edit-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.edit-head code {
|
||||
color: #17233d;
|
||||
}
|
||||
.pin-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.edit-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.edit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.required {
|
||||
color: #ed4014;
|
||||
}
|
||||
.field-tip {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
.summary-row {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 答卷值的只读呈现(数据详情页 / 我的填写详情 / 编辑弹层的"原值"对照共用)。
|
||||
*
|
||||
* 三条纪律:
|
||||
* 1. **三态空值与已删字段降级**(契约 §3、V8):值在 schema 且为空 → "—";
|
||||
* values 无此 key(提交时字段还不存在)→ "—";values 有 key 但传入的 schema 已删该字段 →
|
||||
* "(已移除字段)原始key: 原始值",并在表尾单列一节,不静默丢弃;
|
||||
* 2. **默认转义**(方案 §2.3 红线):全部走文本插值与 CSS(textarea 用 pre-wrap 保换行),
|
||||
* <b>本组件永不含 v-html</b>,冒烟脚本按全仓 grep 断言零命中;
|
||||
* 3. **只读态才脱敏**(契约 §3 phone):手机号在只读呈现打码,编辑态由弹层自己传原值。
|
||||
*
|
||||
* 附件链接的 URL 前缀与 T7 的图片控件同口径(值里只有相对 key,绝对地址渲染期拼——契约 §3、V24);
|
||||
* "经 FILE_WEB_PATH 真能取到文件"属部署环境断言,按 T3/T9 的分工留到 160 复验。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { EMPTY_TEXT, cellText, flattenFields, isFileArray } from "@/utils/dataValue";
|
||||
import type { DesignField, FileValue } from "@/types/formSchema";
|
||||
import conf from "@/assets/config/host.config";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 钉住版本(或当前发布版本)内联展开后的 groups——后端读接口下发的形状 */
|
||||
groups?: any;
|
||||
/** 答卷值文档 */
|
||||
values?: Record<string, unknown> | null;
|
||||
/** 手机号是否脱敏(只读呈现默认脱敏) */
|
||||
maskPhone?: boolean;
|
||||
}>(),
|
||||
{ groups: () => [], values: null, maskPhone: true }
|
||||
);
|
||||
|
||||
interface FileLink {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ValueRow {
|
||||
key: string;
|
||||
label: string;
|
||||
text: string;
|
||||
multiline: boolean;
|
||||
files: FileLink[];
|
||||
}
|
||||
|
||||
interface GroupView {
|
||||
key: string;
|
||||
label: string;
|
||||
rows: ValueRow[];
|
||||
}
|
||||
|
||||
const schemaFields = computed<DesignField[]>(() => flattenFields(props.groups));
|
||||
|
||||
/** 附件地址由渲染期拼(值里只存相对 key,绝对 URL 不进答卷——契约 §3、方案 §4.3②) */
|
||||
function fileLinks(value: unknown): FileLink[] {
|
||||
if (!isFileArray(value)) return [];
|
||||
const prefix = `${conf.serviceHost || ""}/static/`;
|
||||
return (value as FileValue[]).map((f) => ({
|
||||
name: String(f.fileName ?? f.key ?? ""),
|
||||
url: prefix + String(f.key ?? "")
|
||||
}));
|
||||
}
|
||||
|
||||
const views = computed<GroupView[]>(() => {
|
||||
const groups = Array.isArray(props.groups) ? (props.groups as any[]) : [];
|
||||
return groups.map((group) => {
|
||||
const fields = Array.isArray(group?.fields) ? (group.fields as DesignField[]) : [];
|
||||
return {
|
||||
key: String(group?.key ?? ""),
|
||||
label: String(group?.label ?? ""),
|
||||
rows: fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label || field.key,
|
||||
text: cellText(field, field.key, props.values, { maskPhone: props.maskPhone }),
|
||||
multiline: field.type === "textarea",
|
||||
files: fileLinks(props.values?.[field.key])
|
||||
}))
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** schema 已删、值文档里还留着的 key(V8 的降级半边) */
|
||||
const removedRows = computed<ValueRow[]>(() => {
|
||||
const known = new Set(schemaFields.value.map((f) => f.key));
|
||||
const values = props.values || {};
|
||||
return Object.keys(values)
|
||||
.filter((key) => !known.has(key))
|
||||
.map((key) => {
|
||||
const raw = values[key];
|
||||
const blank = raw === null || raw === undefined || (typeof raw === "string" && raw.trim() === "");
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
text: blank ? EMPTY_TEXT : cellText(null, key, values),
|
||||
multiline: false,
|
||||
files: []
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="answer-values">
|
||||
<section v-for="group in views" :key="group.key" class="value-group">
|
||||
<h5 v-if="group.label" class="group-title">{{ group.label }}</h5>
|
||||
<div v-for="row in group.rows" :key="row.key" class="value-row">
|
||||
<span class="value-label">{{ row.label }}</span>
|
||||
<span :class="['value-text', row.multiline ? 'pre-wrap' : '']">{{ row.text }}</span>
|
||||
<span v-if="row.files.length" class="value-files">
|
||||
<a v-for="file in row.files" :key="file.url" :href="file.url" target="_blank" rel="noopener">
|
||||
{{ file.name }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="removedRows.length" class="value-group removed-group">
|
||||
<h5 class="group-title">历史遗留字段(当前版本 schema 已删除)</h5>
|
||||
<div v-for="row in removedRows" :key="row.key" class="value-row">
|
||||
<span class="value-label">{{ row.label }}</span>
|
||||
<span class="value-text pre-wrap">{{ row.text }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.answer-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.value-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.group-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.value-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed #e8eaec;
|
||||
}
|
||||
.value-label {
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
}
|
||||
.value-text {
|
||||
color: #17233d;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.value-text.pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.value-files {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.value-files a {
|
||||
font-size: 12px;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
.removed-group .group-title {
|
||||
border-left-color: #ff9900;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设计器左栏·控件面板(一期 11 类)。
|
||||
*
|
||||
* 面板源是 CONTROL_CATALOG(只列一期放行类型),address/user/users 在这里**不出现**——
|
||||
* 后端发布校验同样拒它们,给入口只会让人画出一张发不出去的表单(契约 §2、方案 §4.2)。
|
||||
* 拖拽用 vuedraggable 的 clone 模式:clone 时即生成完整字段(带唯一 key 与 _uid),
|
||||
* 落到画布直接可用;点击条目走同一条 add 事件,键盘用户不必拖也能加字段。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
import type { ControlCatalogItem, DesignField } from "@/types/formSchema";
|
||||
import { CONTROL_CATALOG } from "@/types/formSchema";
|
||||
import { defaultField } from "@/utils/designer/schema";
|
||||
|
||||
const props = defineProps<{ nextKey: (type?: string) => string; disabled?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "add", field: DesignField): void }>();
|
||||
|
||||
const baseItems = computed(() => CONTROL_CATALOG.filter((t) => t.section === "base"));
|
||||
const enhanceItems = computed(() => CONTROL_CATALOG.filter((t) => t.section === "enhance"));
|
||||
const sections = computed(() => [
|
||||
{ name: "基础字段", items: baseItems.value },
|
||||
{ name: "增强字段", items: enhanceItems.value }
|
||||
]);
|
||||
|
||||
/** 拖拽克隆即生成字段;key 从共享命名空间取(分组 key 与字段 key 同池,P17) */
|
||||
function cloneItem(seed: ControlCatalogItem): DesignField {
|
||||
return defaultField(seed.type, props.nextKey(seed.type), seed.name);
|
||||
}
|
||||
|
||||
function clickAdd(seed: ControlCatalogItem) {
|
||||
if (props.disabled) return;
|
||||
emit("add", cloneItem(seed));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="d-lib">
|
||||
<div class="d-lib-title">表单控件(一期 11 类)</div>
|
||||
<div class="d-lib-scroll">
|
||||
<div v-for="section in sections" :key="section.name">
|
||||
<div class="d-lib-group-title">{{ section.name }}</div>
|
||||
<draggable
|
||||
:list="section.items as any[]"
|
||||
:sort="false"
|
||||
:disabled="!!disabled"
|
||||
:group="{ name: 'designer', pull: 'clone', put: false }"
|
||||
:clone="cloneItem as any"
|
||||
item-key="type"
|
||||
:force-fallback="true"
|
||||
class="d-lib-grid"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div class="d-lib-item" :class="{ off: disabled }" @click="clickAdd(element as ControlCatalogItem)">
|
||||
{{ (element as ControlCatalogItem).name }}
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
<Alert v-if="disabled" type="warning" class="d-lib-alert">已发布版本只读;要改版请回到草稿编辑并保存升版。</Alert>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.d-lib {
|
||||
width: 210px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #e8eaec;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.d-lib-title {
|
||||
padding: 12px 14px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.d-lib-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.d-lib-group-title {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
.d-lib-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.d-lib-item {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
.d-lib-item:hover {
|
||||
border-color: var(--brand, #577afb);
|
||||
color: #2d4ed8;
|
||||
}
|
||||
.d-lib-item.off {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.d-lib-item.off:hover {
|
||||
border-color: #dcdee2;
|
||||
color: #515a6e;
|
||||
}
|
||||
.d-lib-alert {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设计器中栏·画布:多分组 + 组内字段拖拽,支持跨组移动与分组自身排序。
|
||||
*
|
||||
* **数组序即展示序,没有 orderNum**(契约 §2)——拖完就是落库顺序,页面上不再排第二次。
|
||||
* 已发布字段在卡片上标「发布过」:key/type 是它的身份,要改语义只能删旧增新(契约 §2、V8 前提),
|
||||
* 所以这里不提供就地改 key 的入口,属性面板同样把输入框锁死。
|
||||
*/
|
||||
import draggable from "vuedraggable";
|
||||
import { fieldWidget } from "@/components/fields";
|
||||
import type { DesignField, DesignGroup, FieldOption } from "@/types/formSchema";
|
||||
import type { DictView } from "@/types/form";
|
||||
import { CONTROL_NAMES, isDictRef, isPhase1Type } from "@/types/formSchema";
|
||||
import { isOptionType } from "@/types/formSchema";
|
||||
import { dictRefCode } from "@/types/formSchema";
|
||||
|
||||
/**
|
||||
* Sortable 的 group 配置必须是模块级常量:模板里每次渲染都新造一个对象,
|
||||
* vuedraggable 会把它当「选项变了」反复重挂 Sortable,嵌套容器下能把自己绕死。
|
||||
*/
|
||||
const GROUP_OUTER = { name: "fc-group" };
|
||||
const GROUP_INNER = { name: "designer" };
|
||||
|
||||
const props = defineProps<{
|
||||
groups: DesignGroup[];
|
||||
selected: { gi: number; fi: number } | null;
|
||||
/** 只读态(查看已发布版本行):不给拖、不给加、不给删 */
|
||||
disabled?: boolean;
|
||||
/** 可选字典(selectable 结果),字典引用字段的预览选项从这儿展开 */
|
||||
dicts?: DictView[];
|
||||
/** 当前发布版本里已存在的字段 key(这些字段的 key/type 不可变更) */
|
||||
publishedKeys?: Set<string>;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "select", gi: number, fi: number): void;
|
||||
(e: "copy", gi: number, fi: number): void;
|
||||
(e: "remove", gi: number, fi: number): void;
|
||||
(e: "add", field: DesignField): void;
|
||||
(e: "addGroup"): void;
|
||||
(e: "removeGroup", gi: number): void;
|
||||
}>();
|
||||
|
||||
function resolvedOptions(field: DesignField): FieldOption[] {
|
||||
const options = field.options as FieldOption[] | { source?: string } | undefined;
|
||||
if (!options) return [];
|
||||
if (Array.isArray(options)) return options;
|
||||
if (isDictRef(options)) {
|
||||
const code = dictRefCode(options) || "";
|
||||
const dict = (props.dicts || []).find((d) => d.code === code);
|
||||
// 只出未删条目(软删条目不可新选,契约 §2.1)——预览按填报页会看到的候选集呈现
|
||||
return dict ? dict.items.map((i) => ({ label: i.label, value: i.value })) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function dictMissing(field: DesignField): boolean {
|
||||
const code = dictRefCode(field.options as never);
|
||||
if (!code) return false;
|
||||
return !(props.dicts || []).some((d) => d.code === code);
|
||||
}
|
||||
|
||||
function isActive(gi: number, fi: number): boolean {
|
||||
return !!props.selected && props.selected.gi === gi && props.selected.fi === fi;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽变更统一处理:只认 added/moved。
|
||||
* removed 不动选中态——Sortable 是「先 add 后 remove」派发,在这里清选中会把刚建立选中态反手抹掉
|
||||
* (svc-hall 同款坑,注释留着给下一个接手的人)。
|
||||
*/
|
||||
function onChange(gi: number, ev: any) {
|
||||
if (props.disabled) return;
|
||||
if (ev.added) emit("select", gi, ev.added.newIndex);
|
||||
else if (ev.moved) emit("select", gi, ev.moved.newIndex);
|
||||
}
|
||||
|
||||
function optionSummary(field: DesignField): string {
|
||||
if (!isOptionType(field.type)) return "";
|
||||
if (dictRefCode(field.options as never)) {
|
||||
const code = dictRefCode(field.options as never);
|
||||
return dictMissing(field) ? `字典 dict:${code} 不在可选域` : `引用字典 ${code}`;
|
||||
}
|
||||
return `${resolvedOptions(field).length} 个选项`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="d-canvas">
|
||||
<div class="d-canvas-inner">
|
||||
<draggable :list="groups as any[]" :group="GROUP_OUTER" item-key="_uid" handle=".d-group-handle"
|
||||
:disabled="!!disabled" animation="150" ghost-class="d-ghost">
|
||||
<template #item="{ element: group, index: gi }">
|
||||
<div class="d-group">
|
||||
<div class="d-group-head">
|
||||
<span v-if="!disabled" class="d-group-handle" title="拖拽排序分组">⠿</span>
|
||||
<Input v-model="group.key" placeholder="分组 key(字母/数字/下划线)" size="small"
|
||||
style="width: 190px" :disabled="!!disabled" />
|
||||
<Input v-model="group.label" placeholder="分组名称" size="small" style="width: 190px"
|
||||
:disabled="!!disabled" />
|
||||
<span class="d-group-count">{{ (group.fields || []).length }} 个字段</span>
|
||||
<Button v-if="!disabled && groups.length > 1" size="small" type="text" style="color: #ed4014"
|
||||
@click="emit('removeGroup', gi)">删除分组</Button>
|
||||
</div>
|
||||
<div class="d-group-body">
|
||||
<draggable v-model="group.fields" :group="GROUP_INNER" item-key="_uid"
|
||||
handle=".d-field-handle" :disabled="!!disabled" animation="150" ghost-class="d-ghost"
|
||||
@change="onChange(gi, $event)">
|
||||
<template #item="{ element: field, index: fi }">
|
||||
<div class="d-field" :class="{ active: isActive(gi, fi) }"
|
||||
@click="emit('select', gi, fi)">
|
||||
<span v-if="!disabled" class="d-field-handle" title="拖拽排序">⠿</span>
|
||||
<div class="d-field-main">
|
||||
<div class="d-field-label">
|
||||
<span v-if="field.required" class="d-required">*</span>
|
||||
{{ field.label || "未命名字段" }}
|
||||
<span class="d-field-key">{{ field.key }}</span>
|
||||
<Tag v-if="publishedKeys && publishedKeys.has(field.key)" size="small"
|
||||
color="primary">发布过</Tag>
|
||||
<Tag v-if="field.searchable" size="small" color="success">可检索</Tag>
|
||||
<Tag v-if="!isPhase1Type(field.type)" size="small" color="error">二期类型</Tag>
|
||||
</div>
|
||||
<div class="d-field-meta">
|
||||
{{ CONTROL_NAMES[field.type] || field.type }}
|
||||
<span v-if="optionSummary(field)" class="d-field-opts">
|
||||
({{ optionSummary(field) }})
|
||||
</span>
|
||||
<span v-if="field.tip" class="d-field-tip">{{ field.tip }}</span>
|
||||
</div>
|
||||
<div class="d-field-widget">
|
||||
<component v-if="fieldWidget(field.type)" :is="fieldWidget(field.type)"
|
||||
:field="field as any" :options="resolvedOptions(field)" />
|
||||
<span v-else class="d-widget-none">该类型控件一期未实现,发布会被拒</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!disabled" class="d-field-tools">
|
||||
<Icon type="md-copy" title="复制字段" @click.stop="emit('copy', gi, fi)" />
|
||||
<Icon type="ios-trash" title="删除字段" @click.stop="emit('remove', gi, fi)" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div v-if="!(group.fields || []).length" class="d-empty-tip">
|
||||
从左侧拖拽或点击来添加字段(数组顺序即填报页展示顺序)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<Button v-if="!disabled" type="dashed" long icon="md-add" style="margin-top: 12px"
|
||||
@click="emit('addGroup')">添加分组</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.d-canvas {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
background: #f2f4f8;
|
||||
height: 100%;
|
||||
}
|
||||
.d-canvas-inner {
|
||||
min-height: 100%;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.d-group {
|
||||
background: #fff;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.d-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.d-group-count {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.d-group-handle,
|
||||
.d-field-handle {
|
||||
cursor: grab;
|
||||
color: #c5c8ce;
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
.d-group-body {
|
||||
min-height: 48px;
|
||||
}
|
||||
.d-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafbfc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.d-field.active {
|
||||
border-color: var(--brand, #577afb);
|
||||
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
.d-field-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.d-field-label {
|
||||
color: #34373e;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.d-field-key {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
}
|
||||
.d-required {
|
||||
color: #ed4014;
|
||||
}
|
||||
.d-field-meta {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.d-field-tip {
|
||||
margin-left: 8px;
|
||||
color: #c5c8ce;
|
||||
}
|
||||
.d-field-widget {
|
||||
margin-top: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.d-widget-none {
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
}
|
||||
.d-field-tools {
|
||||
display: none;
|
||||
gap: 6px;
|
||||
color: #808695;
|
||||
font-size: 16px;
|
||||
}
|
||||
.d-field:hover .d-field-tools,
|
||||
.d-field.active .d-field-tools {
|
||||
display: flex;
|
||||
}
|
||||
.d-field-tools :deep(.ivu-icon:hover) {
|
||||
color: var(--brand, #577afb);
|
||||
cursor: pointer;
|
||||
}
|
||||
.d-ghost {
|
||||
opacity: 0.4;
|
||||
border: 1px dashed var(--brand, #577afb);
|
||||
}
|
||||
.d-empty-tip {
|
||||
color: #c5c8ce;
|
||||
text-align: center;
|
||||
padding: 14px 0;
|
||||
border: 1px dashed #dcdee2;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,463 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设计器右栏·字段属性面板(白名单属性编辑器)。
|
||||
*
|
||||
* 三条纪律都在这一栏落地:
|
||||
* 1. **只编辑白名单 19 项**——面板上没有的键就产不出来,序列化时再过一道 pickField;
|
||||
* 2. **value 生成即不可变(P19)**:选项只给 label 输入框,value 是只读码块,
|
||||
* 改 label 永远不动 value,历史答卷的筛选与统计口径才打得穿不了(方案 §4.3①);
|
||||
* 3. **发布过的字段不换身份**:key 落在当前发布版本里 → key 与 type 两个输入框锁死,
|
||||
* 要换语义请删旧字段新增(契约 §2「改 key=换字段」,后端 assertFieldTypesStable 同样拒)。
|
||||
*
|
||||
* searchable 对 upload/image 直接不可勾(P28:契约没给附件检索算子,勾了也是个恒空集的筛选项)。
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
import type { DesignField, FieldOption } from "@/types/formSchema";
|
||||
import type { DictView } from "@/types/form";
|
||||
import {
|
||||
CONTROL_NAMES,
|
||||
FILE_ACCEPT,
|
||||
IMAGE_ACCEPT,
|
||||
MAX_FILE_SIZE_MB,
|
||||
MAX_FILES_PER_FIELD,
|
||||
MAX_LABEL_LEN,
|
||||
MAX_PRECISION,
|
||||
VALUE_SHAPE_HINT,
|
||||
dictRefCode,
|
||||
isDictRef,
|
||||
isOptionType,
|
||||
searchableAllowed
|
||||
} from "@/types/formSchema";
|
||||
import { fieldErrors, makeOption, seedOptionList } from "@/utils/designer/schema";
|
||||
|
||||
const props = defineProps<{
|
||||
field: DesignField;
|
||||
dicts: DictView[];
|
||||
/** 已占用的 key 集合(含分组 key,共享命名空间),用于重复提示 */
|
||||
usedKeys: Set<string>;
|
||||
/** 当前发布版本里的字段 key → type */
|
||||
publishedTypes: Map<string, string>;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const mode = computed<"inline" | "dict">(() => (isDictRef(props.field.options) ? "dict" : "inline"));
|
||||
const inlineOptions = computed<FieldOption[]>(() =>
|
||||
Array.isArray(props.field.options) ? (props.field.options as FieldOption[]) : []
|
||||
);
|
||||
const dictCode = computed(() => dictRefCode(props.field.options as never) || "");
|
||||
const errors = computed(() => fieldErrors(props.field, props.usedKeys));
|
||||
const keyLocked = computed(() => props.publishedTypes.has(props.field.key));
|
||||
const isOptionField = computed(() => isOptionType(props.field.type));
|
||||
const isTextLike = computed(() => ["text", "textarea", "phone"].includes(props.field.type));
|
||||
/** 单行文本类:textarea 的行数/长度在下一栏单独给,两块不能同时铺 maxLength */
|
||||
const isPlainText = computed(() => ["text", "phone"].includes(props.field.type));
|
||||
const isAttach = computed(() => props.field.type === "upload" || props.field.type === "image");
|
||||
const canSearchable = computed(() => searchableAllowed(props.field.type));
|
||||
/** 默认值候选项:字典引用字段用展开后的条目,内联字段用自己的 options */
|
||||
const choiceOptions = computed<FieldOption[]>(() => {
|
||||
if (mode.value === "dict" && pickedDict.value) {
|
||||
return pickedDict.value.items.map((i) => ({ label: i.label, value: i.value }));
|
||||
}
|
||||
return inlineOptions.value;
|
||||
});
|
||||
|
||||
/** 可选字典按层级分栏:平台级(可见可用不可改)与本校 */
|
||||
const platformDicts = computed(() => props.dicts.filter((d) => d.platformLevel));
|
||||
const schoolDicts = computed(() => props.dicts.filter((d) => !d.platformLevel));
|
||||
const pickedDict = computed(() => props.dicts.find((d) => d.code === dictCode.value) || null);
|
||||
|
||||
/** 选字典的临时选择:只有真的选定一个 code 才写回 options,避免留下 source:"" 的半成品 */
|
||||
const pendingDict = ref("");
|
||||
watch(
|
||||
() => props.field._uid,
|
||||
() => {
|
||||
pendingDict.value = "";
|
||||
}
|
||||
);
|
||||
|
||||
function switchMode(next: string) {
|
||||
if (next === mode.value) return;
|
||||
if (next === "inline") {
|
||||
props.field.options = inlineOptions.value.length ? inlineOptions.value : seedOptionList();
|
||||
} else {
|
||||
props.field.options = { source: "" };
|
||||
pendingDict.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function applyDict(code: string) {
|
||||
if (!code) return;
|
||||
props.field.options = { source: `dict:${code}` };
|
||||
pendingDict.value = "";
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
if (!Array.isArray(props.field.options)) props.field.options = [];
|
||||
const list = props.field.options as FieldOption[];
|
||||
list.push(makeOption(list.map((o) => o.value), `选项${list.length + 1}`));
|
||||
}
|
||||
|
||||
function removeOption(index: number) {
|
||||
const list = inlineOptions.value;
|
||||
// 至少留一项:后端对选项类控件的 options 判「不能为空数组」(契约 §2),这里提前拦住
|
||||
if (list.length <= 1) return;
|
||||
list.splice(index, 1);
|
||||
}
|
||||
|
||||
function usePresetAccept(preset: string) {
|
||||
props.field.accept = preset;
|
||||
}
|
||||
|
||||
/** defaultValue 按契约 §3 的值形状收窄:number 存数字、多选存 value 数组、其余存字符串 */
|
||||
const defText = computed<string>({
|
||||
get: () => (typeof props.field.defaultValue === "string" ? props.field.defaultValue : ""),
|
||||
set: (v) => {
|
||||
props.field.defaultValue = v === "" ? undefined : v;
|
||||
}
|
||||
});
|
||||
const defNumber = computed<number | undefined>({
|
||||
get: () => (typeof props.field.defaultValue === "number" ? props.field.defaultValue : undefined),
|
||||
set: (v) => {
|
||||
props.field.defaultValue = v === undefined || v === null ? undefined : v;
|
||||
}
|
||||
});
|
||||
const defList = computed<string[]>({
|
||||
get: () => (Array.isArray(props.field.defaultValue) ? (props.field.defaultValue as string[]) : []),
|
||||
set: (v) => {
|
||||
props.field.defaultValue = v.length ? v : undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="d-prop">
|
||||
<div class="d-prop-title">字段属性</div>
|
||||
<div class="d-prop-scroll">
|
||||
<Alert v-if="errors.length" type="error" show-icon class="mb10">
|
||||
<div v-for="(err, i) in errors" :key="i">{{ err }}</div>
|
||||
</Alert>
|
||||
|
||||
<div class="prop-block">
|
||||
<div class="prop-row">
|
||||
<label>字段名称</label>
|
||||
<Input v-model="field.label" :maxlength="MAX_LABEL_LEN" placeholder="必填,填报页显示它"
|
||||
:disabled="disabled" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>字段 key</label>
|
||||
<Tooltip v-if="keyLocked" max-width="260" placement="top"
|
||||
content="该 key 已发布过:key 与 type 都不可变更(改 key=换字段)。要换语义请删掉本字段再新增。">
|
||||
<Input :model-value="field.key" disabled />
|
||||
</Tooltip>
|
||||
<Input v-else v-model="field.key" placeholder="字母/数字/下划线,≤64" :disabled="disabled" />
|
||||
<div class="prop-note">答卷值与检索索引都按 key 定位,发布后不可改</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>控件类型</label>
|
||||
<div class="prop-static">
|
||||
{{ CONTROL_NAMES[field.type] || field.type }}
|
||||
<Tag v-if="!searchableAllowed(field.type)" size="small">附件类不可检索</Tag>
|
||||
</div>
|
||||
<div class="prop-note">值形状:{{ VALUE_SHAPE_HINT[field.type] || "—" }}</div>
|
||||
</div>
|
||||
<div class="prop-row inline">
|
||||
<Checkbox v-model="field.required" :disabled="disabled">必填</Checkbox>
|
||||
<Tooltip v-if="!canSearchable" max-width="240" placement="top"
|
||||
content="upload/image 没有检索算子(P28):勾了也不进筛选候选,设计器直接不给入口">
|
||||
<Checkbox :model-value="false" disabled>可检索</Checkbox>
|
||||
</Tooltip>
|
||||
<Checkbox v-else v-model="field.searchable" :disabled="disabled">可检索</Checkbox>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>填写提示 tip</label>
|
||||
<Input v-model="field.tip" :disabled="disabled" placeholder="字段下方的灰字说明(可空)" />
|
||||
</div>
|
||||
<div v-if="!isOptionField && !isAttach" class="prop-row">
|
||||
<label>占位文本</label>
|
||||
<Input v-model="field.placeholder" :disabled="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isOptionField" class="prop-block">
|
||||
<div class="prop-head">选项</div>
|
||||
<RadioGroup :model-value="mode" type="button" size="small" :disabled="disabled"
|
||||
@on-change="switchMode">
|
||||
<Radio label="inline">内联选项</Radio>
|
||||
<Radio label="dict">引用字典</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<template v-if="mode === 'inline'">
|
||||
<draggable :list="inlineOptions as any[]" item-key="value" handle=".opt-handle"
|
||||
:disabled="disabled" class="opt-list">
|
||||
<template #item="{ element: option, index }">
|
||||
<div class="opt-row">
|
||||
<span class="opt-handle">⠿</span>
|
||||
<Input v-model="option.label" size="small" placeholder="选项名称(可随便改)"
|
||||
:disabled="disabled" />
|
||||
<Tooltip max-width="240" placement="top"
|
||||
content="value 生成即不可变:答案存它,改 label 不影响历史统计与筛选(P19)">
|
||||
<code class="opt-value">{{ option.value }}</code>
|
||||
</Tooltip>
|
||||
<Icon v-if="!disabled && inlineOptions.length > 1" type="ios-close-circle-outline"
|
||||
class="opt-del" @click="removeOption(index)" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<Button size="small" icon="md-add" :disabled="disabled" @click="addOption">添加选项</Button>
|
||||
<div class="prop-note">顺序即填报页展示顺序(数组序,无 orderNum);多选回显也按这个顺序</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="prop-row">
|
||||
<label>选择字典</label>
|
||||
<Select v-model="pendingDict" :disabled="disabled" filterable placeholder="选择要引用的选项字典"
|
||||
@on-change="applyDict">
|
||||
<OptionGroup v-if="platformDicts.length" label="平台级(可见可用不可改)">
|
||||
<Option v-for="d in platformDicts" :key="'p' + d.code" :value="d.code"
|
||||
:label="`${d.name}(dict:${d.code},${d.items.length} 项)`" />
|
||||
</OptionGroup>
|
||||
<OptionGroup v-if="schoolDicts.length" label="本校">
|
||||
<Option v-for="d in schoolDicts" :key="'s' + d.code" :value="d.code"
|
||||
:label="`${d.name}(dict:${d.code},${d.items.length} 项)`" />
|
||||
</OptionGroup>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="pickedDict" class="dict-preview">
|
||||
<span v-for="i in pickedDict.items" :key="i.value" class="dict-item">
|
||||
{{ i.label }}<code>{{ i.value }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div class="prop-note">
|
||||
当前引用:<code>dict:{{ dictCode || "(未选)" }}</code>。字典是活引用、不进版本快照,
|
||||
下发时内联展开;软删条目不再出现在这里,但旧答卷仍可原样重存(契约 §2.1)。
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="isTextLike || field.type === 'number' || field.type === 'date' || isOptionField"
|
||||
class="prop-block">
|
||||
<div class="prop-head">校验与展示</div>
|
||||
<div v-if="isPlainText" class="prop-grid">
|
||||
<div class="prop-row">
|
||||
<label>最大长度</label>
|
||||
<InputNumber v-model="field.maxLength" :min="1" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>正则 pattern</label>
|
||||
<Input v-model="field.pattern" :disabled="disabled" placeholder="可空,例如 ^1[3-9]\d{9}$" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="field.type === 'textarea'" class="prop-grid">
|
||||
<div class="prop-row">
|
||||
<label>行数 rows</label>
|
||||
<InputNumber v-model="field.rows" :min="1" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>最大长度</label>
|
||||
<InputNumber v-model="field.maxLength" :min="1" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="field.type === 'number'" class="prop-grid">
|
||||
<div class="prop-row">
|
||||
<label>最小值 min</label>
|
||||
<InputNumber v-model="field.min" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>最大值 max</label>
|
||||
<InputNumber v-model="field.max" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>小数位 precision</label>
|
||||
<InputNumber v-model="field.precision" :min="0" :max="MAX_PRECISION" :disabled="disabled"
|
||||
controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>单位 unit</label>
|
||||
<Input v-model="field.unit" :disabled="disabled" placeholder="如 分、公斤" />
|
||||
</div>
|
||||
<div class="prop-note">precision 上限 {{ MAX_PRECISION }}:索引列 v_num 是 DECIMAL(20,6)</div>
|
||||
</div>
|
||||
<div v-if="field.type === 'date'" class="prop-note">
|
||||
日期存储格式定死 YYYY-MM-DD(区间筛选按日期类型比,不按字典序)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isAttach" class="prop-block">
|
||||
<div class="prop-head">附件约束</div>
|
||||
<div class="prop-row">
|
||||
<label>扩展名 accept</label>
|
||||
<Input v-model="field.accept" :disabled="disabled" placeholder=".pdf,.docx" />
|
||||
<div class="prop-actions">
|
||||
<Button size="small" :disabled="disabled" @click="usePresetAccept(IMAGE_ACCEPT)">图片预设</Button>
|
||||
<Button size="small" :disabled="disabled" @click="usePresetAccept(FILE_ACCEPT)">文档预设</Button>
|
||||
</div>
|
||||
<div class="prop-note">白名单不得含 .svg(P28),逗号分隔、带点前缀</div>
|
||||
</div>
|
||||
<div class="prop-grid">
|
||||
<div class="prop-row">
|
||||
<label>单文件 MB</label>
|
||||
<InputNumber v-model="field.maxSize" :min="1" :max="MAX_FILE_SIZE_MB" :disabled="disabled"
|
||||
controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>最多个数</label>
|
||||
<InputNumber v-model="field.maxFiles" :min="1" :max="MAX_FILES_PER_FIELD" :disabled="disabled"
|
||||
controls-out />
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-note">
|
||||
硬上限:单文件 {{ MAX_FILE_SIZE_MB }}MB、每字段 {{ MAX_FILES_PER_FIELD }} 个(契约 §3);
|
||||
答卷值只存 {id,fileName,key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prop-block">
|
||||
<div class="prop-head">默认值</div>
|
||||
<div v-if="isTextLike" class="prop-row">
|
||||
<Input v-model="defText" :disabled="disabled" placeholder="留空即无默认值" />
|
||||
</div>
|
||||
<div v-else-if="field.type === 'number'" class="prop-row">
|
||||
<InputNumber v-model="defNumber" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div v-else-if="field.type === 'date'" class="prop-row">
|
||||
<DatePicker type="date" format="yyyy-MM-dd" v-model="defText" :disabled="disabled"
|
||||
style="width: 100%" />
|
||||
</div>
|
||||
<div v-else-if="field.type === 'select' || field.type === 'radio'" class="prop-row">
|
||||
<Select v-model="defText" :disabled="disabled" clearable placeholder="无默认值">
|
||||
<Option v-for="o in choiceOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'checkbox' || field.type === 'multiselect'" class="prop-row">
|
||||
<Select v-model="defList" multiple :disabled="disabled" clearable placeholder="无默认值">
|
||||
<Option v-for="o in choiceOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
</div>
|
||||
<div v-else class="prop-note">附件类控件不设默认值(值来自上传返回值)</div>
|
||||
<div class="prop-note">defaultValue 的类型必须与值形状一致,后端发布校验同判</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.d-prop {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid #e8eaec;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.d-prop-title {
|
||||
padding: 12px 14px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.d-prop-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px 24px;
|
||||
}
|
||||
.prop-block {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px dashed #eff0f3;
|
||||
}
|
||||
.prop-head {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.prop-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.prop-row.inline {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.prop-row > label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.prop-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.prop-static {
|
||||
color: #34373e;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.prop-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.prop-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.opt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.opt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.opt-handle {
|
||||
cursor: grab;
|
||||
color: #c5c8ce;
|
||||
}
|
||||
.opt-value {
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.opt-del {
|
||||
color: #c5c8ce;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.opt-del:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
.dict-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.dict-item {
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.dict-item code {
|
||||
margin-left: 4px;
|
||||
color: #808695;
|
||||
}
|
||||
.mb10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
/** checkbox 多选(值 string[]=选项 value 数组,契约 §3)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { stringArrayDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string[];
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const items = computed(() => props.options || []);
|
||||
const displayValue = computed(() => props.modelValue ?? stringArrayDefault(props.field));
|
||||
|
||||
function onChange(val: string[]) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxGroup :model-value="displayValue" @on-change="onChange">
|
||||
<Checkbox v-for="o in items" :key="o.value" :label="o.value" :disabled="readonly">{{ o.label }}</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
/** date 日期(存储格式定死 YYYY-MM-DD,方案 §4.3④——旧项目用 TEXT 字典序比日期是反面教材)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onChange(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DatePicker type="date" format="yyyy-MM-dd" :model-value="displayValue"
|
||||
:placeholder="field.placeholder || '请选择日期'" :disabled="readonly" style="width: 200px"
|
||||
@on-change="onChange" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* image 图片(值 [{id,fileName,key}],契约 §3——中心上传接口直接返回这三件,
|
||||
* 前端只存返回值,绝不把 fileUrl/绝对 URL 写进答卷值,否则换域名历史附件全断)。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import type { FileValue } from "@/types/formSchema";
|
||||
import { Upload } from "view-ui-plus";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: FileValue[];
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: () => [],
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: FileValue[]): void;
|
||||
}>();
|
||||
|
||||
const fileList = computed(() => props.modelValue || []);
|
||||
const uploading = ref(false);
|
||||
|
||||
const hints = computed(() => {
|
||||
const f = props.field;
|
||||
return [
|
||||
f.accept ? `格式 ${f.accept}` : "",
|
||||
f.maxSize ? `单张 ≤${f.maxSize}MB` : "",
|
||||
f.maxFiles ? `最多 ${f.maxFiles} 张` : ""
|
||||
].filter(Boolean);
|
||||
});
|
||||
|
||||
const accept = computed(() => props.field.accept || ".jpg,.jpeg,.png,.gif,.bmp");
|
||||
|
||||
async function beforeUpload(file: File) {
|
||||
const maxSize = (props.field.maxSize || 50) * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
return false;
|
||||
}
|
||||
const maxFiles = props.field.maxFiles || 9;
|
||||
if (fileList.value.length >= maxFiles) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
uploading.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>(
|
||||
"/api/form/upload",
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
).send();
|
||||
const newValue = [...fileList.value, { id: result.id, fileName: result.fileName, key: result.key }];
|
||||
emit("update:modelValue", newValue);
|
||||
} catch (e) {
|
||||
console.error("Upload failed:", e);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(index: number) {
|
||||
const newValue = fileList.value.filter((_, i) => i !== index);
|
||||
emit("update:modelValue", newValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="attach">
|
||||
<div class="image-list">
|
||||
<div v-for="(item, idx) in fileList" :key="item.id" class="image-item">
|
||||
<img :src="`/static/${item.key}`" :alt="item.fileName" class="image-preview" />
|
||||
<div v-if="!readonly" class="image-remove" @click="handleRemove(idx)">×</div>
|
||||
</div>
|
||||
<Upload v-if="!readonly" :before-upload="beforeUpload" :handle-upload="handleUpload" :accept="accept"
|
||||
:disabled="uploading">
|
||||
<div class="attach-tile"><Icon type="ios-add" /></div>
|
||||
</Upload>
|
||||
<div v-else-if="fileList.length === 0" class="attach-tile empty"><Icon type="ios-image" /></div>
|
||||
</div>
|
||||
<div v-if="hints.length" class="attach-hint">{{ hints.join("|") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attach {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.image-item {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.image-remove {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.attach-tile {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px dashed #dcdee2;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #c5c8ce;
|
||||
font-size: 22px;
|
||||
background: #fafbfc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.attach-tile.empty {
|
||||
cursor: default;
|
||||
}
|
||||
.attach-hint {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, { pc: Component; app: Component }> = {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/** multiselect 下拉多选(值 string[],展示按 options 定义序而非勾选序——契约 §3)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { stringArrayDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string[];
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const items = computed(() => props.options || []);
|
||||
const displayValue = computed(() => props.modelValue ?? stringArrayDefault(props.field));
|
||||
|
||||
function onChange(val: string[]) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select multiple :model-value="displayValue" :placeholder="field.placeholder || '请选择'" :disabled="readonly"
|
||||
style="width: 260px" @on-change="onChange">
|
||||
<Option v-for="o in items" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
/** number 数字(值 number|null,空不是空串;precision/unit 是 v1.1 补进白名单的展示属性,契约 §2)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { numberDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: number | null;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: number | null): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? numberDefault(props.field));
|
||||
|
||||
function onChange(val: number | null) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="num-widget">
|
||||
<InputNumber :model-value="displayValue" :min="field.min" :max="field.max" :precision="field.precision"
|
||||
:disabled="readonly" @on-change="onChange" />
|
||||
<span v-if="field.unit" class="num-unit">{{ field.unit }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.num-unit {
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/** phone 手机号(值 string;pattern 由字段声明,校验在填报侧执行,脱敏只读呈现属 T7)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onInput(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input :model-value="displayValue" :maxlength="field.maxLength || 11"
|
||||
:placeholder="field.placeholder || '请输入手机号'" :disabled="readonly" @input="onInput" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
/** radio 单选(值 string=选项 value;多选展示顺序按 options 定义序,数组序即展示序)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const items = computed(() => props.options || []);
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onChange(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroup :model-value="displayValue" @on-change="onChange">
|
||||
<Radio v-for="o in items" :key="o.value" :label="o.value" :disabled="readonly">{{ o.label }}</Radio>
|
||||
</RadioGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/** select 下拉单选(答案存 value,只读呈现回查 options 取 label——契约 §3)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const items = computed(() => props.options || []);
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onChange(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select :model-value="displayValue" :placeholder="field.placeholder || '请选择'" :disabled="readonly"
|
||||
style="width: 220px" @on-change="onChange">
|
||||
<Option v-for="o in items" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/** text 单行文本(契约 §3 值形状 string)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onInput(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input :model-value="displayValue" :placeholder="field.placeholder || '请输入'" :disabled="readonly"
|
||||
@input="onInput" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/** textarea 多行文本(值 string;只读呈现保留换行,方案 §4.3②)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onInput(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input type="textarea" :model-value="displayValue" :rows="field.rows || 3" :maxlength="field.maxLength"
|
||||
:placeholder="field.placeholder || '请输入'" :disabled="readonly" @input="onInput" />
|
||||
</template>
|
||||
@@ -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") : [];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* upload 附件(值 [{id,fileName,key}];单文件 ≤50MB、每字段 ≤9 个是契约 §3 的硬上限,
|
||||
* 字段级 accept/maxSize/maxFiles 只能往更严收,不能往宽放)。
|
||||
* accept 不含 .svg 由设计器把门(P28),这里只如实呈现配置。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import type { FileValue } from "@/types/formSchema";
|
||||
import { Upload } from "view-ui-plus";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: FileValue[];
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: () => [],
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: FileValue[]): void;
|
||||
}>();
|
||||
|
||||
const fileList = computed(() => props.modelValue || []);
|
||||
const uploading = ref(false);
|
||||
|
||||
const hints = computed(() => {
|
||||
const f = props.field;
|
||||
return [
|
||||
f.accept ? `格式 ${f.accept}` : "",
|
||||
f.maxSize ? `单个 ≤${f.maxSize}MB` : "",
|
||||
f.maxFiles ? `最多 ${f.maxFiles} 个` : ""
|
||||
].filter(Boolean);
|
||||
});
|
||||
|
||||
const accept = computed(() => props.field.accept || "");
|
||||
|
||||
async function beforeUpload(file: File) {
|
||||
const maxSize = (props.field.maxSize || 50) * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
return false;
|
||||
}
|
||||
const maxFiles = props.field.maxFiles || 9;
|
||||
if (fileList.value.length >= maxFiles) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
uploading.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>(
|
||||
"/api/form/upload",
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
).send();
|
||||
const newValue = [...fileList.value, { id: result.id, fileName: result.fileName, key: result.key }];
|
||||
emit("update:modelValue", newValue);
|
||||
} catch (e) {
|
||||
console.error("Upload failed:", e);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(index: number) {
|
||||
const newValue = fileList.value.filter((_, i) => i !== index);
|
||||
emit("update:modelValue", newValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="attach">
|
||||
<div class="file-list">
|
||||
<div v-for="(item, idx) in fileList" :key="item.id" class="file-item">
|
||||
<Icon type="ios-document" class="file-icon" />
|
||||
<span class="file-name">{{ item.fileName }}</span>
|
||||
<Icon v-if="!readonly" type="ios-close-circle" class="file-remove" @click="handleRemove(idx)" />
|
||||
</div>
|
||||
</div>
|
||||
<Upload v-if="!readonly" :before-upload="beforeUpload" :handle-upload="handleUpload" :accept="accept"
|
||||
:disabled="uploading">
|
||||
<Button icon="ios-cloud-upload-outline" :loading="uploading">上传附件</Button>
|
||||
</Upload>
|
||||
<div v-if="hints.length" class="attach-hint">{{ hints.join("|") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attach {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
background: #f7f7f7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.file-icon {
|
||||
font-size: 16px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-remove {
|
||||
font-size: 16px;
|
||||
color: #ed4014;
|
||||
cursor: pointer;
|
||||
}
|
||||
.attach-hint {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表单级配置面板(方案 §5.1:配置与状态定义**不进版本快照**,改它们不升版)。
|
||||
*
|
||||
* 三处口径按后端原样,不自己发明:
|
||||
* - **限次只有 fillLimit 一个开关**:`limitScope` 不是表单可配项,它是提交时按 subject_id
|
||||
* 推导的语义(subject 非空按被代填人限、否则按提交人限——契约 §5)。所以这里给的是解释文案,
|
||||
* 不是一个假的单选框(本轮问题单 P53)。
|
||||
* - 截止时间只到日期时后端补 23:59:59(方案 §5.4),空=不限。
|
||||
* - 状态定义:code 建后不改(历史答卷按 code 存状态,改 code=改写历史),能改的只有 label
|
||||
* 与「废弃」标记;废弃是软删(V13 历史仍显示其名),且至少留一个可用状态。
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { formApi } from "@/api/module/form";
|
||||
import type { FormView, StatusDefView } from "@/types/form";
|
||||
import { AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form";
|
||||
import { MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema";
|
||||
|
||||
const props = defineProps<{ form: FormView | null }>();
|
||||
const emit = defineEmits<{ (e: "saved", form: FormView): void }>();
|
||||
|
||||
const visible = ref(false);
|
||||
const saving = ref(false);
|
||||
const model = reactive<{
|
||||
audience: number;
|
||||
fillLimit: number;
|
||||
deadline: string;
|
||||
answerNoEnabled: number;
|
||||
groupTag: string;
|
||||
}>({
|
||||
audience: AUDIENCE_OPTIONS[0].value,
|
||||
fillLimit: 0,
|
||||
deadline: "",
|
||||
answerNoEnabled: 0,
|
||||
groupTag: ""
|
||||
});
|
||||
const statusDefs = ref<StatusDefView[]>([]);
|
||||
const newStatus = reactive({ code: "", label: "" });
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.form?.formUuid] as const,
|
||||
() => {
|
||||
const f = props.form;
|
||||
if (!visible.value || !f) return;
|
||||
model.audience = f.audience ?? AUDIENCE_OPTIONS[0].value;
|
||||
model.fillLimit = f.fillLimit ?? 0;
|
||||
model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : "";
|
||||
model.answerNoEnabled = f.answerNoEnabled ?? 0;
|
||||
model.groupTag = f.groupTag || "";
|
||||
statusDefs.value = (f.statusDefs || []).map((d) => ({ ...d }));
|
||||
newStatus.code = "";
|
||||
newStatus.label = "";
|
||||
}
|
||||
);
|
||||
|
||||
const isPlatform = computed(() => props.form?.schId === 0);
|
||||
const statusText = computed(() => {
|
||||
const f = props.form;
|
||||
if (!f) return "";
|
||||
if (f.status === FORM_STATUS.DRAFT) return "草稿(从未发布)";
|
||||
if (f.status === FORM_STATUS.PUBLISHED) return `已发布 v${f.currentVersion}`;
|
||||
return `已停用(当前发布 v${f.currentVersion})`;
|
||||
});
|
||||
|
||||
function open() {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function addStatus() {
|
||||
const code = newStatus.code.trim();
|
||||
const label = newStatus.label.trim();
|
||||
if (!code || !CODE_PATTERN.test(code)) {
|
||||
Message.error("状态 code 仅限字母/数字/下划线(≤64 位)");
|
||||
return;
|
||||
}
|
||||
if (!label) {
|
||||
Message.error("状态名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (statusDefs.value.some((d) => d.code === code)) {
|
||||
// 含已废弃的 code:复用它会改写历史答卷的状态名(后端同判)
|
||||
Message.error(`状态 code 已存在(含已废弃的):${code}`);
|
||||
return;
|
||||
}
|
||||
statusDefs.value.push({ code, label, is_deleted: false });
|
||||
newStatus.code = "";
|
||||
newStatus.label = "";
|
||||
}
|
||||
|
||||
function moveStatus(index: number, dir: -1 | 1) {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= statusDefs.value.length) return;
|
||||
const list = statusDefs.value;
|
||||
[list[index], list[target]] = [list[target], list[index]];
|
||||
}
|
||||
|
||||
function liveCount(defs: StatusDefView[]): number {
|
||||
return defs.filter((d) => !d.is_deleted).length;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const f = props.form;
|
||||
if (!f) return;
|
||||
if (statusDefs.value.length && liveCount(statusDefs.value) === 0) {
|
||||
Message.error("至少要保留一个可用状态(全部废弃后新数据无状态可给)");
|
||||
return;
|
||||
}
|
||||
if (model.answerNoEnabled === 1 && f.code.length > 49) {
|
||||
Message.error(`开启报名号要求 code ≤49 位(当前 ${f.code.length})`);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await formApi
|
||||
.config({
|
||||
uuid: f.formUuid,
|
||||
audience: model.audience,
|
||||
fillLimit: model.fillLimit,
|
||||
deadline: model.deadline,
|
||||
answerNoEnabled: model.answerNoEnabled,
|
||||
groupTag: model.groupTag
|
||||
})
|
||||
.send();
|
||||
// 状态定义为空列表时不能传空数组(后端拒),传 null 才是「回默认待处理/已处理」
|
||||
const defs = statusDefs.value;
|
||||
const latest = await formApi
|
||||
.statusDefs({ uuid: f.formUuid, statusDefs: defs.length ? defs : null })
|
||||
.send();
|
||||
emit("saved", latest);
|
||||
Message.success("表单配置已保存(不进版本快照,无需升版)");
|
||||
visible.value = false;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLifecycle() {
|
||||
const f = props.form;
|
||||
if (!f) return;
|
||||
try {
|
||||
const res =
|
||||
f.status === FORM_STATUS.PUBLISHED
|
||||
? await formApi.disable(f.formUuid).send()
|
||||
: await formApi.enable(f.formUuid).send();
|
||||
emit("saved", res);
|
||||
Message.success(
|
||||
f.status === FORM_STATUS.PUBLISHED
|
||||
? "已停用:填报入口失效,数据仍可读可导"
|
||||
: "已恢复当前发布版本"
|
||||
);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model="visible" :title="`表单配置|${form?.name || ''}`" :width="640" :mask-closable="false">
|
||||
<div v-if="form" class="cfg">
|
||||
<Alert v-if="isPlatform" type="warning" show-icon class="mb10">
|
||||
平台级表单(sch_id=0):跨校资产,只有超管上下文可维护。
|
||||
</Alert>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">生命周期</span>
|
||||
<span class="cfg-static">
|
||||
{{ statusText }}
|
||||
<Tag v-if="form.draftVersion" color="warning" size="small">
|
||||
待发布草稿 v{{ form.draftVersion }}
|
||||
</Tag>
|
||||
</span>
|
||||
<span class="cfg-actions">
|
||||
<Button v-if="form.status === FORM_STATUS.PUBLISHED || form.status === FORM_STATUS.DISABLED"
|
||||
size="small" @click="toggleLifecycle">
|
||||
{{ form.status === FORM_STATUS.PUBLISHED ? "停用" : "恢复" }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">面向人群</span>
|
||||
<RadioGroup v-model="model.audience">
|
||||
<Radio v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Radio>
|
||||
</RadioGroup>
|
||||
<span class="cfg-note">决定数据范围矩阵落在哪一列(契约 §8)</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">限次</span>
|
||||
<RadioGroup v-model="model.fillLimit">
|
||||
<Radio :label="0">不限次数</Radio>
|
||||
<Radio :label="1">每人限填一次</Radio>
|
||||
</RadioGroup>
|
||||
<span class="cfg-note">
|
||||
「每人」按提交主体判:管理员代填指定了被代填人时按那个人限,否则按提交人限;
|
||||
软删答卷会释放名额(契约 §5)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">截止时间</span>
|
||||
<DatePicker v-model="model.deadline" type="date" format="yyyy-MM-dd" placeholder="不限"
|
||||
clearable style="width: 180px" />
|
||||
<span class="cfg-note">只选日期时后端补 23:59:59;过期后填报页只读、提交接口拒绝</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">报名号</span>
|
||||
<i-switch v-model="model.answerNoEnabled" :true-value="1" :false-value="0" />
|
||||
<span class="cfg-note">
|
||||
开启后提交生成 <code>{code}-{YYYYMMDD}-{当日序号}</code>(要求 code ≤49 位)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">分组标签</span>
|
||||
<Input v-model="model.groupTag" :maxlength="64" placeholder="可空,用于列表筛选归类" style="width: 220px" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="cfg-status">
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">状态定义</span>
|
||||
<span class="cfg-note">
|
||||
状态是标记不是流转(无流转规则、无会签);默认「待处理/已处理」,
|
||||
<Button size="small" type="text" @click="statusDefs = []">回默认</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="(d, i) in statusDefs" :key="d.code" class="status-row"
|
||||
:class="{ dropped: d.is_deleted }">
|
||||
<code class="status-code">{{ d.code }}</code>
|
||||
<Input v-model="d.label" size="small" :maxlength="128" style="width: 160px" />
|
||||
<span class="status-order">
|
||||
<Button size="small" type="text" icon="ios-arrow-up" :disabled="i === 0"
|
||||
@click="moveStatus(i, -1)" />
|
||||
<Button size="small" type="text" icon="ios-arrow-down"
|
||||
:disabled="i === statusDefs.length - 1" @click="moveStatus(i, 1)" />
|
||||
</span>
|
||||
<Checkbox v-model="d.is_deleted">废弃</Checkbox>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<Input v-model="newStatus.code" size="small" placeholder="code" style="width: 120px"
|
||||
:maxlength="64" />
|
||||
<Input v-model="newStatus.label" size="small" placeholder="名称" style="width: 160px"
|
||||
:maxlength="128" />
|
||||
<Button size="small" icon="md-add" @click="addStatus">新增状态</Button>
|
||||
</div>
|
||||
<div class="cfg-note">
|
||||
已废弃的状态历史答卷仍显示其名(V13),只是新数据不再给;code 一经使用不改,
|
||||
改 code 等于改写历史答卷的状态名。名称长度 ≤{{ MAX_NAME_LEN }} 与后端同口径。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="submit">保存配置</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cfg-label {
|
||||
width: 78px;
|
||||
color: #515a6e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cfg-static {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cfg-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.cfg-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cfg-status {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-row.dropped .status-code,
|
||||
.status-row.dropped input {
|
||||
color: #c5c8ce;
|
||||
}
|
||||
.status-code {
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
min-width: 90px;
|
||||
text-align: center;
|
||||
}
|
||||
.status-order {
|
||||
display: flex;
|
||||
}
|
||||
.mb10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -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) {
|
||||
|
||||
+46
-1
@@ -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: "/" },
|
||||
];
|
||||
|
||||
+35
-3
@@ -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<string, any> | null,
|
||||
/**
|
||||
* /api/base/GetUserMod 的<b>原始模块树</b>(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<string, any> | null) {
|
||||
this.user = user || null;
|
||||
},
|
||||
setModules(modules: IModule[] | null) {
|
||||
this.modules = modules || [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
/** 提交响应(契约 §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<string, any>;
|
||||
}
|
||||
|
||||
/** 上传文件响应(契约 §3:{id, fileName, key}) */
|
||||
export interface FileView {
|
||||
id: number;
|
||||
fileName: string;
|
||||
key: string;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 详情:行 + 钉住版本内联展开后的 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<string, string> = {
|
||||
ALL: "全部",
|
||||
TEACHING_CLASS: "任教班级",
|
||||
SELF_SUBMITTED: "仅本人提交",
|
||||
ABOUT_ME: "关于我"
|
||||
};
|
||||
|
||||
/** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */
|
||||
export function scopeLabel(name: string): string {
|
||||
return DATA_SCOPE_LABELS[name] ?? name;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
text: "单行文本",
|
||||
textarea: "多行文本",
|
||||
number: "数字",
|
||||
date: "日期",
|
||||
select: "下拉选择",
|
||||
radio: "单选",
|
||||
checkbox: "多选",
|
||||
multiselect: "下拉多选",
|
||||
phone: "手机号",
|
||||
image: "图片",
|
||||
upload: "附件",
|
||||
address: "地址(二期)",
|
||||
user: "选人(二期)",
|
||||
users: "多选人(二期)"
|
||||
};
|
||||
|
||||
/** 值形状说明(契约 §3 的表):属性面板脚注用,让编辑的人知道答卷里存的是什么 */
|
||||
export const VALUE_SHAPE_HINT: Record<string, string> = {
|
||||
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);
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>).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<string, unknown> | null | undefined,
|
||||
opts: { maskPhone?: boolean } = {}): string {
|
||||
const has = isPlainObject(values) && Object.prototype.hasOwnProperty.call(values, key);
|
||||
const value = has ? (values as Record<string, unknown>)[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<string, unknown>).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<string, DesignField>();
|
||||
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<string, number>();
|
||||
const filledCount = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const values = isPlainObject(row.values) ? (row.values as Record<string, unknown>) : {};
|
||||
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)];
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string>, 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<string> {
|
||||
const set = new Set<string>();
|
||||
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<string> {
|
||||
const counts = new Map<string, number>();
|
||||
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、同字段内唯一,形态 `<prefix>_<n>`。
|
||||
*
|
||||
* 为什么不用 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<string, string> = {
|
||||
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<string>, 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<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const prop of ALLOWED_FIELD_PROPS) {
|
||||
const v = (f as unknown as Record<string, unknown>)[prop];
|
||||
if (isEmptyValue(v)) continue;
|
||||
out[prop] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 序列化分组(group 属性白名单锁 key/label/fields,别的一律不落——P17) */
|
||||
export function pickGroup(g: DesignGroup): Record<string, unknown> {
|
||||
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<string, unknown>[] } {
|
||||
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<string, unknown>)[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<string, string[] | null> {
|
||||
const out: Record<string, string[] | null> = {};
|
||||
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<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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<string, unknown>;
|
||||
|
||||
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<string>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).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>): 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>): 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<string>();
|
||||
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<string>(PHASE1_TYPES);
|
||||
@@ -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<EmbedMessage, "proto" | "v">, 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);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 数据面页面的"选哪张表单"共用件(数据列表 / 统计页两处同形,快应用上下文第三种候选来源)。
|
||||
*
|
||||
* 两条来路都是<b>合规</b>的,区别只在账号被授权了哪一行模块:
|
||||
* - 中心上下文:`/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<FormCandidate[]>([]);
|
||||
const candidateError = ref("");
|
||||
const current = ref<CurrentForm | null>(null);
|
||||
const formUuid = ref("");
|
||||
|
||||
async function loadCurrent(uuid: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
formUuid.value = uuid;
|
||||
await loadCurrent(uuid);
|
||||
}
|
||||
|
||||
/** 按 code 直达(清单不可用时的口子):成功返回选中的 uuid */
|
||||
async function pickByCode(code: string): Promise<string | null> {
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 按钮级/页面级权限的**前端呈现**判定(方案 §6.1 第②层、§6.2 权限锚点、V26 的前端半句)。
|
||||
*
|
||||
* 输入永远是 `GetUserMod` 返回的<b>原始模块树</b>(含 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 的中心模块行表里<b>没有导出行</b>,后端 T4 的导出端点
|
||||
* 按 `/admin/data` 页面行把关,所以前端同口径按页面行判——不自造一个中心侧没有的锚点。
|
||||
* - `app`:快应用上下文。方案 §6.2 的快应用典型配法里"导出"是 <b>path 空的按钮级锚点行</b>,
|
||||
* 授权了才在树里出现;拿不到该锚点就不画按钮(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 第三半句的前端侧口径):
|
||||
* 学生身份只见"填报"路由 = 导航树里只有填报行——这是模块授权的直接结果,
|
||||
* 前端<b>不再自己按角色过滤一遍</b>(任务书 T8:范围以后端为准,前端不自行过滤)。
|
||||
* 本函数只做"树里有几项可跳转"的取证辅助,供断言用。
|
||||
*/
|
||||
export function navigableCount(tree: IModule[] | null | undefined): number {
|
||||
return authorizedPaths(tree).length;
|
||||
}
|
||||
+251
-18
@@ -1,39 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
/**
|
||||
* 快应用上下文 /app/:appCode/* 占位(T5)。
|
||||
* 快应用上下文 /app/:appCode/(T8 落地,替换 T5 占位)。
|
||||
*
|
||||
* 形态(方案 §6.2 纯 A 形态、手册 §2.1):快应用没有独立前端,UserBase 应用行 pc_url 直接指向
|
||||
* 本路由,中心按<b>那个应用</b>的模块树渲染轻导航/直落单表单——取树走 api/module/base.ts 的
|
||||
* getUserMod(appCode)(参数名按 P2 定稿 appCode)。
|
||||
* 形态(方案 §6.2 纯 A 形态 + 任务书 T8"快应用上下文"):
|
||||
* 快应用 = UserBase 真实应用行 + 无自有后端 + pc_url → 本路由。
|
||||
* - **模块树**:经 `/api/base/GetUserMod?appCode=<目标应用>` 取**那个应用**的树。H4 未上线时
|
||||
* 后端返回空树 + warn(T1 已验收的兜底,P3 裁决)——本页照此显示空态,**绝不回落本应用菜单**。
|
||||
* - **范围矩阵**:受限身份可见集**以后端为准**,本页不自行按角色过滤(任务书 T8 红线)——
|
||||
* 树里有什么就是什么,页面只做"单表单直落 vs 应用聚合轻导航"的形态分派。
|
||||
* - **单表单直落无导航**:树里只有一个可跳转填报行(`/f/{code}`)→ 直接渲染填报页,无轻导航。
|
||||
* - **应用聚合带轻导航**:树里有多个可跳转行 → 顶部一排轻导航(按 path 分派),内容区渲染对应视图。
|
||||
*
|
||||
* P3 裁决的口径在本页成立:<b>H4 未上线时该请求返回空树,页面就显示空态,绝不回落本应用菜单</b>
|
||||
* (回落会把「要 A 应用的菜单」伪装成「A 应用就这些」)。树渲染与范围矩阵在 T8 落地。
|
||||
* V26 三半句的前端侧(模块把关):
|
||||
* ① 无导出授权者不渲染导出按钮 → `canExport(tree, "app")`,其中 **tree 是本页拉到的那棵应用树**,
|
||||
* 经 `modules` prop 下传给内嵌的 Data.vue(P76:中心会话树里没有快应用的"导出"锚点行,
|
||||
* 拿它判锚点永远判不中——fail-closed 方向,有授权也不画按钮);
|
||||
* ② 导航不渲染 path 空节点 → 树构建走 `buildNavTree`(menuNav 已跳过锚点行,本页复验);
|
||||
* ③ 学生身份只见填报路由 → 树里只有 `/f/{code}` 行时,导航自然只有填报项(后端授权的直接结果,
|
||||
* 前端不再自己按角色过滤)。
|
||||
*
|
||||
* path → 视图分派(快应用模块行的 path 形态,方案 §6.2 表):
|
||||
* `/f/{code}` → 填报(渲染单表单,或跳中心填报页)
|
||||
* `/app/{appCode}/data` → 数据管理(复用 Data.vue,codes 来自树里的填报行)
|
||||
* `/app/{appCode}/stats`→ 统计(复用 Stats.vue)
|
||||
* 其余(含导入这类 path 空锚点)→ 不渲染为可跳转项(menuNav 已过滤 path 空)
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { baseApi } from "@/api/module/base";
|
||||
import { buildNavTree, flattenNav, type NavNode } from "@/utils/menuNav";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import { fillCodesOfModules } from "@/utils/modulePerms";
|
||||
import Data from "@/views/admin/Data.vue";
|
||||
import Stats from "@/views/admin/Stats.vue";
|
||||
import type { IModule } from "@/types/Basics";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const appCode = computed(() => String(route.params.appCode ?? ""));
|
||||
const view = computed(() => String(route.params.view ?? ""));
|
||||
/** 子视图段(/app/:appCode/:view):空=由树决定单表单直落或落默认聚合视图 */
|
||||
const viewParam = computed(() => String(route.params.view ?? ""));
|
||||
|
||||
const rawTree = ref<IModule[]>([]);
|
||||
const navTree = ref<NavNode[]>([]);
|
||||
const loading = ref(false);
|
||||
const errorText = ref("");
|
||||
const emptyTree = ref(false);
|
||||
|
||||
/** 树里的填报目标 code(数据/统计视图靠它 resolve 出 uuid——快应用不授权定义管理行,§3.4) */
|
||||
const fillCodes = computed(() => fillCodesOfModules(rawTree.value));
|
||||
|
||||
/** 可跳转导航项(path 空锚点已被 menuNav 跳过——V26 第二半句的复验点) */
|
||||
const navItems = computed(() => flattenNav(navTree.value));
|
||||
const isSingleForm = computed(() => navItems.value.length === 1 && /^\/f\//.test(navItems.value[0]?.page || ""));
|
||||
|
||||
/** 当前内容视图:'fill' | 'data' | 'stats' | 'unknown' */
|
||||
const activeView = computed<"fill" | "data" | "stats" | "unknown">(() => {
|
||||
const v = viewParam.value.trim();
|
||||
if (!v) return isSingleForm.value ? "fill" : navItems.value.length ? "data" : "unknown";
|
||||
if (v === "data") return "data";
|
||||
if (v === "stats") return "stats";
|
||||
if (v === "fill") return "fill";
|
||||
// 模块行里配置的完整 path 段(如 /app/honor/data 的 "data" 已覆盖;未知段按 unknown 呈现)
|
||||
return "unknown";
|
||||
});
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
if (activeView.value === "data") return "数据查看";
|
||||
if (activeView.value === "stats") return "统计";
|
||||
if (activeView.value === "fill") return "填报";
|
||||
return "应用表单";
|
||||
});
|
||||
|
||||
async function loadTree() {
|
||||
if (!appCode.value) {
|
||||
errorText.value = "地址里缺少 appCode";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
errorText.value = "";
|
||||
emptyTree.value = false;
|
||||
try {
|
||||
// 目标应用的模块树:H4 未上线时后端返回空树(P3 口径),这里如实显示空态
|
||||
const tree: IModule[] = (await baseApi.getUserMod(appCode.value).send()) || [];
|
||||
rawTree.value = tree;
|
||||
// 导航树:path 空节点(权限锚点)跳过(menuNav 规则①,V26 第二半句)
|
||||
navTree.value = buildNavTree(tree, resolveMenuPage);
|
||||
emptyTree.value = navTree.value.length === 0;
|
||||
} catch (e: any) {
|
||||
rawTree.value = [];
|
||||
navTree.value = [];
|
||||
errorText.value = e?.message || "目标应用的模块树取不到";
|
||||
emptyTree.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 轻导航点击:按模块行的 path 分派到本页的内容视图段 */
|
||||
function gotoNav(item: { page: string }) {
|
||||
const p = item.page;
|
||||
if (/^\/f\//.test(p)) {
|
||||
router.push(p);
|
||||
return;
|
||||
}
|
||||
const m = /^\/app\/[^/]+\/(.+)$/.exec(p);
|
||||
const seg = m ? m[1] : p.replace(/^\//, "");
|
||||
router.push(`/app/${appCode.value}/${seg}`);
|
||||
}
|
||||
|
||||
/** 树里第一个可跳转项(单表单直落/默认落点用) */
|
||||
function firstNavPath(): string {
|
||||
return navItems.value[0]?.page || "";
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTree();
|
||||
// 单表单直落:树里只有一个填报行且当前没带子视图段 → 直接把内容区渲染成该表单的填报
|
||||
// (不跳转,避免把用户从 /app/{appCode}/ 弹到别处;用户看着地址栏仍是应用入口)
|
||||
if (!viewParam.value && isSingleForm.value) {
|
||||
// 保持在本页渲染填报(activeView 已算成 fill),无需额外动作
|
||||
}
|
||||
});
|
||||
|
||||
watch(appCode, () => loadTree());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<h4 class="subtitle">快应用上下文</h4>
|
||||
<Alert show-icon type="info">
|
||||
<code>/app/:appCode/*</code> 路由骨架占位;目标应用模块树渲染、单表单直落与应用聚合轻导航在 T8 落地。
|
||||
<div class="app-head">
|
||||
<span class="app-tag">快应用</span>
|
||||
<code class="app-code">{{ appCode || "(空)" }}</code>
|
||||
<span class="toolbar-note">模块树按目标应用取(GetUserMod?appCode=),范围以后端为准</span>
|
||||
<Button icon="md-refresh" size="small" @click="loadTree">刷新</Button>
|
||||
</div>
|
||||
|
||||
<Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert>
|
||||
|
||||
<Alert v-else-if="emptyTree" show-icon type="warning" class="region-tip">
|
||||
「{{ appCode }}」这个应用当前没有可进入的模块行。
|
||||
<br />两种可能:① 你的身份没有被授权该应用的任何模块行(受限身份未见集由后端决定);
|
||||
② 平台侧尚未上线目标应用的模块树查询(UserBase 的 appCode 参数,H4)——届时本接口返回空树,
|
||||
<b>本页刻意不回落表单中心自己的菜单</b>(回落会把「要 A 应用的菜单」伪装成「A 应用就这些」)。
|
||||
</Alert>
|
||||
<p class="app-meta">
|
||||
appCode = <b>{{ appCode || "(空)" }}</b>
|
||||
<template v-if="view">|view = <b>{{ view }}</b></template>
|
||||
|
||||
<template v-else>
|
||||
<!-- 应用聚合:多于一个可跳转行时给轻导航(单表单直落时不出导航) -->
|
||||
<div v-if="!isSingleForm && navItems.length" class="light-nav">
|
||||
<span class="nav-live">
|
||||
<a v-for="item in navItems" :key="item.id" href="javascript:;"
|
||||
:class="['nav-item', { active: route.path === item.page }]" @click="gotoNav(item)">
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</span>
|
||||
<span class="toolbar-note">path 空的权限锚点行(如"导出")不进导航</span>
|
||||
</div>
|
||||
|
||||
<h5 class="section-title">{{ currentTitle }}</h5>
|
||||
|
||||
<!-- 数据/统计视图复用中心管理端组件:定义管理权不在快应用,codes 只来自模块树的填报行;
|
||||
embedded 让它只渲染本体,避免与本页的白卡/内边距叠成两层;
|
||||
modules 把**本页刚拉到的那棵应用树**传进去——导出按钮的锚点判定吃它,不吃中心会话树(P76) -->
|
||||
<Data v-if="activeView === 'data'" context="app" :app-code="appCode" :codes="fillCodes"
|
||||
:modules="rawTree" embedded />
|
||||
<Stats v-else-if="activeView === 'stats'" context="app" :app-code="appCode" :codes="fillCodes" embedded />
|
||||
<template v-else-if="activeView === 'fill'">
|
||||
<Alert show-icon type="info">
|
||||
单表单直落:该应用只授权了一个填报行,内容区直接渲染填报,不再叠一层导航。
|
||||
<br />填报目标:<code>{{ firstNavPath() || "(树里没有 /f/{code} 行)" }}</code>
|
||||
</Alert>
|
||||
<div class="fill-mount">
|
||||
<div class="fill-hint">
|
||||
直接进入填报页:
|
||||
<a href="javascript:;" @click="firstNavPath() && router.push(firstNavPath())">
|
||||
{{ firstNavPath() || "—" }}
|
||||
</a>
|
||||
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Alert v-else show-icon type="warning">
|
||||
该应用模块行里的这一段路径没有对应视图(本页只认识 data / stats / fill 三种,
|
||||
以及 path 形如 /f/{code} 的填报行)。请核对平台侧的模块行配置。
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<p class="toolbar-note foot-note">
|
||||
V26 前端侧三半句:①无导出授权不画导出按钮(快应用按 path 空的"导出"锚点行判)——
|
||||
导出按钮在数据视图内按该规则显隐;②导航不渲染 path 空节点;③学生身份只见填报路由(树里授权的直接结果)。
|
||||
接口侧的拒绝是 T4 已验的另一半,前端藏按钮不等于授权。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-meta {
|
||||
margin: 16px 0 0;
|
||||
.app-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.app-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
background: #2d8cf0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.app-code {
|
||||
color: #17233d;
|
||||
}
|
||||
.toolbar-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.region-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.light-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-live {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.nav-item {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.nav-item:hover {
|
||||
color: #2d8cf0;
|
||||
background: #f0f6ff;
|
||||
}
|
||||
.nav-item.active {
|
||||
color: #2d8cf0;
|
||||
background: #f0f6ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.fill-mount {
|
||||
padding: 16px 0;
|
||||
}
|
||||
.fill-hint {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.foot-note {
|
||||
display: block;
|
||||
margin-top: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
+507
-20
@@ -1,30 +1,394 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
/**
|
||||
* 填报页 /f/:code 占位(T5)。
|
||||
* 本体(code 寻址 + resolve、mode 闭集、控件渲染与校验、提交与幂等、ResizeObserver 高度上报)在 T7 落地。
|
||||
* 本轮只固定两件事:①路由形态与参数名;②self != top 的剥壳态可被肉眼确认(壳由 App.vue 判,
|
||||
* 这里只把它显示出来,方便冒烟时区分直航/嵌入两种形态——方案 §3.3 同一 URL 的两种形态)。
|
||||
* 填报页 /f/:code(T7 落地,替换 T5 占位)。
|
||||
*
|
||||
* 契约纪律:
|
||||
* 1. mode 闭集(page/embed),显式优先,self!=top 仅作未传时自动兜底(契约 §7);
|
||||
* 2. 身份一律 CAS 会话自取,任何身份注入被禁止(V17);
|
||||
* 3. 提交带客户端幂等键(uuid),命中返回 200+原 answerId(契约 §5);
|
||||
* 4. 值渲染默认转义,全仓禁 v-html(方案 §2.3 红线);
|
||||
* 5. 三态空值与已删字段降级(契约 §3)。
|
||||
*/
|
||||
import { computed, h, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { fieldWidget } from "@/components/fields";
|
||||
import type { DesignField } from "@/types/formSchema";
|
||||
import type { ResolvedForm, MyAnswerItem } from "@/types/answer";
|
||||
import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer } from "@/api/module/fill";
|
||||
import { startHeightReporting, reportSubmitResult, reportNeedLogin } from "@/utils/embed";
|
||||
import Utils from "@/utils";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// ==================== mode 闭集(契约 §7) ====================
|
||||
|
||||
const MODE_WHITELIST = ["page", "embed"] as const;
|
||||
type FillMode = typeof MODE_WHITELIST[number];
|
||||
|
||||
const explicitMode = computed(() => {
|
||||
const raw = String(route.query.mode ?? "").trim().toLowerCase();
|
||||
if (MODE_WHITELIST.includes(raw as any)) return raw as FillMode;
|
||||
if (raw) console.warn(`[T7] mode 参数 "${raw}" 不在白名单 ${MODE_WHITELIST.join("/")},已拒绝(V36)`);
|
||||
return null;
|
||||
});
|
||||
|
||||
const isEmbedded = computed(() => {
|
||||
if (explicitMode.value) return explicitMode.value === "embed";
|
||||
return window.self !== window.top;
|
||||
});
|
||||
|
||||
// ==================== 表单解析 ====================
|
||||
|
||||
const code = computed(() => String(route.params.code ?? ""));
|
||||
const embedded = window.self !== window.top;
|
||||
const appCode = computed(() => String(route.query.appCode ?? ""));
|
||||
const form = ref<ResolvedForm | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function loadForm() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
form.value = await resolveForm(code.value, appCode.value || undefined);
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || "表单加载失败";
|
||||
if (e?.code === 4) {
|
||||
if (isEmbedded.value) {
|
||||
reportNeedLogin();
|
||||
} else {
|
||||
window.location.href = "/cas/login?service=" + encodeURIComponent(window.location.href);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 字段渲染与校验 ====================
|
||||
|
||||
const formValues = ref<Record<string, any>>({});
|
||||
const formErrors = ref<Record<string, string>>({});
|
||||
|
||||
function getFieldWidget(type: string) {
|
||||
return fieldWidget(type, "pc");
|
||||
}
|
||||
|
||||
function getFieldValue(key: string, defaultValue?: any) {
|
||||
const val = formValues.value[key];
|
||||
if (val === undefined || val === null || val === "") return defaultValue ?? "";
|
||||
return val;
|
||||
}
|
||||
|
||||
function setFieldValue(key: string, value: any) {
|
||||
formValues.value[key] = value;
|
||||
delete formErrors.value[key];
|
||||
}
|
||||
|
||||
function validateField(field: DesignField): string | null {
|
||||
const value = formValues.value[field.key];
|
||||
if (field.required && (value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0))) {
|
||||
return `${field.label} 为必填项`;
|
||||
}
|
||||
if (field.type === "text" || field.type === "textarea" || field.type === "phone") {
|
||||
if (typeof value === "string" && field.maxLength && value.length > field.maxLength) {
|
||||
return `${field.label} 不能超过 ${field.maxLength} 个字符`;
|
||||
}
|
||||
if (field.pattern && typeof value === "string" && value && !new RegExp(field.pattern).test(value)) {
|
||||
return `${field.label} 格式不正确`;
|
||||
}
|
||||
}
|
||||
if (field.type === "number" && typeof value === "number") {
|
||||
if (field.min !== undefined && value < field.min) return `${field.label} 不能小于 ${field.min}`;
|
||||
if (field.max !== undefined && value > field.max) return `${field.label} 不能大于 ${field.max}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!form.value?.groups) return true;
|
||||
const groups = form.value.groups as any[];
|
||||
for (const group of groups) {
|
||||
for (const field of group.fields || []) {
|
||||
const err = validateField(field);
|
||||
if (err) errors[field.key] = err;
|
||||
}
|
||||
}
|
||||
formErrors.value = errors;
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
// ==================== 提交 ====================
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitted = ref(false);
|
||||
const submitResult = ref<{ answerId: string; answerNo: string | null } | null>(null);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value || submitting.value) return;
|
||||
if (!validateAll()) {
|
||||
Message.error("请检查表单填写");
|
||||
return;
|
||||
}
|
||||
if (form.value.deadlineReached) {
|
||||
Message.error("已过截止时间,无法提交");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const idempotencyKey = Utils.uuid();
|
||||
const result = await submitAnswer({
|
||||
formCode: code.value,
|
||||
appCode: appCode.value || undefined,
|
||||
idempotencyKey,
|
||||
values: formValues.value
|
||||
});
|
||||
submitResult.value = { answerId: result.answerId, answerNo: result.answerNo };
|
||||
submitted.value = true;
|
||||
if (isEmbedded.value) {
|
||||
reportSubmitResult({ success: true, answerId: result.answerId, answerNo: result.answerNo || undefined });
|
||||
} else {
|
||||
const redirectUrl = String(route.query.redirect ?? "");
|
||||
if (redirectUrl) {
|
||||
const finalUrl = redirectUrl.replace("{answerId}", result.answerId);
|
||||
window.location.href = finalUrl;
|
||||
} else {
|
||||
Message.success("提交成功" + (result.answerNo ? `,报名号:${result.answerNo}` : ""));
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || "提交失败";
|
||||
Message.error(msg);
|
||||
if (isEmbedded.value) {
|
||||
reportSubmitResult({ success: false, error: msg });
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 我的提交段 ====================
|
||||
|
||||
const myAnswers = ref<MyAnswerItem[]>([]);
|
||||
const editingAnswer = ref<MyAnswerItem | null>(null);
|
||||
|
||||
async function loadMyAnswers() {
|
||||
try {
|
||||
myAnswers.value = await listMyAnswers(code.value, appCode.value || undefined);
|
||||
} catch (e: any) {
|
||||
console.error("加载我的提交失败:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditAnswer(answer: MyAnswerItem) {
|
||||
editingAnswer.value = answer;
|
||||
formValues.value = { ...answer.values };
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
async function handleUpdateAnswer() {
|
||||
if (!editingAnswer.value || submitting.value) return;
|
||||
if (!validateAll()) {
|
||||
Message.error("请检查表单填写");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await editMyAnswer(editingAnswer.value.answerId, formValues.value);
|
||||
Message.success("更新成功");
|
||||
editingAnswer.value = null;
|
||||
formValues.value = {};
|
||||
await loadMyAnswers();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "更新失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteAnswer(answer: MyAnswerItem) {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: "删除后可重新填写(软删释放限次名额),确定删除吗?",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteMyAnswer(answer.answerId);
|
||||
Message.success("删除成功");
|
||||
await loadMyAnswers();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "删除失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancelEdit() {
|
||||
editingAnswer.value = null;
|
||||
formValues.value = {};
|
||||
formErrors.value = {};
|
||||
}
|
||||
|
||||
// ==================== 三态空值与已删字段降级(契约 §3) ====================
|
||||
// Note: renderFieldValue is kept for future use in read-only display scenarios
|
||||
// const renderFieldValue = (field: DesignField, values: Record<string, any>): string => { ... }
|
||||
|
||||
// ==================== ResizeObserver 高度上报 ====================
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
let stopHeightReporting: (() => void) | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
loadForm();
|
||||
loadMyAnswers();
|
||||
if (isEmbedded.value && containerRef.value) {
|
||||
stopHeightReporting = startHeightReporting(containerRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (stopHeightReporting) stopHeightReporting();
|
||||
});
|
||||
|
||||
watch(code, () => {
|
||||
loadForm();
|
||||
loadMyAnswers();
|
||||
});
|
||||
|
||||
// ==================== 只读态判定 ====================
|
||||
|
||||
const isReadonly = computed(() => {
|
||||
if (!form.value) return false;
|
||||
if (form.value.deadlineReached) return true;
|
||||
if (form.value.fillLimit === 1 && myAnswers.value.length > 0) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const readonlyReason = computed(() => {
|
||||
if (!form.value) return "";
|
||||
if (form.value.deadlineReached) return `已过截止时间(${form.value.deadline})`;
|
||||
if (form.value.fillLimit === 1 && myAnswers.value.length > 0) return "您已提交过该表单(限填一次)";
|
||||
return "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 嵌入态不套 page-wrapper 骨架:剥壳后由页面自己铺满(T7 接高度上报) -->
|
||||
<div :class="embedded ? 'fill-plain' : 'page-wrapper yh-fill'">
|
||||
<div ref="containerRef" :class="isEmbedded ? 'fill-plain' : 'page-wrapper yh-fill'">
|
||||
<div class="main-container">
|
||||
<h4 class="subtitle">填报</h4>
|
||||
<Alert show-icon type="info">
|
||||
<code>/f/:code</code> 路由骨架占位,表单寻址与答卷提交在 T7 落地。
|
||||
</Alert>
|
||||
<p class="fill-meta">
|
||||
code = <b>{{ code || "(空)" }}</b>
|
||||
|形态 = <b>{{ embedded ? "页面块(self != top,已剥壳)" : "整页(直航,带壳)" }}</b>
|
||||
</p>
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<Spin fix>
|
||||
<Icon type="ios-loading" size=18 class="spin-icon-load"></Icon>
|
||||
<div>加载中...</div>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<Alert show-icon type="error">
|
||||
{{ error }}
|
||||
<template v-if="!isEmbedded" slot desc>
|
||||
<a href="/cas/login">重新登录</a>
|
||||
</template>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- 表单不存在/停用/从未发布 -->
|
||||
<div v-else-if="!form" class="empty-state">
|
||||
<Alert show-icon type="warning">
|
||||
表单不存在、已停用或从未发布
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- 表单内容 -->
|
||||
<div v-else class="form-content">
|
||||
<h2 class="form-title">{{ form.name }}</h2>
|
||||
|
||||
<!-- 只读态提示 -->
|
||||
<Alert v-if="isReadonly" show-icon type="info" class="readonly-tip">
|
||||
{{ readonlyReason }},当前为只读模式
|
||||
</Alert>
|
||||
|
||||
<!-- 提交成功态 -->
|
||||
<div v-if="submitted && submitResult" class="success-state">
|
||||
<Alert show-icon type="success">
|
||||
提交成功!
|
||||
<template v-if="submitResult.answerNo" slot desc>
|
||||
报名号:<b>{{ submitResult.answerNo }}</b>
|
||||
</template>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- 编辑态提示 -->
|
||||
<Alert v-if="editingAnswer" show-icon type="info" class="edit-tip">
|
||||
正在编辑您的提交({{ editingAnswer.submitTime }})
|
||||
<Button type="text" @click="handleCancelEdit">取消编辑</Button>
|
||||
</Alert>
|
||||
|
||||
<!-- 表单字段 -->
|
||||
<div v-if="!submitted && !isReadonly || editingAnswer" class="form-groups">
|
||||
<div v-for="group in (form.groups as any[])" :key="group.key" class="form-group">
|
||||
<h3 v-if="group.label" class="group-label">{{ group.label }}</h3>
|
||||
<div v-for="field in (group.fields || [])" :key="field.key" class="form-item">
|
||||
<label class="form-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<div class="form-control">
|
||||
<component :is="getFieldWidget(field.type)" :field="field"
|
||||
:options="field.options || []" :model-value="getFieldValue(field.key, field.defaultValue)"
|
||||
:readonly="isReadonly && !editingAnswer"
|
||||
@update:model-value="(val: any) => setFieldValue(field.key, val)" />
|
||||
<div v-if="formErrors[field.key]" class="form-error">{{ formErrors[field.key] }}</div>
|
||||
<div v-if="field.tip" class="form-tip">{{ field.tip }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div v-if="!isReadonly || editingAnswer" class="form-actions">
|
||||
<Button v-if="editingAnswer" type="primary" :loading="submitting" @click="handleUpdateAnswer">
|
||||
更新提交
|
||||
</Button>
|
||||
<Button v-else type="primary" :loading="submitting" :disabled="isReadonly"
|
||||
@click="handleSubmit">
|
||||
提交
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的提交段 -->
|
||||
<div v-if="!editingAnswer && myAnswers.length > 0" class="my-answers">
|
||||
<h3 class="section-title">我的提交</h3>
|
||||
<Table :columns="[{
|
||||
title: '提交时间',
|
||||
key: 'submitTime',
|
||||
width: 180
|
||||
}, {
|
||||
title: '报名号',
|
||||
key: 'answerNo'
|
||||
}, {
|
||||
title: '状态',
|
||||
key: 'statusLabel'
|
||||
}, {
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (_h: any, { row }: any) => {
|
||||
return h('div', { class: 'action-buttons' }, [
|
||||
h('Button', {
|
||||
props: { type: 'text', size: 'small' },
|
||||
on: { click: () => handleEditAnswer(row) }
|
||||
}, '编辑'),
|
||||
h('Button', {
|
||||
props: { type: 'text', size: 'small', color: 'error' },
|
||||
on: { click: () => handleDeleteAnswer(row) }
|
||||
}, '删除')
|
||||
]);
|
||||
}
|
||||
}]" :data="myAnswers" border></Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,8 +396,131 @@ const embedded = window.self !== window.top;
|
||||
<style scoped>
|
||||
.fill-plain {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
.fill-meta {
|
||||
margin: 16px 0 0;
|
||||
|
||||
.page-wrapper.yh-fill {
|
||||
min-height: 100vh;
|
||||
background: #f5f7f9;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
color: #17233d;
|
||||
}
|
||||
|
||||
.readonly-tip,
|
||||
.edit-tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-state {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-groups {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #17233d;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ed4014;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #ed4014;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.my-answers {
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e8eaec;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #17233d;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spin-icon-load {
|
||||
animation: ani-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ani-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+276
-2
@@ -1,7 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import PageStub from "@/components/PageStub.vue";
|
||||
/**
|
||||
* 我的填写 /my(T8 落地,替换 T5 占位)。
|
||||
*
|
||||
* 两视图 = 两个**独立入口**,不做 query 参数分支(方案 §6.3 原文:"做成两个视图不做一个接口加参数
|
||||
* (鉴权分支容易写漏)",V18):
|
||||
* - `/my/submit` = 我提交的(submitter = 会话用户)→ `GET /api/form/my/submit-list`;
|
||||
* - `/my/about` = 关于我的(subject = 会话用户,含管理员代填的那几条)→ `GET /api/form/my/about-list`。
|
||||
* 两者是**两个端点、两个谓词**,页面按路由分派;顶部 Tab 只是 UI 导航(切路由),不是接口参数。
|
||||
* 管理员代填后:这条答卷在**填表人的"我提交的"**里出现,同时在**被代填人的"关于我的"**里出现(V18)。
|
||||
*
|
||||
* 三条交互口径沿 T7 的"我的提交"段(不另造轮子):
|
||||
* 1. **编辑入口只在"我提交的"**:编辑=填表人的动作;"关于我的"整列只读
|
||||
* (被代填人能看不能改——改是填表人或管理员的动作,后端 myDetail 同口径)。
|
||||
* 删除同理只在"我提交的"出现(删的是自己交的那条)。
|
||||
* 2. **填表人无状态入口**:两个视图都不出现"改状态"(V14/V29 前端半句)——
|
||||
* 后端 FormMyAnswerController 里根本没有状态端点,前端也不画这个按钮。
|
||||
* 3. **编辑按钉住 version**:详情走 `GET /api/form/my/detail`(钉住版本 groups + 值文档),
|
||||
* 复用 T7/T8 同一个编辑弹层(mode="my"),按同版本校验提交。
|
||||
*
|
||||
* 身份一律会话推导(V17):请求里不带任何用户 id 参数。数据面身份只用 uuid(V5)。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
import { deleteMyAnswer, getMyAnswerDetail, listAnswersAboutMe, listMySubmitted } from "@/api/module/fill";
|
||||
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
|
||||
import AnswerValueView from "@/components/answer/AnswerValueView.vue";
|
||||
import { statusDisplay } from "@/utils/dataValue";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import type { AnswerDetail, MyAnswerViewItem } from "@/types/data";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useDefaultStore();
|
||||
|
||||
/** 当前视图:只由路由路径决定(两个独立入口,不看 query) */
|
||||
const view = computed<"submit" | "about">(() => (String(route.path).endsWith("/about") ? "about" : "submit"));
|
||||
|
||||
const VIEW_META = {
|
||||
submit: {
|
||||
title: "我提交的",
|
||||
endpoint: "GET /api/form/my/submit-list",
|
||||
predicate: "谓词:submitter_id = 会话用户",
|
||||
note: "这里是我交过的答卷(管理员代填的也在——提交人是我)。可以编辑/删除自己的答卷。"
|
||||
},
|
||||
about: {
|
||||
title: "关于我的",
|
||||
endpoint: "GET /api/form/my/about-list",
|
||||
predicate: "谓词:subject_id = 会话用户",
|
||||
note: "这些数据是关于我的(老师/管理员代填后指定的主体是我)。整列只读——我不是提交人,改/删是提交人或管理员的动作。"
|
||||
}
|
||||
} as const;
|
||||
|
||||
const meta = computed(() => VIEW_META[view.value]);
|
||||
|
||||
const regionRef = ref<HTMLElement | null>(null);
|
||||
const regionH = useElementHeight(regionRef);
|
||||
|
||||
const rows = ref<MyAnswerViewItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadedOnce = ref(false);
|
||||
const errorText = ref("");
|
||||
|
||||
/** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */
|
||||
const sessionUserId = computed(() => store.sessionUserId);
|
||||
|
||||
function isMine(row: MyAnswerViewItem): boolean {
|
||||
return sessionUserId.value !== null && row.submitterId === sessionUserId.value;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
errorText.value = "";
|
||||
try {
|
||||
rows.value = view.value === "about" ? await listAnswersAboutMe() : await listMySubmitted();
|
||||
loadedOnce.value = true;
|
||||
} catch (e: any) {
|
||||
rows.value = [];
|
||||
errorText.value = e?.message || "加载失败";
|
||||
Message.error(errorText.value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function switchView(target: "submit" | "about") {
|
||||
if (target === view.value) return;
|
||||
router.push({ path: target === "about" ? "/my/about" : "/my/submit" });
|
||||
}
|
||||
|
||||
// ==================== 详情 / 编辑 / 删除 ====================
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<AnswerDetail | null>(null);
|
||||
|
||||
async function openDetail(row: MyAnswerViewItem) {
|
||||
try {
|
||||
detail.value = await getMyAnswerDetail(row.answerId);
|
||||
detailOpen.value = true;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "答卷详情取不到");
|
||||
}
|
||||
}
|
||||
|
||||
const editDetail = ref<AnswerDetail | null>(null);
|
||||
|
||||
/** 编辑入口:只在"我提交的"视图 + 这条确实是我提交的(两条同时成立才画按钮) */
|
||||
async function openEdit(row: MyAnswerViewItem) {
|
||||
try {
|
||||
editDetail.value = await getMyAnswerDetail(row.answerId);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "答卷详情取不到");
|
||||
}
|
||||
}
|
||||
|
||||
function softDelete(row: MyAnswerViewItem) {
|
||||
Modal.confirm({
|
||||
title: "确认删除这条答卷?",
|
||||
content:
|
||||
"删除是软删(无物理删除):四条读出路径同时不可见,索引行同步删," +
|
||||
"并**释放限填名额**——删掉后你可以重新填一次(契约 §5)。",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteMyAnswer(row.answerId);
|
||||
Message.success("已删除");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "删除失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const tableColumns = computed(() => {
|
||||
const cols: any[] = [
|
||||
{
|
||||
title: "表单",
|
||||
key: "formName",
|
||||
minWidth: 200,
|
||||
render: (h2: any, p: any) =>
|
||||
h2("span", [
|
||||
p.row.formName || p.row.code,
|
||||
h2("span", { class: "form-code" }, `(${p.row.appCode}/${p.row.code})`)
|
||||
])
|
||||
},
|
||||
{ title: "答卷号/报名号", key: "answerNo", width: 180, render: (h2: any, p: any) => h2("span", p.row.answerNo || "—") },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 130,
|
||||
render: (h2: any, p: any) => h2("span", statusDisplay(p.row.status, p.row.statusLabel))
|
||||
},
|
||||
{ title: "钉住版本", key: "version", width: 100, render: (h2: any, p: any) => h2("span", `v${p.row.version}`) },
|
||||
{ title: "提交时间", key: "submitTime", width: 170 },
|
||||
{
|
||||
title: "提交人 / 被代填人",
|
||||
key: "identity",
|
||||
width: 190,
|
||||
render: (h2: any, p: any) =>
|
||||
h2(
|
||||
"span",
|
||||
`提交人 ${p.row.submitterId}${p.row.subjectId ? `|主体 ${p.row.subjectId}` : "(本人自填)"}`
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 操作列沿 P51 的恒定件:只在"我提交的"给编辑/删除;"关于我的"只给"详情"。
|
||||
// 两个视图都不出现"改状态"(填表人无此权限——V14,后端也无该端点)。
|
||||
const items: any[] = [{ key: "detail", label: "详情", on: (row: MyAnswerViewItem) => openDetail(row) }];
|
||||
if (view.value === "submit") {
|
||||
items.push({ key: "edit", label: "编辑", when: (row: MyAnswerViewItem) => isMine(row), on: (row: MyAnswerViewItem) => openEdit(row) });
|
||||
items.push({
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
danger: true,
|
||||
when: (row: MyAnswerViewItem) => isMine(row),
|
||||
on: (row: MyAnswerViewItem) => softDelete(row)
|
||||
});
|
||||
}
|
||||
cols.push(actionColumn(items, { mode: "text", maxInline: 2 }));
|
||||
return cols;
|
||||
});
|
||||
|
||||
watch(view, () => {
|
||||
loadedOnce.value = false;
|
||||
load();
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageStub title="我的填写" stage="T8" note="全员授权行(手册 §2.2):我提交的/关于我的两视图在 T8 落地,两个视图不做参数分支。" />
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="view-head">
|
||||
<RadioGroup :model-value="view" type="button" @on-change="(v: string | number) => switchView(String(v) as 'submit' | 'about')">
|
||||
<Radio label="submit">我提交的</Radio>
|
||||
<Radio label="about">关于我的</Radio>
|
||||
</RadioGroup>
|
||||
<Button icon="md-refresh" @click="load">刷新</Button>
|
||||
<span class="toolbar-note">
|
||||
{{ meta.endpoint }} | {{ meta.predicate }}(两个端点两个谓词,不做参数分支)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Alert show-icon type="info" class="region-tip">
|
||||
<b>{{ meta.title }}</b>:{{ meta.note }}
|
||||
<template v-if="view === 'about'">
|
||||
<br />本视图不渲染编辑/删除入口(填表人才能改自己提交的那条);也不渲染改状态(填表人在接口层即被拒)。
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<div class="table-region" ref="regionRef">
|
||||
<Table :columns="tableColumns" :data="rows" :height="regionH || undefined" :loading="loading"
|
||||
no-data-text="这里还没有数据" class="link-table" />
|
||||
</div>
|
||||
|
||||
<p class="toolbar-note foot-note">
|
||||
共 {{ rows.length }} 条(跨表单,不带值文档——要看值走详情,值文档与钉住版本的 schema 一起下发的,
|
||||
渲染与校验同源)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Modal v-model="detailOpen" :title="`答卷详情(${detail?.formName || detail?.code || ''})`" :width="720"
|
||||
@on-cancel="detailOpen = false">
|
||||
<template v-if="detail">
|
||||
<div class="meta-line">
|
||||
<span>答卷 <code>{{ detail.answerId }}</code></span>
|
||||
<span>钉住版本 v{{ detail.version }}</span>
|
||||
<span>状态 {{ statusDisplay(detail.status, detail.statusLabel) }}</span>
|
||||
</div>
|
||||
<AnswerValueView :groups="detail.groups" :values="detail.values" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<Button @click="detailOpen = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<AnswerEditModal v-if="editDetail" :detail="editDetail" mode="my" @close="editDetail = null" @saved="load" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.region-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-code {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.meta-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.meta-line code {
|
||||
color: #17233d;
|
||||
}
|
||||
.foot-note {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 答卷详情(T8,任务书"详情页"):中心上下文 `/admin/data/detail?answerId=<uuid>`,
|
||||
* 快应用上下文 `/app/{appCode}/data/detail?answerId=<uuid>`(P77——同一个组件、同一条 uuid 链,
|
||||
* 返回按地址段认路,不把用户弹出应用壳)。
|
||||
*
|
||||
* 三条纪律:
|
||||
* 1. **uuid 寻址**(V5):参数与页面文案里出现的答卷身份只有 32 位 uuid,库内自增 id 不在这条链上,
|
||||
* 也不在任何响应里(后端 ReadModels 的注释是同一条);
|
||||
* 2. **按钉住版本呈现**:`groups` 是这条答卷提交时那个版本的 schema(后端 detail 直接下发),
|
||||
* 所以升版后回看历史答卷不会看到"当年没有的字段",三态空值与已删字段由同一份呈现函数降级显示(V8);
|
||||
* 3. **改状态/编辑入口按管理权显隐**(V29 前端半句):判据是"授权模块集里有没有 `/admin/data` 这一行",
|
||||
* 与后端守卫同一棵树(`FormAdminGuard`),前端藏按钮不等于授权,接口侧仍会拒。
|
||||
* ——这一条在两种上下文里都按**中心会话树**判:后端 detail/list/export 走的仍是中心 `/admin/data` 行,
|
||||
* 快应用自己的"数据查看"行不参与接口把关(该口径缺口登记执行手册 §5,见 T9 报告)。
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { dataApi } from "@/api/module/data";
|
||||
import { resolveForm } from "@/api/module/fill";
|
||||
import AnswerValueView from "@/components/answer/AnswerValueView.vue";
|
||||
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { hasPage } from "@/utils/modulePerms";
|
||||
import { statusDisplay } from "@/utils/dataValue";
|
||||
import type { AnswerDetail } from "@/types/data";
|
||||
import type { StatusDefView } from "@/types/form";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useDefaultStore();
|
||||
|
||||
const answerId = computed(() => String(route.query.answerId || "").trim());
|
||||
const detail = ref<AnswerDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
/** 数据管理权(授权模块行判定;填表人身份进来时这里就是 false——V29 前端半句) */
|
||||
const canManage = computed(() => hasPage(store.modules, "/admin/data"));
|
||||
const liveStatusDefs = ref<StatusDefView[]>([]);
|
||||
const statusModal = ref<{ visible: boolean; target: string }>({ visible: false, target: "" });
|
||||
const editOpen = ref(false);
|
||||
|
||||
async function load() {
|
||||
if (!answerId.value) {
|
||||
error.value = "缺少 answerId(答卷 uuid)——数据面只认 uuid,地址形如 ?answerId=<32 位 uuid>";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
detail.value = await dataApi.detail(answerId.value).send();
|
||||
} catch (e: any) {
|
||||
// 不在范围内=FORBIDDEN(与列表同一构造器判:列表看不见的详情也打不开)
|
||||
error.value = e?.message || "答卷加载失败";
|
||||
detail.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态候选来自该表单的<b>当前发布版本</b>定义(detail 响应不含 statusDefs,这是 T4 已验收的形状)。
|
||||
* 取数走 resolve——它是契约 §1 里读接口的唯一入口,且不要求"表单设计"模块行,
|
||||
* 所以只授权数据管理的身份也能拿到;表单已停用/从未发布时 resolve 会拒,
|
||||
* 那时就不给改状态入口(宁可少一个动作,也不编一份假的状态表)。
|
||||
*/
|
||||
async function loadStatusDefs() {
|
||||
if (!detail.value) return;
|
||||
try {
|
||||
const resolved = await resolveForm(detail.value.code, detail.value.appCode);
|
||||
liveStatusDefs.value = (resolved.statusDefs || []).filter((d) => !d.is_deleted);
|
||||
} catch (e: any) {
|
||||
console.warn("[T8] 状态定义取不到(表单可能已停用/未发布):", e?.message || e);
|
||||
liveStatusDefs.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function back() {
|
||||
// 归属上下文由地址段给出(P77):/app/{appCode}/data/detail → 回该应用的数据视图;否则回中心数据管理
|
||||
const app = String(route.params.appCode || "");
|
||||
if (app) router.push({ path: `/app/${app}/data`, query: { form: detail.value?.formUuid } });
|
||||
else router.push({ path: "/admin/data", query: { form: detail.value?.formUuid } });
|
||||
}
|
||||
|
||||
function openStatus() {
|
||||
if (!liveStatusDefs.value.length) {
|
||||
Message.warning("这张表的状态定义取不到(可能表单定义已被清理),本轮不给出改状态入口");
|
||||
return;
|
||||
}
|
||||
statusModal.value = { visible: true, target: liveStatusDefs.value[0].code };
|
||||
}
|
||||
|
||||
async function submitStatus() {
|
||||
statusModal.value.visible = false;
|
||||
try {
|
||||
await dataApi.changeStatus(answerId.value, statusModal.value.target).send();
|
||||
Message.success("已改状态并写 status_log");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "改状态失败");
|
||||
}
|
||||
}
|
||||
|
||||
function softDelete() {
|
||||
Modal.confirm({
|
||||
title: "确认软删这条答卷?",
|
||||
content: "软删是唯一的删除形态(无物理删除接口):四条读出路径同时不可见,索引行同步删,限填名额释放。",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await dataApi.softDelete(answerId.value).send();
|
||||
Message.success("已软删");
|
||||
back();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "软删失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
await loadStatusDefs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="head-bar">
|
||||
<Button icon="ios-arrow-back" @click="back">返回列表</Button>
|
||||
<span v-if="detail" class="crumb">{{ detail.formName || detail.code }}</span>
|
||||
</div>
|
||||
|
||||
<Alert v-if="loading" show-icon type="info">加载中…</Alert>
|
||||
<Alert v-else-if="error" show-icon type="error">{{ error }}</Alert>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="meta-card">
|
||||
<div class="meta-row"><span class="meta-key">答卷 uuid</span><code>{{ detail.answerId }}</code></div>
|
||||
<div class="meta-row"><span class="meta-key">表单</span>
|
||||
<span>{{ detail.formName }}({{ detail.appCode }}/{{ detail.code }})</span>
|
||||
</div>
|
||||
<div class="meta-row"><span class="meta-key">钉住版本</span>
|
||||
<span>v{{ detail.version }}(按这一版渲染,与当前发布版无关)</span>
|
||||
</div>
|
||||
<div class="meta-row"><span class="meta-key">状态</span>
|
||||
<span>{{ statusDisplay(detail.status, detail.statusLabel) }}</span>
|
||||
</div>
|
||||
<div class="meta-row"><span class="meta-key">报名号</span><span>{{ detail.answerNo || "—" }}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">提交时间</span><span>{{ detail.submitTime }}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">提交人 / 被代填人</span>
|
||||
<span>{{ detail.submitterId }} / {{ detail.subjectId ?? "—(本人自填)" }}</span>
|
||||
</div>
|
||||
<div class="meta-row"><span class="meta-key">表单 uuid</span><code>{{ detail.formUuid }}</code></div>
|
||||
</div>
|
||||
|
||||
<div class="actions" v-if="canManage">
|
||||
<Button type="primary" @click="editOpen = true">编辑值</Button>
|
||||
<Button @click="openStatus">改状态</Button>
|
||||
<Button type="error" ghost @click="softDelete">软删</Button>
|
||||
<span class="tip-note">填表人身份看不到这一排入口(V29 前端半句);接口侧的拒绝是另一道,不冲突。</span>
|
||||
</div>
|
||||
<Alert v-else show-icon type="info" class="actions">
|
||||
当前身份没有数据管理模块行,本页只读(改状态/编辑/删除入口不渲染)。
|
||||
</Alert>
|
||||
|
||||
<h5 class="section-title">答卷值(钉住版本 v{{ detail.version }})</h5>
|
||||
<AnswerValueView :groups="detail.groups" :values="detail.values" />
|
||||
</template>
|
||||
|
||||
<Modal v-model="statusModal.visible" title="改状态" :width="420">
|
||||
<Form :label-width="90">
|
||||
<FormItem label="当前状态">
|
||||
<span>{{ detail ? statusDisplay(detail.status, detail.statusLabel) : "—" }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="目标状态">
|
||||
<Select v-model="statusModal.target">
|
||||
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<p class="tip-note">
|
||||
状态是标记不是流转:不校验从哪个状态到哪个状态、无条件分支(方案 §5.3 红线、V15);改动写 status_log。
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button @click="statusModal.visible = false">取消</Button>
|
||||
<Button type="primary" @click="submitStatus">确认</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<AnswerEditModal v-if="detail && editOpen" :detail="detail" mode="admin" @close="editOpen = false"
|
||||
@saved="load" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.crumb {
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
}
|
||||
.meta-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 6px 24px;
|
||||
padding: 12px 16px;
|
||||
background: #f8f8f9;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.meta-key {
|
||||
color: #808695;
|
||||
min-width: 130px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tip-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
}
|
||||
</style>
|
||||
+770
-6
@@ -1,11 +1,775 @@
|
||||
<script setup lang="ts">
|
||||
import PageStub from "@/components/PageStub.vue";
|
||||
/**
|
||||
* 数据列表 /admin/data(T8 落地,替换 T5 占位)。
|
||||
*
|
||||
* 四处分寸,改动前请先读:
|
||||
* 1. **翻页只有游标**:后端 keyset 响应没有 total、没有页码(方案 §5.5 铁律②),
|
||||
* 所以这里只有"触底加载下一页",`cursor`=上一页末行 answerId(uuid)(P39),不能跳页;
|
||||
* 2. **列 = 最新已发布 schema ∪ 值文档里出现过的历史 key**(方案 §5.2):
|
||||
* 删字段不清历史索引行,历史 key 在值里还在,列上降级显示"(已移除字段)xxx"(V8 前端半句)。
|
||||
* 筛选候选则**只认后端 `filterFields`**(当前发布版本 searchable 且有算子的字段)——
|
||||
* 历史已删字段的 key 发给后端会被 `rejectUnknownKeys` 拒(缺口记执行手册 §5 问题单,前端不私造筛选项);
|
||||
* 3. **可见列按 (归属应用, code) 记忆**(V40),localStorage 键见 `utils/dataValue.columnMemoryKey`;
|
||||
* 4. **数据范围以后端为准**:响应里的 `scopes` 只用来把"你现在看到的是哪个范围"讲清楚,
|
||||
* 前端不据此再过滤一遍(任务书 T8 红线)。
|
||||
*
|
||||
* 快应用上下文(AppContext)复用本组件:传 `context="app"` + `app-code` + `codes`
|
||||
* (候选来自该应用模块树的 `/f/{code}` 行,经 resolve 拿 uuid——定义管理权不在快应用上下文里,§3.4)
|
||||
* + `modules`(**同一棵原始模块树**,按钮级权限按它判,P76)。
|
||||
*/
|
||||
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal, Tooltip, Checkbox } from "view-ui-plus";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
import { useFormCandidates } from "@/utils/hook/useFormCandidates";
|
||||
import { dataApi, downloadBlob, exportBlob } from "@/api/module/data";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { canExport } from "@/utils/modulePerms";
|
||||
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
|
||||
import {
|
||||
cellText,
|
||||
clipText,
|
||||
columnMemoryKey,
|
||||
deriveColumns,
|
||||
loadColumnMemory,
|
||||
reconcileVisibleKeys,
|
||||
saveColumnMemory,
|
||||
statusDisplay,
|
||||
type DataCol
|
||||
} from "@/utils/dataValue";
|
||||
import { scopeLabel, type AnswerDetail, type AnswerRow, type FilterField, type FilterItem, type ListView } from "@/types/data";
|
||||
import type { StatusDefView } from "@/types/form";
|
||||
import type { DesignField } from "@/types/formSchema";
|
||||
import type { IModule } from "@/types/Basics";
|
||||
|
||||
/** 行视图 + 勾选态(`_checked` 是 view-ui-plus Table 的行内选中字段,见 withSelectionState) */
|
||||
type RowVM = AnswerRow & { _checked?: boolean };
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
context?: "center" | "app";
|
||||
/** app 上下文的归属应用(center 留空=form-center) */
|
||||
appCode?: string;
|
||||
/** app 上下文的候选 code(来自该应用模块树的 /f/{code} 行) */
|
||||
codes?: string[];
|
||||
/**
|
||||
* 按钮级权限判定的**那棵树**(P76):app 上下文必须由宿主传入目标应用的模块树
|
||||
* (GetUserMod?appCode= 的结果)——中心会话树里没有快应用的"导出"锚点行,
|
||||
* 拿它判锚点等于永远判不中(fail-closed 方向:有授权也不画按钮)。
|
||||
* center 上下文不传,仍按 `store.modules`(会话自己的树)判页面行。
|
||||
*/
|
||||
modules?: IModule[] | null;
|
||||
/**
|
||||
* 被宿主页面内嵌(快应用上下文 /app/:appCode/)时置 true:只渲染内容本体,
|
||||
* 不再套一层 `.page-wrapper > .main-container`——否则会出现双层白卡与双份内边距。
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}>(),
|
||||
{ context: "center", appCode: "", codes: () => [], modules: null, embedded: false }
|
||||
);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useDefaultStore();
|
||||
|
||||
const regionRef = ref<HTMLElement | null>(null);
|
||||
const regionH = useElementHeight(regionRef);
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const BATCH_LIMIT = 200;
|
||||
|
||||
// ==================== 表单候选与当前版本(与统计页共用 useFormCandidates) ====================
|
||||
|
||||
const {
|
||||
candidates,
|
||||
candidateError,
|
||||
current,
|
||||
formUuid,
|
||||
loadCandidates,
|
||||
selectForm: chooseForm,
|
||||
pickByCode
|
||||
} = useFormCandidates(() => props.context, () => props.appCode, () => props.codes);
|
||||
|
||||
const manualCode = ref("");
|
||||
|
||||
/** 定义清单不可用时的直达入口:仍然只经 resolve(契约 §1"resolve 是读接口唯一入口") */
|
||||
async function useManualCode() {
|
||||
const code = manualCode.value.trim();
|
||||
if (!code) {
|
||||
Message.warning("请先输入表单 code");
|
||||
return;
|
||||
}
|
||||
const uuid = await pickByCode(code);
|
||||
if (!uuid) {
|
||||
Message.error(candidateError.value || "按 code 解析失败");
|
||||
return;
|
||||
}
|
||||
manualCode.value = "";
|
||||
await selectForm(uuid);
|
||||
}
|
||||
|
||||
// ==================== 筛选 / 排序 / 游标 ====================
|
||||
|
||||
const filterFields = ref<FilterField[]>([]);
|
||||
const filterRows = ref<FilterItem[]>([]);
|
||||
const statusFilter = ref("");
|
||||
const order = ref<"desc" | "asc">("desc");
|
||||
const rows = ref<RowVM[]>([]);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const scopes = ref<string[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadedOnce = ref(false);
|
||||
|
||||
/** 已选行(跨已加载页累积;换表单/换筛选即清空) */
|
||||
const selected = ref<string[]>([]);
|
||||
|
||||
const liveStatusDefs = computed<StatusDefView[]>(() =>
|
||||
(current.value?.statusDefs || []).filter((d: StatusDefView) => !d.is_deleted)
|
||||
);
|
||||
|
||||
/** 生效范围文案(只展示,不过滤) */
|
||||
const scopeText = computed(() => (scopes.value.length ? scopes.value.map(scopeLabel).join("、") : "后端未回传"));
|
||||
|
||||
async function loadFilterFields(uuid: string): Promise<void> {
|
||||
try {
|
||||
filterFields.value = (await dataApi.filterFields(uuid).send()) || [];
|
||||
} catch (e: any) {
|
||||
filterFields.value = [];
|
||||
console.warn("[T8] 筛选候选取不到:", e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
function queryBody() {
|
||||
const filters = filterRows.value
|
||||
.filter((f) => f.key)
|
||||
.map((f) => ({
|
||||
key: f.key,
|
||||
value: f.value || undefined,
|
||||
from: f.from || undefined,
|
||||
to: f.to || undefined
|
||||
}));
|
||||
return {
|
||||
formUuid: formUuid.value,
|
||||
status: statusFilter.value || undefined,
|
||||
filters: filters.length ? filters : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/** 首屏/条件变更:重置游标从头取(keyset 没有"跳到第 N 页"这回事) */
|
||||
async function reload() {
|
||||
if (!formUuid.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: ListView = await dataApi
|
||||
.list({ ...queryBody(), cursor: null, order: order.value, size: PAGE_SIZE })
|
||||
.send();
|
||||
selected.value = [];
|
||||
rows.value = withSelectionState(res?.rows || []);
|
||||
nextCursor.value = res?.nextCursor ?? null;
|
||||
scopes.value = res?.scopes || [];
|
||||
loadedOnce.value = true;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "数据加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 触底加载:cursor=上一页末行 uuid,服务端按 uk 换库内 id 并同样过范围(P39:不可见即拒) */
|
||||
async function loadMore() {
|
||||
if (!nextCursor.value || loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: ListView = await dataApi
|
||||
.list({ ...queryBody(), cursor: nextCursor.value, order: order.value, size: PAGE_SIZE })
|
||||
.send();
|
||||
rows.value = withSelectionState([...rows.value, ...(res?.rows || [])]);
|
||||
nextCursor.value = res?.nextCursor ?? null;
|
||||
if (res?.scopes) scopes.value = res.scopes;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "下一页加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 行级勾选态回填(T9 观察项①落实):view-ui-plus 的 `data` 监听器会用 `makeObjBaseData` 重建
|
||||
* `objData`,而那里的 `_isChecked` **只认行上的 `_checked`**(点选只改 objData,不回写行),
|
||||
* 所以整组替换后已勾的行会弹回未选,而"已选 N 条"仍按 selected 累计——两处口径打架。
|
||||
* 判据统一放这一处:`selected`(onSelectionChange 维护)才是唯一事实源,rows 每次都据它带 `_checked`。
|
||||
*/
|
||||
function withSelectionState(list: AnswerRow[]): RowVM[] {
|
||||
return list.map((row) => ({ ...row, _checked: selected.value.includes(row.answerId) } as RowVM));
|
||||
}
|
||||
|
||||
// ==================== 可见列(V40) ====================
|
||||
|
||||
const allColumns = computed<DataCol[]>(() => deriveColumns(current.value?.fields || [], rows.value));
|
||||
const visibleKeys = ref<string[]>([]);
|
||||
|
||||
function applyMemory() {
|
||||
if (!current.value) {
|
||||
visibleKeys.value = [];
|
||||
return;
|
||||
}
|
||||
const key = columnMemoryKey(current.value.appCode, current.value.code);
|
||||
visibleKeys.value = reconcileVisibleKeys(allColumns.value, loadColumnMemory(key));
|
||||
}
|
||||
|
||||
const colPicker = ref(false);
|
||||
|
||||
function toggleColumn(colKey: string) {
|
||||
const next = visibleKeys.value.includes(colKey)
|
||||
? visibleKeys.value.filter((k) => k !== colKey)
|
||||
: [...visibleKeys.value, colKey];
|
||||
visibleKeys.value = next;
|
||||
if (current.value) saveColumnMemory(columnMemoryKey(current.value.appCode, current.value.code), next);
|
||||
}
|
||||
|
||||
watch(allColumns, applyMemory);
|
||||
|
||||
// ==================== 表格列定义 ====================
|
||||
|
||||
function renderCell(col: DataCol, values: Record<string, unknown>) {
|
||||
const text = cellText(col.field, col.key, values);
|
||||
const { shown, truncated, full } = clipText(text);
|
||||
const span = h("span", { class: col.removed ? "cell-removed" : "" }, shown);
|
||||
if (!truncated) return span;
|
||||
return h(Tooltip, { content: full, transfer: true }, { default: () => [span] });
|
||||
}
|
||||
|
||||
const tableColumns = computed(() => {
|
||||
const cols: any[] = [{ type: "selection", width: 44 }];
|
||||
cols.push({ title: "提交时间", key: "submitTime", width: 160 });
|
||||
cols.push({
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 130,
|
||||
render: (h2: any, p: any) => h2("span", statusDisplay(p.row.status, p.row.statusLabel))
|
||||
});
|
||||
for (const col of allColumns.value) {
|
||||
if (!visibleKeys.value.includes(col.key)) continue;
|
||||
cols.push({
|
||||
title: col.label,
|
||||
key: col.key,
|
||||
width: 180,
|
||||
render: (_h2: any, p: any) => renderCell(col, p.row.values)
|
||||
});
|
||||
}
|
||||
cols.push({
|
||||
title: "报名号",
|
||||
key: "answerNo",
|
||||
width: 150,
|
||||
render: (h2: any, p: any) => h2("span", p.row.answerNo || "—")
|
||||
});
|
||||
cols.push({
|
||||
title: "身份",
|
||||
key: "identity",
|
||||
width: 170,
|
||||
render: (h2: any, p: any) =>
|
||||
h2(Tooltip, {
|
||||
content: `submitter_id=${p.row.submitterId}${p.row.subjectId ? `,subject_id=${p.row.subjectId}(他人代填)` : ",无代填主体"}`,
|
||||
transfer: true
|
||||
}, {
|
||||
default: () => [h2("span", `提交人 ${p.row.submitterId}${p.row.subjectId ? `|主体 ${p.row.subjectId}` : ""}`)]
|
||||
})
|
||||
});
|
||||
cols.push(
|
||||
actionColumn(
|
||||
[
|
||||
{ key: "detail", label: "详情", on: (row: AnswerRow) => goDetail(row) },
|
||||
{ key: "edit", label: "编辑", on: (row: AnswerRow) => openEdit(row) },
|
||||
{ key: "status", label: "改状态", on: (row: AnswerRow) => openStatus(row) },
|
||||
{ key: "delete", label: "软删", danger: true, on: (row: AnswerRow) => softDeleteOne(row) }
|
||||
],
|
||||
{ mode: "text", maxInline: 2 }
|
||||
)
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
|
||||
/** 附件列的"N 个文件"由 cellText 统一给,这里不再另写一份(同一张表里两套口径迟早漂移) */
|
||||
|
||||
// ==================== 行操作 ====================
|
||||
|
||||
function goDetail(row: AnswerRow) {
|
||||
// P77:快应用上下文留在应用壳里看详情(/app/{appCode}/data/detail),中心上下文走 /admin/data/detail;
|
||||
// 两边都只带 uuid(V5),返回由详情页按 route.params.appCode 认路。
|
||||
const path = props.context === "app" ? `/app/${props.appCode}/data/detail` : "/admin/data/detail";
|
||||
router.push({ path, query: { answerId: row.answerId } });
|
||||
}
|
||||
|
||||
const editDetail = ref<AnswerDetail | null>(null);
|
||||
|
||||
async function openEdit(row: AnswerRow) {
|
||||
try {
|
||||
// 编辑弹层吃的是详情接口——它带的是该答卷**钉住版本**的 groups,不是当前发布版本
|
||||
editDetail.value = await dataApi.detail(row.answerId).send();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "答卷详情取不到");
|
||||
}
|
||||
}
|
||||
|
||||
/** 等值筛选项的候选值:选项类字段(select/radio/checkbox/multiselect)的已展开 options(契约 §2.1 内联) */
|
||||
function eqOptionsFor(key: string): Array<{ label: string; value: string }> {
|
||||
const field = (current.value?.fields || []).find((f: DesignField) => f.key === key);
|
||||
const options = field?.options;
|
||||
return Array.isArray(options) ? options : [];
|
||||
}
|
||||
|
||||
const statusModal = ref<{ visible: boolean; answerId: string; target: string; current: string }>({
|
||||
visible: false,
|
||||
answerId: "",
|
||||
target: "",
|
||||
current: ""
|
||||
});
|
||||
|
||||
function openStatus(row: AnswerRow) {
|
||||
if (!liveStatusDefs.value.length) {
|
||||
Message.warning("该表单没有可用状态定义");
|
||||
return;
|
||||
}
|
||||
statusModal.value = {
|
||||
visible: true,
|
||||
answerId: row.answerId,
|
||||
target: row.status || liveStatusDefs.value[0].code,
|
||||
current: statusDisplay(row.status, row.statusLabel)
|
||||
};
|
||||
}
|
||||
|
||||
async function submitStatus() {
|
||||
const { answerId, target } = statusModal.value;
|
||||
statusModal.value.visible = false;
|
||||
try {
|
||||
await dataApi.changeStatus(answerId, target).send();
|
||||
Message.success("已改状态并写 status_log");
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "改状态失败");
|
||||
}
|
||||
}
|
||||
|
||||
function softDeleteOne(row: AnswerRow) {
|
||||
Modal.confirm({
|
||||
title: "确认软删这条答卷?",
|
||||
content: "软删是唯一的删除形态(无物理删除接口):列表/详情/统计/导出都不再可见,索引行同步删,限填名额释放。",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await dataApi.softDelete(row.answerId).send();
|
||||
Message.success("已软删");
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "软删失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onSelectionChange(selection: RowVM[]) {
|
||||
selected.value = selection.map((r) => r.answerId);
|
||||
}
|
||||
|
||||
// ==================== 批量 ====================
|
||||
|
||||
function reportBatch(results: { answerId: string; success: boolean; msg: string | null }[], actionText: string) {
|
||||
const failed = results.filter((r) => !r.success);
|
||||
const okCount = results.length - failed.length;
|
||||
if (!failed.length) {
|
||||
Message.success(`${actionText} ${okCount} 条全部成功(后端逐条校验、逐条留痕)`);
|
||||
return;
|
||||
}
|
||||
Modal.warning({
|
||||
title: `${actionText}:成功 ${okCount} 条,失败 ${failed.length} 条`,
|
||||
content: failed
|
||||
.slice(0, 10)
|
||||
.map((f) => `${f.answerId.slice(0, 8)}…:${f.msg || "未知原因"}`)
|
||||
.join("\n") + (failed.length > 10 ? `\n…另有 ${failed.length - 10} 条` : ""),
|
||||
okText: "知道了"
|
||||
});
|
||||
}
|
||||
|
||||
function checkedBatch(ids: string[], actionText: string, run: () => Promise<any>) {
|
||||
if (!ids.length) {
|
||||
Message.warning("先勾选要处理的答卷");
|
||||
return;
|
||||
}
|
||||
if (ids.length > BATCH_LIMIT) {
|
||||
Message.warning(`批量单次上限 ${BATCH_LIMIT} 条(后端 P29),当前选了 ${ids.length} 条,请减少选择`);
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: `确认${actionText} ${ids.length} 条?`,
|
||||
content: `处理范围只覆盖${actionText}能覆盖到的行:不在你数据范围(当前=${scopeText.value})里的答卷会逐条被拒并点名原因,成功的部分照常留痕。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const results = await run();
|
||||
reportBatch(results || [], actionText);
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || `${actionText}失败`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function batchDelete() {
|
||||
checkedBatch(selected.value, "批量软删", () => dataApi.batchSoftDelete(selected.value).send());
|
||||
}
|
||||
|
||||
const batchStatusModal = ref(false);
|
||||
const batchStatusTarget = ref("");
|
||||
|
||||
function batchChangeStatus() {
|
||||
if (!selected.value.length) {
|
||||
Message.warning("先勾选要改状态的答卷");
|
||||
return;
|
||||
}
|
||||
if (!liveStatusDefs.value.length) {
|
||||
Message.warning("该表单没有可用状态定义");
|
||||
return;
|
||||
}
|
||||
batchStatusTarget.value = liveStatusDefs.value[0].code;
|
||||
batchStatusModal.value = true;
|
||||
}
|
||||
|
||||
async function submitBatchStatus() {
|
||||
const ids = selected.value;
|
||||
batchStatusModal.value = false;
|
||||
checkedBatch(ids, `批量改状态为「${liveStatusDefs.value.find((d) => d.code === batchStatusTarget.value)?.label}」`, () =>
|
||||
dataApi.batchChangeStatus(ids, batchStatusTarget.value).send()
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
/**
|
||||
* 权限判定用的树(P76):app 上下文=宿主编进来的**目标应用树**(快应用的"导出"锚点行在这里),
|
||||
* center 上下文=会话自己的树(按 `/admin/data` 页面行判,手册 §2.2 中心表没有导出行)。
|
||||
* 两种上下文各自吃自己的树,`canExport` 的纯函数判据不变。
|
||||
*/
|
||||
const permsTree = computed(() => (props.context === "app" ? props.modules : store.modules));
|
||||
const exportable = computed(() => canExport(permsTree.value, props.context === "app" ? "app" : "center"));
|
||||
const exporting = ref(false);
|
||||
|
||||
function askExport() {
|
||||
if (!formUuid.value) {
|
||||
Message.warning("先选择表单");
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: "导出当前筛选结果",
|
||||
content:
|
||||
`范围=单表单 + 当前筛选(表单:${current.value?.name || formUuid.value})。` +
|
||||
"行数口径是你这个数据范围(当前=" + scopeText.value + ")下符合筛选条件的**总数**,不是本页已加载的行数。" +
|
||||
"导出是同步流式下载,不排任务、不给假进度。",
|
||||
onOk: async () => {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const blob = await exportBlob(queryBody());
|
||||
downloadBlob(blob, `${current.value?.code || "form"}-answers.xlsx`);
|
||||
Message.success("已开始下载");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "导出失败");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 触底加载(keyset 的 UI 形态) ====================
|
||||
|
||||
let scrollTarget: HTMLElement | null = null;
|
||||
|
||||
function onScroll() {
|
||||
if (!scrollTarget || loading.value || !nextCursor.value) return;
|
||||
const { scrollTop, clientHeight, scrollHeight } = scrollTarget;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 40) loadMore();
|
||||
}
|
||||
|
||||
/** view-ui 定了 :height 后内部滚动在 .ivu-table-body,触底事件挂它而不是挂窗口 */
|
||||
async function bindBottomScroll() {
|
||||
await nextTick();
|
||||
scrollTarget?.removeEventListener("scroll", onScroll);
|
||||
scrollTarget = regionRef.value?.querySelector(".ivu-table-body") ?? null;
|
||||
scrollTarget?.addEventListener("scroll", onScroll);
|
||||
}
|
||||
|
||||
watch(rows, bindBottomScroll);
|
||||
onMounted(bindBottomScroll);
|
||||
onBeforeUnmount(() => scrollTarget?.removeEventListener("scroll", onScroll));
|
||||
|
||||
// ==================== 选择与初始化 ====================
|
||||
|
||||
async function selectForm(uuid: string) {
|
||||
formUuid.value = uuid;
|
||||
filterRows.value = [];
|
||||
statusFilter.value = "";
|
||||
rows.value = [];
|
||||
nextCursor.value = null;
|
||||
loadedOnce.value = false;
|
||||
await Promise.all([chooseForm(uuid), loadFilterFields(uuid)]);
|
||||
applyMemory();
|
||||
await reload();
|
||||
}
|
||||
|
||||
function addFilterRow() {
|
||||
filterRows.value = [...filterRows.value, { key: "", value: "", from: "", to: "" }];
|
||||
}
|
||||
|
||||
function removeFilterRow(index: number) {
|
||||
filterRows.value = filterRows.value.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function filterFieldOf(key: string): FilterField | undefined {
|
||||
return filterFields.value.find((f) => f.key === key);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadCandidates();
|
||||
const fromQuery = String(route.query.form || "");
|
||||
const picked = candidates.value.find((c: { formUuid: string }) => c.formUuid === fromQuery) || candidates.value[0];
|
||||
if (picked) await selectForm(picked.formUuid);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.context, props.appCode, props.codes.join(",")],
|
||||
() => init()
|
||||
);
|
||||
|
||||
onMounted(init);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageStub
|
||||
title="数据管理"
|
||||
stage="T8"
|
||||
note="两段式列表 + keyset(提交时间序)、可见列选择记忆、批量软删/改状态、按钉住版本编辑在 T8 落地;数据范围(scope)由后端裁决,前端只消费。"
|
||||
/>
|
||||
<div :class="embedded ? 'data-bare' : 'page-wrapper yh-fill'">
|
||||
<div :class="embedded ? '' : 'main-container'">
|
||||
<div class="toolbar">
|
||||
<Select v-model="formUuid" style="width: 260px" placeholder="选择表单" @on-change="selectForm">
|
||||
<Option v-for="c in candidates" :key="c.formUuid" :value="c.formUuid"
|
||||
:label="`${c.name}(${c.appCode}/${c.code})`" />
|
||||
</Select>
|
||||
<Input v-model="manualCode" search placeholder="按 code 直达" style="width: 200px" clearable
|
||||
@on-search="useManualCode" />
|
||||
<Select v-model="statusFilter" style="width: 140px" placeholder="状态" clearable @on-change="reload">
|
||||
<Option value="">全部状态</Option>
|
||||
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
|
||||
</Select>
|
||||
<Select v-model="order" style="width: 150px" @on-change="reload">
|
||||
<Option value="desc">最新在前</Option>
|
||||
<Option value="asc">最早在前</Option>
|
||||
</Select>
|
||||
<Button icon="md-refresh" @click="reload">刷新</Button>
|
||||
<Button icon="md-settings" @click="colPicker = true">列设置</Button>
|
||||
<span class="toolbar-note">
|
||||
已加载 {{ rows.length }} 行{{ nextCursor ? "(还有下一页,触底继续取)" : "(已到末尾)" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="filterFields.length || filterRows.length" class="filter-region">
|
||||
<div v-for="(row, index) in filterRows" :key="`f_${index}`" class="filter-row">
|
||||
<Select v-model="row.key" style="width: 190px" placeholder="筛选字段">
|
||||
<Option v-for="f in filterFields" :key="f.key" :value="f.key"
|
||||
:label="`${f.label}(${f.operator === 'EQ' ? '等值' : f.operator === 'RANGE' ? '区间' : '前缀'})`" />
|
||||
</Select>
|
||||
<template v-if="filterFieldOf(row.key)?.operator === 'EQ'">
|
||||
<Select v-model="row.value" style="width: 200px" placeholder="选择值" clearable>
|
||||
<Option v-for="opt in eqOptionsFor(row.key)" :key="opt.value" :value="opt.value"
|
||||
:label="opt.label" />
|
||||
</Select>
|
||||
</template>
|
||||
<template v-else-if="filterFieldOf(row.key)?.operator === 'RANGE'">
|
||||
<Input v-model="row.from" style="width: 160px"
|
||||
:placeholder="filterFieldOf(row.key)?.type === 'date' ? '起始日 YYYY-MM-DD' : '下界(含)'" />
|
||||
<Input v-model="row.to" style="width: 160px"
|
||||
:placeholder="filterFieldOf(row.key)?.type === 'date' ? '截止日 YYYY-MM-DD' : '上界(含)'" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Input v-model="row.value" style="width: 200px" placeholder="前缀文本" clearable />
|
||||
</template>
|
||||
<Button icon="md-close" @click="removeFilterRow(index)">删</Button>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<Button size="small" icon="md-add" @click="addFilterRow">加筛选条件</Button>
|
||||
<Button size="small" type="primary" :disabled="!formUuid" @click="reload">应用筛选</Button>
|
||||
<span class="toolbar-note">
|
||||
候选=当前发布版本里 searchable 且有算子的字段(附件类不出现,P28);算子按字段类型定,不给自由表达式
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="candidateError" show-icon type="warning" class="region-tip">
|
||||
表单清单:{{ candidateError }}(这通常是账号只授权了"数据管理"、没有"表单设计"模块行——
|
||||
用上面"按 code 直达"仍可打开已知表单的数据)
|
||||
</Alert>
|
||||
<Alert v-else-if="loadedOnce" show-icon type="info" class="region-tip">
|
||||
数据范围(后端契约 §8 矩阵 + customId 解析,前端只展示不再过滤):<b>{{ scopeText }}</b>
|
||||
</Alert>
|
||||
|
||||
<div class="table-region" ref="regionRef">
|
||||
<Table :columns="tableColumns" :data="rows" :height="regionH || undefined" :loading="loading"
|
||||
no-data-text="没有符合条件的答卷(或还没选表单)" class="link-table"
|
||||
@on-selection-change="onSelectionChange" />
|
||||
</div>
|
||||
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle batch-bar">
|
||||
<span class="toolbar-note">已选 {{ selected.length }} 条</span>
|
||||
<Button size="small" :disabled="!selected.length" @click="batchDelete">批量软删</Button>
|
||||
<Button size="small" :disabled="!selected.length" @click="batchChangeStatus">批量改状态</Button>
|
||||
<Button v-if="exportable" size="small" icon="md-download" :loading="exporting"
|
||||
@click="askExport">导出</Button>
|
||||
<Tooltip v-else content="当前身份的授权模块集里没有导出权限位(V26:接口侧的后端拒是 T4 已验的另一半)" transfer>
|
||||
<span class="toolbar-note">导出(无权限)</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="fr page-hint">
|
||||
<span class="toolbar-note">{{ loading ? "取下一页…" : nextCursor ? "向下滚动继续加载" : "已到最后一页" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal v-model="colPicker" title="可见列(按表单记忆)" :width="520" @on-cancel="colPicker = false">
|
||||
<p class="toolbar-note">
|
||||
勾选即刻写入本地记忆(键=归属应用 + 表单 code,V40);新出现的列会自动补进可见集,
|
||||
历史已删字段列默认不铺满——需要看历史残留时再勾。
|
||||
</p>
|
||||
<div class="col-list">
|
||||
<label v-for="col in allColumns" :key="col.key" class="col-item">
|
||||
<Checkbox :model-value="visibleKeys.includes(col.key)" @on-change="toggleColumn(col.key)">
|
||||
<span :class="col.removed ? 'cell-removed' : ''">{{ col.label }}</span>
|
||||
<span class="col-filled">已填 {{ Math.round(col.filledRatio * 100) }}%</span>
|
||||
</Checkbox>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="colPicker = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="batchStatusModal" title="批量改状态" :width="420">
|
||||
<Form :label-width="90">
|
||||
<FormItem label="目标状态">
|
||||
<Select v-model="batchStatusTarget">
|
||||
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<p class="toolbar-note">
|
||||
逐条走单条校验与留痕(V39):{{ selected.length }} 条里不在你数据范围内的会被逐条拒。
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button @click="batchStatusModal = false">取消</Button>
|
||||
<Button type="primary" @click="submitBatchStatus">确认</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="statusModal.visible" title="改状态" :width="420">
|
||||
<Form :label-width="90">
|
||||
<FormItem label="当前状态">
|
||||
<span>{{ statusModal.current }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="目标状态">
|
||||
<Select v-model="statusModal.target">
|
||||
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<p class="toolbar-note">
|
||||
状态是标记不是流转:不校验从哪个状态到哪个状态、无条件分支(方案 §5.3 红线、V15);改动写 status_log。
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button @click="statusModal.visible = false">取消</Button>
|
||||
<Button type="primary" @click="submitStatus">确认</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<AnswerEditModal v-if="editDetail" :detail="editDetail" mode="admin" @close="editDetail = null"
|
||||
@saved="reload" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.filter-region {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #f8f8f9;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.region-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.page-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.col-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.col-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.col-filled {
|
||||
color: #c5c8ce;
|
||||
font-size: 12px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.cell-removed {
|
||||
color: #ff9900;
|
||||
}
|
||||
.status-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.status-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.status-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+417
-6
@@ -1,11 +1,422 @@
|
||||
<script setup lang="ts">
|
||||
import PageStub from "@/components/PageStub.vue";
|
||||
/**
|
||||
* 选项字典管理(/admin/dicts,契约 §2.1 / 方案 §4.3③b)。
|
||||
*
|
||||
* 两级的落点:
|
||||
* - **本校字典**:`/dict/list` 只出本层,可增可删可改;
|
||||
* - **平台级字典**:`sch_id=0` 的行只有超管(UserBase type==1)上下文能写,
|
||||
* 校级用户这边只从 `/dict/selectable` 读到它们,**可见、可用、不可改**——
|
||||
* 所以列表给平台级行挂「平台级」标签且不给操作按钮,域切换按钮也只对超管显示;
|
||||
* 接口侧的拒绝仍由后端 FormAdminGuard 做,前端藏按钮不等于授权(契约 §7 身份行)。
|
||||
*
|
||||
* 条目的三条纪律:value 建一次即不可变(它是答案里存的值,也是 uk(dict_id,value) 的定位键)、
|
||||
* label 随便改、废弃是软删(旧答卷仍能原样重存,只是设计器不再给选)。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { dictApi } from "@/api/module/dict";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import type { DictItemView, DictView } from "@/types/form";
|
||||
import { MAX_LABEL_LEN, MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema";
|
||||
import { nextOptionValue } from "@/utils/designer/schema";
|
||||
|
||||
const store = useDefaultStore();
|
||||
const loading = ref(false);
|
||||
const own = ref<DictView[]>([]);
|
||||
const selectable = ref<DictView[]>([]);
|
||||
/** 超管的当前维护域:school=本校,platform=平台级(schId=0) */
|
||||
const layer = ref<"school" | "platform">("school");
|
||||
|
||||
const dictModal = reactive({ visible: false, creating: false, code: "", name: "", origin: null as DictView | null });
|
||||
const itemModal = reactive({ visible: false, dict: null as DictView | null, newLabel: "", items: [] as DictItemView[] });
|
||||
const busy = ref(false);
|
||||
|
||||
/** render() 产出的节点拿不到 scoped 样式,样式走内联 */
|
||||
const CODE_CHIP_STYLE = {
|
||||
color: "#515a6e",
|
||||
background: "#f3f4f6",
|
||||
borderRadius: "3px",
|
||||
padding: "2px 6px",
|
||||
fontSize: "12px"
|
||||
};
|
||||
|
||||
const scope = computed(() => (layer.value === "platform" ? { schId: 0 } : {}));
|
||||
|
||||
/** 列表数据源:超管在平台级上下文时只出平台级行;校级 = 本校行 + 平台级只读行(同 code 本校遮蔽平台) */
|
||||
const rows = computed<DictView[]>(() => {
|
||||
if (layer.value === "platform") return own.value;
|
||||
const platformVisible = selectable.value.filter(
|
||||
(d) => d.platformLevel && !own.value.some((o) => o.code === d.code)
|
||||
);
|
||||
return [...own.value, ...platformVisible];
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: "字典名称", key: "name", minWidth: 180 },
|
||||
{ title: "code", key: "code", minWidth: 140, render: (h: any, p: any) => h("code", { style: CODE_CHIP_STYLE }, p.row.code) },
|
||||
{
|
||||
title: "层级",
|
||||
key: "platformLevel",
|
||||
width: 130,
|
||||
render: (h: any, p: any) =>
|
||||
h(
|
||||
"Tag",
|
||||
{ color: p.row.platformLevel ? "blue" : "green" },
|
||||
() => (p.row.platformLevel ? "平台级(不可改)" : "本校")
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "可用条目",
|
||||
key: "items",
|
||||
width: 110,
|
||||
render: (h: any, p: any) => h("span", `${(p.row.items || []).length}`)
|
||||
},
|
||||
{
|
||||
title: "引用方式",
|
||||
key: "source",
|
||||
minWidth: 180,
|
||||
render: (h: any, p: any) => h("code", { style: CODE_CHIP_STYLE }, `dict:${p.row.code}`)
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 190,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
p.row.platformLevel && !canEdit(p.row)
|
||||
? h("span", { style: { color: "#c5c8ce", fontSize: "12px" } }, "可见可用不可改")
|
||||
: h("div", { class: "op-cell" }, [
|
||||
h("div", { class: "operating-link", onClick: () => openItems(p.row) }, "条目维护"),
|
||||
h("div", { class: "operating-link", onClick: () => openRename(p.row) }, "改名"),
|
||||
h("div", { class: "operating-link text-error", onClick: () => removeDict(p.row) }, "删除")
|
||||
])
|
||||
}
|
||||
]);
|
||||
|
||||
function canEdit(dict: DictView): boolean {
|
||||
// 平台级行必须处在超管的平台级上下文里才可写;本校行本校管理员可写
|
||||
return dict.platformLevel ? layer.value === "platform" && store.isSuperAdmin : !dict.platformLevel;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
own.value = (await dictApi.list(scope.value).send()) || [];
|
||||
selectable.value = (await dictApi.selectable().send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "字典列表加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function switchLayer(next: "school" | "platform") {
|
||||
layer.value = next;
|
||||
load();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
dictModal.visible = true;
|
||||
dictModal.creating = true;
|
||||
dictModal.origin = null;
|
||||
dictModal.code = "";
|
||||
dictModal.name = "";
|
||||
}
|
||||
|
||||
function openRename(dict: DictView) {
|
||||
dictModal.visible = true;
|
||||
dictModal.creating = false;
|
||||
dictModal.origin = dict;
|
||||
// code 是 schema 里的引用键,建后不可改:这里只给名字
|
||||
dictModal.code = dict.code;
|
||||
dictModal.name = dict.name;
|
||||
}
|
||||
|
||||
async function submitDict() {
|
||||
const code = dictModal.code.trim();
|
||||
const name = dictModal.name.trim();
|
||||
if (!CODE_PATTERN.test(code)) {
|
||||
Message.error("字典 code 仅限字母/数字/下划线(≤64 位)");
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
Message.error("字典名称不能为空");
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
await dictApi.save({ ...scope.value, code, name }).send();
|
||||
Message.success(dictModal.creating ? `已创建字典 ${code}` : "名称已更新(code 不变,引用不受影响)");
|
||||
dictModal.visible = false;
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeDict(dict: DictView) {
|
||||
Modal.confirm({
|
||||
title: `删除字典 ${dict.name}`,
|
||||
content: "软删字典。引用它的表单再次发布会因「字典无法解析」被拒;历史答卷的取值仍可原样重存。",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await dictApi.remove({ ...scope.value, code: dict.code, name: dict.name }).send();
|
||||
Message.success("已删除");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "删除失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openItems(dict: DictView) {
|
||||
// 编辑目标取最新一份(列表里的对象可能是 selectable 的平台级副本)
|
||||
const target = own.value.find((d) => d.code === dict.code) || dict;
|
||||
itemModal.visible = true;
|
||||
itemModal.dict = target;
|
||||
itemModal.newLabel = "";
|
||||
itemModal.items = (target.items || []).map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
function nextValue(): string {
|
||||
return nextOptionValue(itemModal.items.map((i) => i.value), "val");
|
||||
}
|
||||
|
||||
async function addItem() {
|
||||
const label = itemModal.newLabel.trim();
|
||||
const dict = itemModal.dict;
|
||||
if (!dict) return;
|
||||
if (!label) {
|
||||
Message.error("条目名称不能为空");
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const value = nextValue();
|
||||
const created = await dictApi
|
||||
.saveItem({ ...scope.value, dictCode: dict.code, value, label, sort: itemModal.items.length + 1 })
|
||||
.send();
|
||||
itemModal.items.push({ ...created });
|
||||
itemModal.newLabel = "";
|
||||
Message.success(`已新增条目(value=${created.value},生成后不可变)`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "新增失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveItem(item: DictItemView) {
|
||||
const dict = itemModal.dict;
|
||||
if (!dict) return;
|
||||
if (!item.label.trim()) {
|
||||
Message.error("条目名称不能为空");
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
// 只传 label/sort:value 原样带回当定位键,接口没有改 value 的通道
|
||||
await dictApi.saveItem({ ...scope.value, dictCode: dict.code, value: item.value, label: item.label.trim(), sort: item.sort }).send();
|
||||
Message.success(`条目 ${item.value} 已更新(value 未变)`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleItem(item: DictItemView) {
|
||||
const dict = itemModal.dict;
|
||||
if (!dict) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
if (item.deleted) {
|
||||
await dictApi
|
||||
.saveItem({ ...scope.value, dictCode: dict.code, value: item.value, label: item.label, sort: item.sort })
|
||||
.send();
|
||||
Message.success("已恢复为可选条目");
|
||||
} else {
|
||||
await dictApi.removeItem({ ...scope.value, dictCode: dict.code, value: item.value, label: item.label }).send();
|
||||
Message.success("已废弃:设计器不再给选,旧答卷该 value 仍可原样重存");
|
||||
}
|
||||
await load();
|
||||
const fresh = own.value.find((d) => d.code === dict.code);
|
||||
itemModal.items = (fresh?.items || []).map((i) => ({ ...i }));
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageStub
|
||||
title="字典管理"
|
||||
stage="T6"
|
||||
note="form_dict / form_dict_item 两级维护、条目 value 不可变、options.source=dict:{code} 引用在 T6 落地;平台级字典(sch_id=0)只在超管上下文维护(方案 §6.5)。"
|
||||
/>
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="toolbar">
|
||||
<Button type="primary" icon="md-add" :disabled="layer === 'platform' && !store.isSuperAdmin"
|
||||
@click="openCreate">新建字典</Button>
|
||||
<RadioGroup v-if="store.isSuperAdmin" :model-value="layer" type="button" size="small"
|
||||
@on-change="switchLayer">
|
||||
<Radio label="school">本校字典</Radio>
|
||||
<Radio label="platform">平台级(sch_id=0)</Radio>
|
||||
</RadioGroup>
|
||||
<Alert v-else type="info" class="layer-note">
|
||||
当前维护域:本校。平台级字典由学校可见、可用、<b>不可改</b>(方案 §4.3③b)
|
||||
</Alert>
|
||||
<Button icon="md-refresh" @click="load">刷新</Button>
|
||||
</div>
|
||||
|
||||
<div class="table-region">
|
||||
<Table :columns="columns" :data="rows" :loading="loading" no-data-text="还没有字典" class="link-table" />
|
||||
</div>
|
||||
|
||||
<p class="foot-note">
|
||||
设计器里字段写 <code>options.source = "dict:{code}"</code> 引用字典;引用是活引用、
|
||||
不进版本快照,读接口下发时内联展开(契约 §2.1)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Modal v-model="dictModal.visible" :title="dictModal.creating ? '新建字典' : '字典改名'" :mask-closable="false"
|
||||
:width="480">
|
||||
<Form :label-width="90">
|
||||
<FormItem label="code" required>
|
||||
<Input v-model="dictModal.code" :disabled="!dictModal.creating"
|
||||
placeholder="字母/数字/下划线,≤64,建后不可改" />
|
||||
<p class="tip">code 是 schema 里的引用键,改 code 等于换字典</p>
|
||||
</FormItem>
|
||||
<FormItem label="名称" required>
|
||||
<Input v-model="dictModal.name" :maxlength="MAX_NAME_LEN" placeholder="如:民族" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="dictModal.visible = false">取消</Button>
|
||||
<Button type="primary" :loading="busy" @click="submitDict">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="itemModal.visible" :title="`条目维护|${itemModal.dict?.name || ''}`" :mask-closable="false"
|
||||
:width="720">
|
||||
<Alert type="info" show-icon class="mb10">
|
||||
value 生成即不可变(答案存它);只有名称与排序可改。废弃=软删,旧答卷照旧可读可重存。
|
||||
</Alert>
|
||||
<div class="item-head">
|
||||
<span class="col-value">value</span>
|
||||
<span class="col-label">名称(可改)</span>
|
||||
<span class="col-sort">排序</span>
|
||||
<span class="col-op">操作</span>
|
||||
</div>
|
||||
<div v-for="item in itemModal.items" :key="item.value" class="item-row" :class="{ dropped: item.deleted }">
|
||||
<code class="col-value">{{ item.value }}</code>
|
||||
<Input v-model="item.label" size="small" :maxlength="MAX_LABEL_LEN" class="col-label" />
|
||||
<InputNumber v-model="item.sort" size="small" :min="0" class="col-sort" controls-out />
|
||||
<span class="col-op">
|
||||
<Button size="small" @click="saveItem(item)">保存</Button>
|
||||
<Button size="small" type="text" :class="item.deleted ? '' : 'text-error'"
|
||||
@click="toggleItem(item)">
|
||||
{{ item.deleted ? "恢复" : "废弃" }}
|
||||
</Button>
|
||||
<Tag v-if="item.deleted" color="default" size="small">已废弃</Tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-row add-row">
|
||||
<code class="col-value">{{ nextValue() }}</code>
|
||||
<Input v-model="itemModal.newLabel" size="small" placeholder="新条目名称,如 维吾尔族" class="col-label"
|
||||
@on-enter="addItem" />
|
||||
<span class="col-sort"></span>
|
||||
<span class="col-op">
|
||||
<Button size="small" type="primary" :loading="busy" icon="md-add" @click="addItem">新增条目</Button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="tip">
|
||||
上一行的 value 是新增时将生成的值({{ itemModal.dict?.code }} 现有
|
||||
{{ itemModal.items.length }} 条,含已废弃);生成后它就固定了。
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button @click="itemModal.visible = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.layer-note {
|
||||
margin: 0;
|
||||
}
|
||||
.table-region {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.foot-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.item-head,
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.item-head {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #eff0f3;
|
||||
}
|
||||
.col-value {
|
||||
width: 120px;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
padding: 3px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.col-label {
|
||||
flex: 1;
|
||||
}
|
||||
.col-sort {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.col-op {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.item-row.dropped .col-value {
|
||||
color: #c5c8ce;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.add-row {
|
||||
border-top: 1px dashed #eff0f3;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.tip {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.mb10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表单设计器(全屏三栏:控件面板 / 画布 / 属性面板 + 版本与发布流程)。
|
||||
*
|
||||
* 版本语义按 P20 落到 UI 上,这是本页最容易做错的地方:
|
||||
* - **保存 ≠ 发布**。保存只在草稿行上直改,或为已发布表单开一条新的草稿行,
|
||||
* 头表的 status/current_version 一律不动——升版窗口期旧版仍可填(契约 §5 钉版)。
|
||||
* - 所以头部必须同时说清「当前发布 v几」和「待发布草稿 v几」,
|
||||
* 并且版本下拉里选到已发布行时整页转只读(行不可变,V7)。
|
||||
* - 发布才把草稿行钉成版本、推指针,并留下 change_log。
|
||||
*
|
||||
* 编辑权边界:已发布过的字段 key 与 type 锁死(改 key=换字段、同 key 不换 type 是后端红线),
|
||||
* 面板里只给「删旧增新」这条路;code 发布后同样不可改(改 code=换表单)。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import ComponentLibrary from "@/components/designer/ComponentLibrary.vue";
|
||||
import DesignerCanvas from "@/components/designer/DesignerCanvas.vue";
|
||||
import FieldPropPanel from "@/components/designer/FieldPropPanel.vue";
|
||||
import FormConfigModal from "@/components/form/FormConfigModal.vue";
|
||||
import { formApi } from "@/api/module/form";
|
||||
import { dictApi } from "@/api/module/dict";
|
||||
import type { DictView, FormView, VersionView } from "@/types/form";
|
||||
import type { DesignField, DesignGroup } from "@/types/formSchema";
|
||||
import { MAX_CHANGE_LOG_LEN, MAX_NAME_LEN } from "@/types/formSchema";
|
||||
import {
|
||||
buildSchemaJson,
|
||||
cloneField,
|
||||
collectUsedKeys,
|
||||
countFields,
|
||||
defaultGroup,
|
||||
makeNextKey,
|
||||
restoreSchema,
|
||||
typesByKeys,
|
||||
validateDesign
|
||||
} from "@/utils/designer/schema";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const uuid = computed(() => String(route.query.uuid || "").trim());
|
||||
const form = ref<FormView | null>(null);
|
||||
const versions = ref<VersionView[]>([]);
|
||||
const groups = ref<DesignGroup[]>([]);
|
||||
const dicts = ref<DictView[]>([]);
|
||||
const selected = ref<{ gi: number; fi: number } | null>(null);
|
||||
/** 当前画布装载的版本行号 */
|
||||
const viewing = ref<number | null>(null);
|
||||
const viewingPublished = ref(false);
|
||||
/** 当前发布版本行原文(锁 key/type + 跨版本身份校验都按它判) */
|
||||
const publishedSchemaJson = ref<string | null>(null);
|
||||
const nameDraft = ref("");
|
||||
const codeDraft = ref("");
|
||||
const saving = ref(false);
|
||||
const publishing = ref(false);
|
||||
const showErrors = ref(false);
|
||||
/** 装载时的 schema 原文基线:dirty 就是「与它不一致」,不用深 watcher 追 */
|
||||
const baseline = ref("");
|
||||
const configRef = ref<InstanceType<typeof FormConfigModal> | null>(null);
|
||||
const publishModal = reactive({ visible: false, changeLog: "" });
|
||||
|
||||
const locked = computed(() => viewingPublished.value);
|
||||
const usedKeys = computed(() => collectUsedKeys(groups.value));
|
||||
const publishedTypes = computed(() => typesByKeys(publishedSchemaJson.value));
|
||||
/** 画布要知道哪些 key 是发布过的(卡片上的身份标记) */
|
||||
const publishedKeySet = computed(() => new Set(publishedTypes.value.keys()));
|
||||
const codeEditable = computed(() => !!form.value && form.value.currentVersion === null);
|
||||
const selectedField = computed<DesignField | null>(() => {
|
||||
const s = selected.value;
|
||||
if (!s) return null;
|
||||
return groups.value[s.gi]?.fields[s.fi] ?? null;
|
||||
});
|
||||
const errors = computed(() =>
|
||||
validateDesign(groups.value, {
|
||||
name: nameDraft.value,
|
||||
code: codeEditable.value ? codeDraft.value : undefined,
|
||||
publishedSchemaJson: publishedSchemaJson.value
|
||||
})
|
||||
);
|
||||
const visibleErrors = computed(() => (showErrors.value ? errors.value : []));
|
||||
const versionOptions = computed(() =>
|
||||
versions.value.map((v) => ({
|
||||
value: v.version,
|
||||
label: `v${v.version} ${v.published ? "已发布" : "待发布草稿"}`
|
||||
}))
|
||||
);
|
||||
|
||||
const dirty = computed(() => baseline.value !== buildSchemaJson(groups.value));
|
||||
|
||||
async function reloadHead() {
|
||||
if (!uuid.value) return;
|
||||
form.value = await formApi.get(uuid.value).send();
|
||||
versions.value = (await formApi.versions(uuid.value).send()) || [];
|
||||
nameDraft.value = form.value?.name || "";
|
||||
codeDraft.value = form.value?.code || "";
|
||||
if (form.value?.currentVersion != null) {
|
||||
const row = await formApi.version(uuid.value, form.value.currentVersion).send();
|
||||
publishedSchemaJson.value = row?.schemaJson || null;
|
||||
} else {
|
||||
publishedSchemaJson.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 装载某个版本行:已发布行只读(V7 逐字节冻结),草稿行可编辑 */
|
||||
async function loadVersion(version: number) {
|
||||
if (!uuid.value || typeof version !== "number" || !Number.isFinite(version)) return;
|
||||
if (loadingRow || version === viewing.value) return;
|
||||
loadingRow = true;
|
||||
try {
|
||||
const row = await formApi.version(uuid.value, version).send();
|
||||
const restored = restoreSchema(row?.schemaJson);
|
||||
groups.value = restored.length ? restored : [defaultGroup(new Set<string>(), 0)];
|
||||
// 基线取「用同一个序列化函数再算一遍」,不取库里的原文:
|
||||
// 原文的属性顺序可能不同,直接比会把刚装载的版本误标成「未保存」
|
||||
baseline.value = buildSchemaJson(groups.value);
|
||||
viewing.value = version;
|
||||
viewingPublished.value = !!row?.published;
|
||||
selected.value = null;
|
||||
showErrors.value = false;
|
||||
} finally {
|
||||
loadingRow = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本下拉的选择入口。
|
||||
* iView 的 Select 在挂载/modelValue 变化时可能回吐一次 on-change(值还不一定是合法版本),
|
||||
* 不加这道闸就是「渲染→emit→请求→渲染」的死循环(浏览器标签会被自己打满)。
|
||||
*/
|
||||
function onVersionPick(next: unknown) {
|
||||
const version = Number(next);
|
||||
if (!Number.isInteger(version) || version === viewing.value) return;
|
||||
loadVersion(version);
|
||||
}
|
||||
|
||||
let loadingRow = false;
|
||||
|
||||
function defaultTargetVersion(f: FormView | null): number | null {
|
||||
if (!f) return null;
|
||||
// 草稿优先:有草稿就编辑草稿,没有草稿则装载当前发布版(第一次保存时开新草稿行)
|
||||
return f.draftVersion ?? f.currentVersion ?? (versions.value[versions.value.length - 1]?.version ?? null);
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
groups.value.push(defaultGroup(usedKeys.value, groups.value.length));
|
||||
}
|
||||
|
||||
function removeGroup(gi: number) {
|
||||
const g = groups.value[gi];
|
||||
if (!g) return;
|
||||
const content = g.fields.length
|
||||
? `分组「${g.label || g.key}」内还有 ${g.fields.length} 个字段,删除后字段一并移除(已发布字段的历史答卷仍可按 key 回退检索)。`
|
||||
: "确定删除该分组吗?";
|
||||
Modal.confirm({
|
||||
title: "删除分组确认",
|
||||
content,
|
||||
onOk: () => {
|
||||
groups.value.splice(gi, 1);
|
||||
selected.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addField(field: DesignField) {
|
||||
if (!groups.value.length) addGroup();
|
||||
const gi = selected.value?.gi ?? groups.value.length - 1;
|
||||
const group = groups.value[gi];
|
||||
group.fields.push(field);
|
||||
select(gi, group.fields.length - 1);
|
||||
}
|
||||
|
||||
function select(gi: number, fi: number) {
|
||||
const g = groups.value[gi];
|
||||
if (!g || !g.fields[fi]) {
|
||||
selected.value = null;
|
||||
return;
|
||||
}
|
||||
selected.value = { gi, fi };
|
||||
}
|
||||
|
||||
function copyField(gi: number, fi: number) {
|
||||
const src = groups.value[gi]?.fields[fi];
|
||||
if (!src) return;
|
||||
const copy = cloneField(src);
|
||||
copy.key = makeNextKey(usedKeys.value)(src.type);
|
||||
groups.value[gi].fields.splice(fi + 1, 0, copy);
|
||||
select(gi, fi + 1);
|
||||
}
|
||||
|
||||
function removeField(gi: number, fi: number) {
|
||||
const f = groups.value[gi]?.fields[fi];
|
||||
if (!f) return;
|
||||
const history = publishedTypes.value.has(f.key)
|
||||
? `「${f.key}」已发布过:删除后历史答卷该字段仍可读、按它筛选仍有效(V8),但新表单不再有它。`
|
||||
: "确定删除该字段吗?";
|
||||
Modal.confirm({ title: "删除字段确认", content: history, onOk: () => {
|
||||
groups.value[gi].fields.splice(fi, 1);
|
||||
selected.value = null;
|
||||
} });
|
||||
}
|
||||
|
||||
async function save(silent = false): Promise<boolean> {
|
||||
if (!uuid.value || !form.value) return false;
|
||||
if (locked.value) {
|
||||
Message.warning("已发布版本行只读;要改版请在版本下拉切回待发布草稿");
|
||||
return false;
|
||||
}
|
||||
showErrors.value = true;
|
||||
if (errors.value.length) {
|
||||
if (!silent) Message.error(errors.value[0]);
|
||||
return false;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await formApi
|
||||
.save({
|
||||
uuid: uuid.value,
|
||||
name: nameDraft.value.trim(),
|
||||
code: codeEditable.value ? codeDraft.value.trim() : undefined,
|
||||
schemaJson: buildSchemaJson(groups.value)
|
||||
})
|
||||
.send();
|
||||
form.value = res;
|
||||
await reloadHead();
|
||||
baseline.value = buildSchemaJson(groups.value);
|
||||
if (!silent) {
|
||||
Message.success(
|
||||
res.currentVersion === null
|
||||
? "已保存为草稿 v" + res.draftVersion + "(发布后填报入口才生效)"
|
||||
: `已保存为待发布草稿 v${res.draftVersion};当前发布仍是 v${res.currentVersion},旧版仍可填`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
return false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openPublish() {
|
||||
if (!form.value) return;
|
||||
if (!form.value.draftVersion && !dirty.value) {
|
||||
Message.warning("没有待发布的草稿版本:已发布表单要先保存改动才会开新草稿");
|
||||
return;
|
||||
}
|
||||
showErrors.value = true;
|
||||
if (errors.value.length) {
|
||||
Message.error(errors.value[0]);
|
||||
return;
|
||||
}
|
||||
publishModal.changeLog = "";
|
||||
publishModal.visible = true;
|
||||
}
|
||||
|
||||
async function confirmPublish() {
|
||||
if (!uuid.value) return;
|
||||
publishing.value = true;
|
||||
try {
|
||||
// 发布走的是库里那条草稿行,先把画布上的改动落盘,避免「发的是旧草稿」
|
||||
if (dirty.value && !(await save(true))) return;
|
||||
if (!form.value?.draftVersion) {
|
||||
Message.warning("库里没有待发布的草稿版本,请先保存改动");
|
||||
return;
|
||||
}
|
||||
const log = publishModal.changeLog.trim().slice(0, MAX_CHANGE_LOG_LEN);
|
||||
const res = await formApi.publish(uuid.value, log || undefined).send();
|
||||
form.value = res;
|
||||
await reloadHead();
|
||||
publishModal.visible = false;
|
||||
Message.success(`已发布 v${res.currentVersion},填报入口按该版本寻址`);
|
||||
// 发布后草稿行被钉成版本,画布切到新发布版(继续改就再开一条草稿)
|
||||
await loadVersion(res.currentVersion ?? viewing.value ?? 1);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "发布失败");
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAs() {
|
||||
const f = form.value;
|
||||
if (!f) return;
|
||||
Modal.confirm({
|
||||
title: "另存为副本",
|
||||
content: `基于当前装载的 v${viewing.value} 复制一张新表单(新 code、新 uuid、version 从 1 起,字典引用随行),原表单与其历史数据零改动。`,
|
||||
onOk: async () => {
|
||||
const code = `${f.code}_copy`;
|
||||
try {
|
||||
const res = await formApi.copy({ uuid: f.formUuid, code, name: `${f.name}(副本)` }).send();
|
||||
Message.success(`副本已创建:${res.code}`);
|
||||
router.push({ path: "/admin/forms/designer", query: { uuid: res.formUuid } });
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "副本创建失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onConfigSaved(res: FormView) {
|
||||
form.value = res;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
if (!uuid.value) {
|
||||
Message.warning("请从表单列表进入设计器");
|
||||
router.replace("/admin/forms");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dicts.value = (await dictApi.selectable().send()) || [];
|
||||
await reloadHead();
|
||||
const target = defaultTargetVersion(form.value);
|
||||
if (target != null) await loadVersion(target);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "表单加载失败");
|
||||
router.replace("/admin/forms");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(boot);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="designer-page">
|
||||
<div class="designer-head">
|
||||
<div class="head-left">
|
||||
<Button icon="ios-arrow-back" @click="router.push('/admin/forms')">返回列表</Button>
|
||||
<Input v-model="nameDraft" placeholder="表单名称" :maxlength="MAX_NAME_LEN" style="width: 200px" />
|
||||
<Tooltip max-width="260" placement="bottom"
|
||||
content="code 发布后不可改(改 code=换表单);仅限字母/数字/下划线/中划线 ≤64">
|
||||
<Input v-model="codeDraft" :disabled="!codeEditable" placeholder="表单 code" style="width: 170px" />
|
||||
</Tooltip>
|
||||
<Select :model-value="viewing" style="width: 190px" @on-change="onVersionPick">
|
||||
<Option v-for="v in versionOptions" :key="v.value" :value="v.value" :label="v.label" />
|
||||
</Select>
|
||||
<Tag v-if="form && form.currentVersion !== null" color="success">当前发布 v{{ form.currentVersion }}</Tag>
|
||||
<Tag v-if="form && form.draftVersion" color="warning">待发布草稿 v{{ form.draftVersion }}</Tag>
|
||||
<Tag v-if="form && form.currentVersion === null && form.draftVersion" color="default">从未发布</Tag>
|
||||
<span v-if="form" class="head-meta">
|
||||
{{ form.code }}|{{ form.appCode }}|{{ countFields(groups) }} 个字段
|
||||
<span v-if="dirty" class="head-dirty">未保存</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="head-right">
|
||||
<Button @click="configRef?.open()">表单配置</Button>
|
||||
<Button @click="copyAs">另存为副本</Button>
|
||||
<Button :loading="saving" :disabled="locked" @click="save()">保存草稿</Button>
|
||||
<Button type="primary" :disabled="locked" @click="openPublish">发布</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="visibleErrors.length" type="error" show-icon class="head-errors">
|
||||
<div v-for="(err, i) in visibleErrors" :key="i">{{ err }}</div>
|
||||
</Alert>
|
||||
<Alert v-if="locked" type="warning" show-icon class="head-errors">
|
||||
正在查看已发布版本 v{{ viewing }}:行内容逐字节冻结(V7),画布只读。要改版请切到待发布草稿。
|
||||
</Alert>
|
||||
|
||||
<div class="designer-body">
|
||||
<ComponentLibrary :next-key="makeNextKey(usedKeys)" :disabled="locked" @add="addField" />
|
||||
<DesignerCanvas
|
||||
:groups="groups"
|
||||
:selected="selected"
|
||||
:disabled="locked"
|
||||
:dicts="dicts"
|
||||
:published-keys="publishedKeySet"
|
||||
@select="select"
|
||||
@copy="copyField"
|
||||
@remove="removeField"
|
||||
@add="addField"
|
||||
@add-group="addGroup"
|
||||
@remove-group="removeGroup"
|
||||
/>
|
||||
<FieldPropPanel
|
||||
v-if="selectedField"
|
||||
:key="selectedField._uid"
|
||||
:field="selectedField"
|
||||
:dicts="dicts"
|
||||
:used-keys="usedKeys"
|
||||
:published-types="publishedTypes"
|
||||
:disabled="locked"
|
||||
/>
|
||||
<aside v-else class="d-prop-empty">
|
||||
<Icon type="ios-settings" />
|
||||
<p>选中画布中的字段后在这里改属性</p>
|
||||
<p class="d-prop-sub">
|
||||
选项类控件改名称不会影响已生成的 value(value 只读),<br />
|
||||
引用字典的字段候选项由字典侧决定,不进版本快照
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<FormConfigModal ref="configRef" :form="form" @saved="onConfigSaved" />
|
||||
|
||||
<Modal v-model="publishModal.visible" title="发布新版本" :mask-closable="false">
|
||||
<p class="publish-tip">
|
||||
发布把待发布草稿 v{{ form?.draftVersion ?? "?" }} 钉成不可变版本行并推当前发布指针;<br />
|
||||
历史答卷仍按各自钉住的版本读取,升版窗口期的在途提交按旧版安全落地。
|
||||
</p>
|
||||
<Input v-model="publishModal.changeLog" type="textarea" :rows="3" :maxlength="MAX_CHANGE_LOG_LEN"
|
||||
placeholder="变更说明(可空,写入版本行 change_log)" />
|
||||
<template #footer>
|
||||
<Button @click="publishModal.visible = false">取消</Button>
|
||||
<Button type="primary" :loading="publishing" @click="confirmPublish">确认发布</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.designer-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #f2f4f8;
|
||||
}
|
||||
.designer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.head-left,
|
||||
.head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.head-meta {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.head-dirty {
|
||||
color: #ff9900;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.head-errors {
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.designer-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.d-prop-empty {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid #e8eaec;
|
||||
background: #fff;
|
||||
color: #808695;
|
||||
text-align: center;
|
||||
padding: 40px 18px;
|
||||
}
|
||||
.d-prop-empty :deep(.ivu-icon) {
|
||||
font-size: 28px;
|
||||
color: #c5c8ce;
|
||||
}
|
||||
.d-prop-sub {
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.publish-tip {
|
||||
color: #515a6e;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
+368
-6
@@ -1,11 +1,373 @@
|
||||
<script setup lang="ts">
|
||||
import PageStub from "@/components/PageStub.vue";
|
||||
/**
|
||||
* 表单列表(/admin/forms,替换 T5 占位页)。
|
||||
*
|
||||
* 两条口径写明白,避免接手的人以为是「后端分页列表」:
|
||||
* - `/api/form/admin/list` 返回本校(+ 目标域)全量表单头表,**没有服务端分页参数**,
|
||||
* 所以这里用 `.table-region` 实测高度喂固定表头、关键字/状态在前端过滤当页数据;
|
||||
* 答卷数据面的两段式列表与 keyset 分页是 T8 的事,两者不是一回事。
|
||||
* - 每行同时呈现**两根指针**:`当前发布 v{n}` 与 `待发布草稿 v{m}`(P20)——
|
||||
* 只写 status 会让人以为「保存了就是改了线上」。
|
||||
*
|
||||
* 操作列形态按 P51 定 text 文字链(同族 center 应用一致),超出 2 个自动收进「更多」。
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { formApi } from "@/api/module/form";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
import FormConfigModal from "@/components/form/FormConfigModal.vue";
|
||||
import type { FormView } from "@/types/form";
|
||||
import { AUDIENCE, FORM_STATUS } from "@/types/form";
|
||||
import { CODE_PATTERN, MAX_NAME_LEN } from "@/types/formSchema";
|
||||
|
||||
const router = useRouter();
|
||||
const regionRef = ref<HTMLElement | null>(null);
|
||||
const regionH = useElementHeight(regionRef);
|
||||
|
||||
const list = ref<FormView[]>([]);
|
||||
const loading = ref(false);
|
||||
const keyword = ref("");
|
||||
const statusFilter = ref<number | string>("");
|
||||
|
||||
const createModal = ref(false);
|
||||
const createDraft = ref({ code: "", name: "", appCode: "form-center" });
|
||||
const creating = ref(false);
|
||||
const copyModal = ref({ visible: false, source: null as FormView | null, code: "", name: "" });
|
||||
const copying = ref(false);
|
||||
/** 配置面板就地开在列表页(不进设计器也能改表单级配置,它不进版本快照) */
|
||||
const configRef = ref<InstanceType<typeof FormConfigModal> | null>(null);
|
||||
const activeForm = ref<FormView | null>(null);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase();
|
||||
return list.value
|
||||
.filter((f) => statusFilter.value === "" || f.status === Number(statusFilter.value))
|
||||
.filter((f) => !kw || f.name.toLowerCase().includes(kw) || f.code.toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
title: "表单名称",
|
||||
key: "name",
|
||||
minWidth: 180,
|
||||
render: (h2: any, p: any) =>
|
||||
h2("div", { class: "cell-name" }, [
|
||||
h2("span", p.row.name),
|
||||
p.row.appCode && p.row.appCode !== "form-center"
|
||||
? h2("span", { class: "cell-app" }, `快应用 ${p.row.appCode}`)
|
||||
: null
|
||||
])
|
||||
},
|
||||
{ title: "code", key: "code", minWidth: 150 },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 200,
|
||||
render: (h2: any, p: any) => {
|
||||
const f: FormView = p.row;
|
||||
const tag =
|
||||
f.status === FORM_STATUS.DRAFT
|
||||
? h2("Tag", { color: "default" }, () => "草稿")
|
||||
: f.status === FORM_STATUS.PUBLISHED
|
||||
? h2("Tag", { color: "success" }, () => `已发布 v${f.currentVersion}`)
|
||||
: h2("Tag", { color: "error" }, () => `已停用 v${f.currentVersion}`);
|
||||
return h2("div", { class: "cell-status" }, [
|
||||
tag,
|
||||
f.draftVersion ? h2("Tag", { color: "warning" }, () => `待发布草稿 v${f.draftVersion}`) : null
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "面向",
|
||||
key: "audience",
|
||||
width: 90,
|
||||
render: (h2: any, p: any) => h2("span", p.row.audience === AUDIENCE.TEACHER ? "面向教师" : "面向学生")
|
||||
},
|
||||
{
|
||||
title: "截止",
|
||||
key: "deadline",
|
||||
width: 130,
|
||||
render: (h2: any, p: any) => h2("span", p.row.deadline ? String(p.row.deadline).slice(0, 10) : "不限")
|
||||
},
|
||||
{
|
||||
title: "限次/报名号",
|
||||
key: "fillLimit",
|
||||
width: 130,
|
||||
render: (h2: any, p: any) =>
|
||||
h2("span", `${p.row.fillLimit ? "限一次" : "不限次"}|${p.row.answerNoEnabled ? "有报名号" : "无报名号"}`)
|
||||
},
|
||||
actionColumn(
|
||||
[
|
||||
{ key: "design", label: "设计", on: (row: FormView) => goDesigner(row) },
|
||||
{ key: "config", label: "配置", on: (row: FormView) => openConfig(row) },
|
||||
{
|
||||
key: "publish",
|
||||
label: "发布",
|
||||
when: (row: FormView) => !!row.draftVersion,
|
||||
on: (row: FormView) => publish(row)
|
||||
},
|
||||
{
|
||||
key: "disable",
|
||||
label: "停用",
|
||||
when: (row: FormView) => row.status === FORM_STATUS.PUBLISHED,
|
||||
on: (row: FormView) => lifecycle(row, "disable")
|
||||
},
|
||||
{
|
||||
key: "enable",
|
||||
label: "恢复",
|
||||
when: (row: FormView) => row.status === FORM_STATUS.DISABLED,
|
||||
on: (row: FormView) => lifecycle(row, "enable")
|
||||
},
|
||||
{ key: "copy", label: "另存为副本", on: (row: FormView) => openCopy(row) },
|
||||
{ key: "delete", label: "删除", danger: true, on: (row: FormView) => softDelete(row) }
|
||||
],
|
||||
{ mode: "text", maxInline: 2 }
|
||||
)
|
||||
]);
|
||||
|
||||
function goDesigner(row: FormView) {
|
||||
router.push({ path: "/admin/forms/designer", query: { uuid: row.formUuid } });
|
||||
}
|
||||
|
||||
function openConfig(row: FormView) {
|
||||
activeForm.value = row;
|
||||
configRef.value?.open();
|
||||
}
|
||||
|
||||
function onConfigSaved(res: FormView) {
|
||||
activeForm.value = res;
|
||||
load();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
list.value = (await formApi.list().send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "表单列表加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const createError = computed(() => {
|
||||
const c = createDraft.value.code.trim();
|
||||
if (!c) return "code 不能为空";
|
||||
if (!CODE_PATTERN.test(c)) return "code 仅限字母/数字/下划线/中划线,≤64 位";
|
||||
if (!createDraft.value.name.trim()) return "名称不能为空";
|
||||
return "";
|
||||
});
|
||||
|
||||
async function submitCreate() {
|
||||
if (createError.value) {
|
||||
Message.error(createError.value);
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
try {
|
||||
const res = await formApi
|
||||
.create({
|
||||
code: createDraft.value.code.trim(),
|
||||
name: createDraft.value.name.trim(),
|
||||
appCode: createDraft.value.appCode.trim() || "form-center"
|
||||
})
|
||||
.send();
|
||||
Message.success(`已创建草稿:${res.code}`);
|
||||
createModal.value = false;
|
||||
createDraft.value = { code: "", name: "", appCode: "form-center" };
|
||||
goDesigner(res);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "创建失败");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function publish(row: FormView) {
|
||||
Modal.confirm({
|
||||
title: `发布 ${row.name}`,
|
||||
content: `把待发布草稿 v${row.draftVersion} 钉成不可变版本并推当前发布指针;当前发布 v${row.currentVersion ?? "无"} 在升版窗口期仍可被在途提交按旧版落地。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await formApi.publish(row.formUuid).send();
|
||||
Message.success(`已发布 v${res.currentVersion}`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "发布失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function lifecycle(row: FormView, action: "disable" | "enable") {
|
||||
Modal.confirm({
|
||||
title: action === "disable" ? `停用 ${row.name}` : `恢复 ${row.name}`,
|
||||
content:
|
||||
action === "disable"
|
||||
? "停用只断填报入口,数据管理仍可读可导(方案 §5.4)。"
|
||||
: "恢复=重新发布当前版本,不产生新版本行;草稿请走发布。",
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = action === "disable" ? await formApi.disable(row.formUuid).send() : await formApi.enable(row.formUuid).send();
|
||||
Message.success(action === "disable" ? "已停用" : `已恢复 v${res.currentVersion}`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openCopy(row: FormView) {
|
||||
copyModal.value = { visible: true, source: row, code: `${row.code}_copy`, name: `${row.name}(副本)` };
|
||||
}
|
||||
|
||||
async function submitCopy() {
|
||||
const m = copyModal.value;
|
||||
if (!m.source) return;
|
||||
if (!CODE_PATTERN.test(m.code.trim())) {
|
||||
Message.error("副本 code 仅限字母/数字/下划线/中划线,≤64 位");
|
||||
return;
|
||||
}
|
||||
copying.value = true;
|
||||
try {
|
||||
const res = await formApi
|
||||
.copy({ uuid: m.source.formUuid, code: m.code.trim(), name: m.name.trim() })
|
||||
.send();
|
||||
Message.success(`副本已创建:${res.code}(v${res.draftVersion},源表单零改动)`);
|
||||
m.visible = false;
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "副本创建失败");
|
||||
} finally {
|
||||
copying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function softDelete(row: FormView) {
|
||||
Modal.confirm({
|
||||
title: `删除 ${row.name}`,
|
||||
content: "只做软删:填报入口消失,历史答卷与索引行保留,且系统没有物理删除接口(方案 §5.4)。",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await formApi.softDelete(row.formUuid).send();
|
||||
Message.success("已删除(软删)");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "删除失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageStub
|
||||
title="表单设计"
|
||||
stage="T6"
|
||||
note="设计器三件套(14 类控件面板 + 属性面板 + 表单级配置)、发布/升版流程、另存为副本在 T6 落地。"
|
||||
/>
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="toolbar">
|
||||
<Button type="primary" icon="md-add" @click="createModal = true">新建表单</Button>
|
||||
<Input v-model="keyword" search placeholder="按名称或 code 搜索" style="width: 220px" clearable />
|
||||
<Select v-model="statusFilter" style="width: 150px" placeholder="状态">
|
||||
<Option value="">全部状态</Option>
|
||||
<Option :value="0">草稿</Option>
|
||||
<Option :value="1">已发布</Option>
|
||||
<Option :value="2">停用</Option>
|
||||
</Select>
|
||||
<Button icon="md-refresh" @click="load">刷新</Button>
|
||||
<span class="toolbar-note">
|
||||
列表显示 {{ filtered.length }} / {{ list.length }} 条(后端一次给全量头表,筛选在前端)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-region" ref="regionRef">
|
||||
<Table :columns="columns" :data="filtered" :height="regionH || undefined" :loading="loading"
|
||||
no-data-text="还没有表单,点「新建表单」开始" class="link-table" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal v-model="createModal" title="新建表单" :mask-closable="false" :width="520">
|
||||
<Form :label-width="90">
|
||||
<FormItem label="表单名称" required>
|
||||
<Input v-model="createDraft.name" :maxlength="MAX_NAME_LEN" placeholder="如:三好学生申报" />
|
||||
</FormItem>
|
||||
<FormItem label="表单 code" required>
|
||||
<Input v-model="createDraft.code" placeholder="字母/数字/下划线/中划线,≤64" />
|
||||
<div class="form-tip">
|
||||
入口与分享地址按 code 寻址(/form-center/f/{code});发布后不可改,改 code=换表单
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="归属应用">
|
||||
<Input v-model="createDraft.appCode" placeholder="form-center" />
|
||||
<div class="form-tip">中心表单留 form-center;快应用表单挂该快应用的 app_code</div>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<Alert v-if="createError" type="warning" show-icon class="form-tip">{{ createError }}</Alert>
|
||||
<template #footer>
|
||||
<Button @click="createModal = false">取消</Button>
|
||||
<Button type="primary" :loading="creating" :disabled="!!createError" @click="submitCreate">
|
||||
创建并进设计器
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="copyModal.visible" title="另存为副本" :mask-closable="false" :width="520">
|
||||
<p class="form-tip">
|
||||
副本=新 code + 新 uuid + version 从 1 起,schema 深拷贝、字典引用随行;<br />
|
||||
源表单与其历史答卷零改动(V41)。表单级配置随副本,但**不继承旧截止时间**。
|
||||
</p>
|
||||
<Form :label-width="90">
|
||||
<FormItem label="副本名称" required>
|
||||
<Input v-model="copyModal.name" :maxlength="MAX_NAME_LEN" />
|
||||
</FormItem>
|
||||
<FormItem label="副本 code" required>
|
||||
<Input v-model="copyModal.code" placeholder="字母/数字/下划线/中划线,≤64" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="copyModal.visible = false">取消</Button>
|
||||
<Button type="primary" :loading="copying" @click="submitCopy">创建副本</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<FormConfigModal ref="configRef" :form="activeForm" @saved="onConfigSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-tip {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cell-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cell-app {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cell-status {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
+293
-6
@@ -1,11 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import PageStub from "@/components/PageStub.vue";
|
||||
/**
|
||||
* 统计页 /admin/stats(T8 落地,替换 T5 占位)。
|
||||
*
|
||||
* 三条纪律:
|
||||
* 1. **前端零聚合**(V11 前端半句):总数 / 按状态 / 按字段分组三个数都直接来自
|
||||
* `POST /api/form/admin/data/stats`,本页不含任何 `reduce`/`filter(...).length`/`groupBy`。
|
||||
* 页面只负责把后端给的桶画成图——一旦前端自己数一遍,就会出现"列表和统计对不上"的第二套口径。
|
||||
* 2. **不缓存、不限定 scope**:短 TTL 缓存由后端管(键含范围签名,仅升版失效——铁律④);
|
||||
* scope 也由后端按契约 §8 矩阵解析。响应里的 `cached` 只用来把"这是缓存值"讲清楚,
|
||||
* 前端既不自己缓存,也不传范围参数(传了就是越权面)。
|
||||
* 3. **分组候选 = 筛选候选取 searchable 字段**(同一个 `filterFields` 端点形状,后端已排除附件类,P28)——
|
||||
* 分组阈值与筛选阈值是同一份"有算子的 searchable 字段",不另造一份候选。
|
||||
*
|
||||
* 快应用上下文复用本组件:传 `context="app"` + `app-code` + `codes`(同 Data.vue 的取 uuid 路径)。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useFormCandidates } from "@/utils/hook/useFormCandidates";
|
||||
import { dataApi } from "@/api/module/data";
|
||||
import { scopeLabel, type CountItem, type FieldGroups, type FilterField, type StatsView } from "@/types/data";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
context?: "center" | "app";
|
||||
appCode?: string;
|
||||
codes?: string[];
|
||||
/** 被宿主页面内嵌(快应用上下文)时置 true:不套 `.page-wrapper > .main-container` */
|
||||
embedded?: boolean;
|
||||
}>(),
|
||||
{ context: "center", appCode: "", codes: () => [], embedded: false }
|
||||
);
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const { candidates, candidateError, formUuid, loadCandidates, selectForm } = useFormCandidates(
|
||||
() => props.context,
|
||||
() => props.appCode,
|
||||
() => props.codes
|
||||
);
|
||||
|
||||
/** 分组候选:与筛选候选同一个端点(searchable 且有算子;附件类不出现) */
|
||||
const groupFields = ref<FilterField[]>([]);
|
||||
const groupByField = ref("");
|
||||
const stats = ref<StatsView | null>(null);
|
||||
const loading = ref(false);
|
||||
const loadedOnce = ref(false);
|
||||
|
||||
/** 生效范围文案(只展示,不过滤——任务书 T8 红线) */
|
||||
const scopes = ref<string[]>([]);
|
||||
const scopeText = computed(() => (scopes.value.length ? scopes.value.map(scopeLabel).join("、") : "后端未回传"));
|
||||
|
||||
/**
|
||||
* 只按"哪些字段能分组"这一件事取候选。scopes 从统计响应里拿(列表接口会回传,统计接口不带——
|
||||
* 首个响应回来后从列表接口补一次范围签名不合适:那会把统计页变成列表页的依赖)。
|
||||
* 这里统一从 `filterFields` 拉不到范围,就把范围文案留空,不编一个假的。
|
||||
*/
|
||||
async function loadGroupFields(uuid: string) {
|
||||
try {
|
||||
groupFields.value = (await dataApi.filterFields(uuid).send()) || [];
|
||||
} catch (e: any) {
|
||||
groupFields.value = [];
|
||||
console.warn("[T8] 统计分组候选取不到:", e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
if (!formUuid.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
stats.value = await dataApi
|
||||
.stats({
|
||||
formUuid: formUuid.value,
|
||||
groupByField: groupByField.value || undefined
|
||||
})
|
||||
.send();
|
||||
loadedOnce.value = true;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "统计加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按状态 / 按字段的桶宽度:以桶内最大值归一,纯视觉映射(不是聚合) */
|
||||
function barPercent(items: CountItem[]): (item: CountItem) => number {
|
||||
const max = items.reduce((m, it) => Math.max(m, it.count), 0);
|
||||
return (item: CountItem) => (max > 0 ? Math.max(2, Math.round((item.count / max) * 100)) : 0);
|
||||
}
|
||||
|
||||
const statusPercent = computed(() => barPercent(stats.value?.byStatus || []));
|
||||
const fieldPercent = computed(() => (groups: FieldGroups) => barPercent(groups.items || []));
|
||||
|
||||
async function selectFormAndLoad(uuid: string) {
|
||||
groupByField.value = "";
|
||||
stats.value = null;
|
||||
loadedOnce.value = false;
|
||||
await selectForm(uuid);
|
||||
await loadGroupFields(uuid);
|
||||
await loadStats();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadCandidates();
|
||||
const fromQuery = String(route.query.form || "");
|
||||
const picked = candidates.value.find((c: { formUuid: string }) => c.formUuid === fromQuery) || candidates.value[0];
|
||||
if (picked) await selectFormAndLoad(picked.formUuid);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.context, props.appCode, props.codes.join(",")],
|
||||
() => init()
|
||||
);
|
||||
|
||||
onMounted(init);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageStub
|
||||
title="统计"
|
||||
stage="T8"
|
||||
note="总数/按状态/按字段分组走接口聚合(铁律④:前端不自己聚合),缓存失效仅升版;UserBase 侧「统计」行可并入数据管理,二选一。"
|
||||
/>
|
||||
<div :class="embedded ? 'stats-bare' : 'page-wrapper yh-fill'">
|
||||
<div :class="embedded ? '' : 'main-container'">
|
||||
<div class="toolbar">
|
||||
<Select v-model="formUuid" style="width: 260px" placeholder="选择表单" @on-change="selectFormAndLoad">
|
||||
<Option v-for="c in candidates" :key="c.formUuid" :value="c.formUuid"
|
||||
:label="`${c.name}(${c.appCode}/${c.code})`" />
|
||||
</Select>
|
||||
<Select v-model="groupByField" style="width: 240px" placeholder="按字段分组(可不选)" clearable
|
||||
@on-change="loadStats">
|
||||
<Option value="">不分组(只出总数与按状态)</Option>
|
||||
<Option v-for="f in groupFields" :key="f.key" :value="f.key" :label="f.label" />
|
||||
</Select>
|
||||
<Button icon="md-refresh" :disabled="!formUuid" @click="loadStats">刷新</Button>
|
||||
<span class="toolbar-note">
|
||||
三个数(总数 / 按状态 / 按字段)全部来自接口聚合,本页不做任何二次统计
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Alert v-if="candidateError" show-icon type="warning" class="region-tip">
|
||||
表单清单:{{ candidateError }}
|
||||
</Alert>
|
||||
|
||||
<Spin v-if="loading && !stats" fix></Spin>
|
||||
<template v-if="stats">
|
||||
<div class="stat-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-label">总数</div>
|
||||
<div class="stat-sub">范围:{{ scopeText }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.byStatus.length }}</div>
|
||||
<div class="stat-label">状态桶数</div>
|
||||
<div class="stat-sub">含"已废弃"状态的行会带 null label</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.byField.length }}</div>
|
||||
<div class="stat-label">分组字段数</div>
|
||||
<div class="stat-sub">
|
||||
{{ stats.cached ? "命中后端短 TTL 缓存(容忍 N 分钟延迟)" : "本次数值直接来自接口聚合" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="section-title">按状态</h5>
|
||||
<div v-if="!stats.byStatus.length" class="empty-tip">没有可统计的答卷(或后端未回传状态桶)</div>
|
||||
<div v-else class="bar-list">
|
||||
<div v-for="item in stats.byStatus" :key="item.value" class="bar-row">
|
||||
<span class="bar-label">{{ item.label || item.value }}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" :style="{ width: statusPercent(item) + '%' }"></div>
|
||||
</div>
|
||||
<span class="bar-count">{{ item.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="groups in stats.byField" :key="groups.key">
|
||||
<h5 class="section-title">按字段分组:{{ groups.label || groups.key }}</h5>
|
||||
<div v-if="!groups.items.length" class="empty-tip">该字段没有可统计的分组值</div>
|
||||
<div v-else class="bar-list">
|
||||
<div v-for="item in groups.items" :key="item.value" class="bar-row">
|
||||
<span class="bar-label">{{ item.label || item.value }}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" :style="{ width: fieldPercent(groups)(item) + '%' }"></div>
|
||||
</div>
|
||||
<span class="bar-count">{{ item.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="toolbar-note foot-note">
|
||||
统计只覆盖你数据范围内的答卷(契约 §8 矩阵,后端四读出路径共用同一构造器);
|
||||
新提交的答卷在短 TTL 内不计入是<b>预期行为</b>(铁律④:提交不清缓存)。
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<Alert v-else-if="loadedOnce === false && !loading && !candidateError" show-icon type="info" class="region-tip">
|
||||
先在上面选一张表单。
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.region-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat-cards {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stat-card {
|
||||
flex: 1 1 200px;
|
||||
min-width: 180px;
|
||||
padding: 14px 16px;
|
||||
background: #f8f8f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: #2d8cf0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.stat-sub {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.section-title {
|
||||
margin: 18px 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.bar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 64px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.bar-label {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
word-break: break-all;
|
||||
}
|
||||
.bar-track {
|
||||
height: 14px;
|
||||
background: #eef0f3;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: #2d8cf0;
|
||||
border-radius: 7px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.bar-count {
|
||||
font-size: 13px;
|
||||
color: #17233d;
|
||||
text-align: right;
|
||||
}
|
||||
.empty-tip {
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.foot-note {
|
||||
display: block;
|
||||
margin-top: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user