build / build (push) Successful in 3m17s
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 推送分层)。
149 lines
6.7 KiB
TypeScript
149 lines
6.7 KiB
TypeScript
/**
|
||
* 数据面接口(后端 `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);
|
||
}
|