From d1177a741c85be08be1b547848c080d5cd76d186 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 20 Oct 2026 17:40:30 +0800 Subject: [PATCH] =?UTF-8?q?t18:=E5=89=8D=E7=AB=AF=E4=BB=A3=E5=BD=95?= =?UTF-8?q?=E4=B8=8E=E5=AF=BC=E5=85=A5=E2=80=94=E2=80=94=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E8=A7=86=E5=9B=BE=E4=B8=A4=E4=B8=AA=E6=96=B0=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E6=8E=A5=E8=BF=9B=E5=90=8C=E4=B8=80=E6=9D=A1=20submit?= =?UTF-8?q?=20=E7=AE=A1=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fill.vue 加代录态:?proxy=1 且该表单在 managed 清单里才画「被记录人」选择器(复用 UserCheckModal + GetSchoolAll 本校组织树),提交体带 subjectId。三条口径写在注释里:proxy 只是界面开关、判权在后端 requireSubject(非 ALL 档带字段即 403);subjectId 是被写入数据的字段而不是操作者身份(V17 不变); 代录态不取也不显示"我的提交"段(那是本人视角的历史,混进来会让人以为已经给这个人记过)。 - AppContext 管理员视图每行加「添加(代录)」与「导入」;导入弹窗组件 components/admin/ImportPanel.vue: 下载模板 → 选文件 → 逐行结果表(行号/结果/答卷ID/原因)+ 成功失败计数。整批拒(不是这张表的模板、 label 重复、超行数上限)与坏行原因一律按后端 msg 原样亮出,前端不复制规则(同 AdminListPanel 口径)。 - api/module/data.ts 加 import(multipart,formUuid 走 query)与 importTemplateBlob(原生 fetch: 文件流不能过 alova 的 response.json() 拦截器,理由同导出那条)。types/data.ts 加 ImportResult/ImportRowResult。 - api/module/anon.ts 加开发期会话开关:仅 conf.isMock(build 产物恒 false)时,地址带 mockSession=0 即判"无会话"——否则 mock 的 GetUserInfo 永远返回 code=0,匿名通道在 mock 面演不出来(V67 前半句要实拍)。 门禁:npx vue-tsc --noEmit 0 错(s7/tsc-2.log);npm run build ✓ built in 20.99s(s7/build-1.log)。 --- src/api/module/anon.ts | 11 ++- src/api/module/data.ts | 44 +++++++++ src/components/admin/ImportPanel.vue | 143 +++++++++++++++++++++++++++ src/types/data.ts | 21 ++++ src/views/AppContext.vue | 45 ++++++++- src/views/Fill.vue | 55 ++++++++++- 6 files changed, 313 insertions(+), 6 deletions(-) create mode 100644 src/components/admin/ImportPanel.vue 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 }}
- +