/** * 数据面接口(后端 `FormAnswerReadController` + `FormAnswerAdminController`, * `/api/form/admin/data/**`,CAS 会话 + 数据管理/统计模块行 + 行级数据范围三层一起判)。 * * 写法沿 P49:`alovaInstance.Get(url, {params})` / `Post(url, body, {params})`。 * * 四处与直觉不同,改动前先读注释: * 1. **列表是 POST + 请求体**(后端 `@RequestBody ListRequest`),不是 GET 带 query; * 2. **翻页只有 cursor/order/size**,没有页码也没有总数(keyset,铁律②); * 3. **`schId` 只在超管显式切目标域时带**(校级传别的值即被 FormAdminGuard 拒); * 4. **导出不走 alova**:响应是 xlsx 二进制流,而 alova 的响应拦截器无条件 `response.json()` * (`utils/alova/alova-core.ts`),拿它接文件流必炸——见 `exportBlob` 里的原生 fetch。 */ import { alovaInstance } from "@/utils/alova/alova-iview"; import Utils from "@/utils"; import conf from "@/assets/config/host.config"; import type { AnswerDetail, BatchItemResult, FilterField, ListRequest, ListView, QueryRequest, StatsView } from "@/types/data"; const BASE = "/api/form/admin/data"; /** 目标域参数:只在超管显式选择上下文时带(undefined/空值剔除,避免 query 里出现空值) */ type Scope = { schId?: number }; function paramsOf(params: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === "") continue; out[k] = v; } return out; } class DataApi { /** 两段式列表(keyset 游标);行含当页值文档,列由前端按 schema 展(铁律①的对外形态) */ list(body: ListRequest, scope: Scope = {}) { return alovaInstance.Post(`${BASE}/list`, body, { params: paramsOf({ ...scope }) }); } /** 详情:uuid 寻址 + 钉住版本的内联展开 schema(V5:URL 与参数里都只有 uuid) */ detail(answerId: string, scope: Scope = {}) { return alovaInstance.Get(`${BASE}/detail`, { params: paramsOf({ ...scope, answerId }) }); } /** 筛选候选(searchable 且有算子;附件类已被后端排除,P28) */ filterFields(formUuid: string, scope: Scope = {}) { return alovaInstance.Get(`${BASE}/filterFields`, { params: paramsOf({ ...scope, formUuid }) }); } /** 统计(总数/按状态/按字段分组;短 TTL 缓存与范围解析都在后端,前端不缓存不限定 scope) */ stats(body: QueryRequest, scope: Scope = {}) { return alovaInstance.Post(`${BASE}/stats`, body, { params: paramsOf({ ...scope }) }); } /** 编辑值文档:按该答卷钉住的 version 校验(新 schema 字段不会在旧数据上凭空出现) */ edit(answerId: string, values: Record, summary?: string, scope: Scope = {}) { return alovaInstance.Post(`${BASE}/edit`, { answerId, values, summary }, { params: paramsOf({ ...scope }) }); } /** 软删(唯一删除形态,无物理删除接口——V30);uuid 走 query */ softDelete(answerId: string, scope: Scope = {}) { return alovaInstance.Post(`${BASE}/softDelete`, {}, { params: paramsOf({ ...scope, answerId }) }); } /** 改状态(填表人在接口层就没有这条路——V14) */ changeStatus(answerId: string, status: string, scope: Scope = {}) { return alovaInstance.Post(`${BASE}/changeStatus`, { answerId, status }, { params: paramsOf({ ...scope }) }); } /** 批量软删:后端逐条校验、逐条留痕、逐条回结果(V39;单次上限 200,P29) */ batchSoftDelete(answerIds: string[], scope: Scope = {}) { return alovaInstance.Post(`${BASE}/batch/softDelete`, { answerIds }, { params: paramsOf({ ...scope }) }); } /** 批量改状态:同上 */ batchChangeStatus(answerIds: string[], status: string, scope: Scope = {}) { return alovaInstance.Post(`${BASE}/batch/changeStatus`, { answerIds, status }, { params: paramsOf({ ...scope }) }); } } export const dataApi = new DataApi(); /** * 导出(xlsx 流式同步下载,方案 §5.5 铁律④:不设行数上限、不做异步任务与下载中心)。 * *

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

* *

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

*/ export async function exportBlob(body: QueryRequest, scope: Scope = {}): Promise { const query = new URLSearchParams(); if (scope.schId !== undefined && scope.schId !== null) query.set("schId", String(scope.schId)); const url = `${conf.serviceHost}${BASE}/export${query.toString() ? "?" + query.toString() : ""}`; const headers: Record = { "Content-Type": "application/json", // 与 alova 请求拦截同一组头:后端 CAS 过滤器按 yschapirequest 判"这是 API 请求" yschapirequest: "1", yschreferer: Utils.strToHexCharCode(window.location.href) }; const currentRole = localStorage.getItem("currentRole"); if (currentRole) headers.currentRole = currentRole; const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), credentials: "include" }); const type = response.headers.get("content-type") || ""; if (!response.ok || type.includes("application/json")) { // 错误态:信封 JSON(成功时是 spreadsheet 流,不会走到这支) let msg = "导出失败"; try { const env = await response.json(); msg = env?.msg || msg; } catch { /* 响应体不是 JSON 时保持默认文案 */ } throw new Error(msg); } return await response.blob(); } /** 触发浏览器下载(文件名由后端响应头给,这里只负责挂上并点一下) */ export function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }