Files
form-center-front/src/api/module/data.ts
T
chenxing 14d45f7e3c
build / build (push) Successful in 3m17s
feat(t9): P76 导出锚点判树修正 + P77 应用内详情形态 + 观察项①勾选回填 + 演练桩
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 推送分层)。
2026-10-15 12:57:10 +08:00

149 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 数据面接口(后端 `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);
}