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:
@@ -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();
|
||||
Reference in New Issue
Block a user