diff --git a/src/api/module/anon.ts b/src/api/module/anon.ts index ea09381..e7e7f19 100644 --- a/src/api/module/anon.ts +++ b/src/api/module/anon.ts @@ -48,8 +48,17 @@ async function probe(path: string): Promise> { } } -/** 探针 1(C15 第一跳):当前浏览器有没有 CAS 会话。非 0(含 302/网络错)一律按"无会话"回答 */ +/** + * 探针 1(C15 第一跳):当前浏览器有没有 CAS 会话。非 0(含 302/网络错)一律按"无会话"回答。 + * + * mock 面的开发期开关(`conf.isMock` 在 build 产物里恒 false,生产链路一个字都不走这支): + * 地址带 `mockSession=0` 即视为无会话——否则 mock 的 GetUserInfo 永远返回 code=0, + * 匿名通道在 mock 面就演不出来(V67 要的"匿名表单 page=直接可填"需要能在本机实拍)。 + */ export async function probeSession(): Promise { + if (conf.isMock && window.location.href.includes("mockSession=0")) { + return { logged: false, user: null }; + } const envelope = await probe("/api/base/GetUserInfo"); return { logged: envelope.code === 0, user: envelope.code === 0 ? envelope.data ?? null : null }; } diff --git a/src/api/module/data.ts b/src/api/module/data.ts index 74757a9..5346c22 100644 --- a/src/api/module/data.ts +++ b/src/api/module/data.ts @@ -18,6 +18,7 @@ import type { AnswerDetail, BatchItemResult, FilterField, + ImportResult, ListRequest, ListView, QueryRequest, @@ -93,10 +94,53 @@ class DataApi { params: paramsOf({ ...scope }) }); } + + /** + * 批量导入(契约 v2 C14,T18):multipart xlsx,逐行走提交管线,响应给逐行结果(坏行不静默)。 + * 守卫与导出同一把(该表单 ALL 档),`formUuid` 走 query 而不是 body——它是请求的寻址件不是数据。 + */ + import(formUuid: string, file: File, scope: Scope = {}) { + const formData = new FormData(); + formData.append("file", file); + return alovaInstance.Post(`${BASE}/import`, formData, { + params: paramsOf({ ...scope, formUuid }), + headers: { "Content-Type": "multipart/form-data" } + }); + } } export const dataApi = new DataApi(); +/** + * 导入模板下载(C14,T18)。与 {@link exportBlob} 同一支原生 fetch:这两个端点都直接写 + * OutputStream、不套 JSON 信封,本仓的响应拦截器对每个响应都 `response.json()`,文件流过它必报错。 + */ +export async function importTemplateBlob(formUuid: string, scope: Scope = {}): Promise { + const query = new URLSearchParams({ formUuid }); + if (scope.schId !== undefined && scope.schId !== null) query.set("schId", String(scope.schId)); + const url = `${conf.serviceHost}${BASE}/import-template?${query.toString()}`; + const headers: Record = { + yschapirequest: "1", + yschreferer: Utils.strToHexCharCode(window.location.href) + }; + const currentRole = localStorage.getItem("currentRole"); + if (currentRole) headers.currentRole = currentRole; + + const response = await fetch(url, { method: "GET", headers, credentials: "include" }); + const type = response.headers.get("content-type") || ""; + if (!response.ok || type.includes("application/json")) { + let msg = "模板下载失败"; + try { + const env = await response.json(); + msg = env?.msg || msg; + } catch { + /* 响应体不是 JSON 时保持默认文案 */ + } + throw new Error(msg); + } + return await response.blob(); +} + /** * 导出(xlsx 流式同步下载,方案 §5.5 铁律④:不设行数上限、不做异步任务与下载中心)。 * diff --git a/src/components/admin/ImportPanel.vue b/src/components/admin/ImportPanel.vue new file mode 100644 index 0000000..ba8fc46 --- /dev/null +++ b/src/components/admin/ImportPanel.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/types/data.ts b/src/types/data.ts index 2ba267e..d177e7b 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -191,3 +191,24 @@ export interface AppRow { export function scopeLabel(name: string): string { return DATA_SCOPE_LABELS[name] ?? name; } + +/** + * 批量导入的逐行结果(契约 v2 C14,T18,`POST /api/form/admin/data/import`)。 + * + * 失败原因直接是提交管线那句话(导入侧不改写),所以"这一行为什么没进去"在界面上与手工填报报错同文; + * 重复导入同一个文件时该行仍是 ok 且带原 answerId(C13 的确定性幂等键)。 + */ +export interface ImportRowResult { + /** xlsx 里的物理行号(与用户在 Excel 里看到的行号一致,可直接指到那一格) */ + rowNo: number; + ok: boolean; + answerId?: string | null; + error?: string | null; +} + +/** 导入响应(C14 的闭集:rows + 两个计数;行上限 1000 由服务端整批拒) */ +export interface ImportResult { + rows: ImportRowResult[]; + okCount: number; + failCount: number; +} diff --git a/src/views/AppContext.vue b/src/views/AppContext.vue index b0bec1e..da06d7b 100644 --- a/src/views/AppContext.vue +++ b/src/views/AppContext.vue @@ -20,6 +20,7 @@ import { useRoute, useRouter } from "vue-router"; import { listFillableForms, listManagedForms } from "@/api/module/fill"; import { fillRouteOf } from "@/utils/fillLink"; import Data from "@/views/admin/Data.vue"; +import ImportPanel from "@/components/admin/ImportPanel.vue"; import type { FillableForm, ManagedForm } from "@/types/data"; import { Message } from "view-ui-plus"; @@ -85,6 +86,25 @@ function goFill(row: FillableForm) { router.push(fillRouteOf(row)); } +/** + * 代录入口(T18):进同一张填报页,带 {@code proxy=1} 让它把"被记录人"选择器画出来。 + * 这个 query 只是界面开关——能不能真的挂到别人名下完全由后端判(该表单 ALL 档, + * {@code AnswerService#requireSubject}),非档者即使手搓请求体也是 403。 + */ +function goProxy(row: ManagedForm) { + const target = fillRouteOf(row); + router.push({ path: target.path, query: { ...target.query, proxy: "1" } }); +} + +/** 导入弹窗的目标行(§5.1:导入入口只存在于管理员视图) */ +const importTarget = ref(null); +const importVisible = ref(false); + +function openImport(row: ManagedForm) { + importTarget.value = row; + importVisible.value = true; +} + function fillPathText(row: FillableForm): string { const target = fillRouteOf(row); return `${location.origin}${location.pathname}#${target.path}${target.query.appCode ? `?appCode=${target.query.appCode}` : ""}`; @@ -135,22 +155,33 @@ watch(appCode, () => loadAll());
{{ currentTitle }}
- +