t18:前端代录与导入——管理员视图两个新动作接进同一条 submit 管线

- 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)。
This commit is contained in:
chenxing
2026-10-20 17:40:30 +08:00
parent 531f7641fb
commit d1177a741c
6 changed files with 313 additions and 6 deletions
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
/**
* 批量导入面板(契约 v2 §5.1/C14,T18):通用壳管理员视图「导入」按钮背后的弹窗。
*
* 三件事:下载模板 → 选文件 → 逐行结果。四处分明:
* 1. **导入入口只在管理员视图**(§5.1):能不能进来由该表单 ALL 档决定,守卫在后端
* (`requireData`),这里只负责不把这个按钮画给填报者;
* 2. **坏行不静默**:响应是逐行结果,表格里 ok/error 全摊开;整体"成功 N 行失败 M 行"
* 不等于"文件里有 N+M 行"这件事也由行号列自证;
* 3. **重复导入同一个文件是逐行 200+原 answerId**(C13 服务端确定性幂等键),
* 所以"手抖点两次导入"不会产生重复数据,也不会报错——提示语里写清楚,否则用户以为没执行;
* 4. 模板与解析共用后端那一份映射(`AnswerImportService`),前端不重述字段规则;
* 行上限 1000 超限是整批拒(不是截断),文案按后端 msg 原样给。
*/
import { computed, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { dataApi, downloadBlob, importTemplateBlob } from "@/api/module/data";
import type { ImportRowResult } from "@/types/data";
const props = defineProps<{
modelValue: boolean;
/** 导入目标:数据面按 uuid 寻址(code 只作填报入口,契约 §1) */
form: { formUuid: string; code: string; name: string; appCode?: string | null };
}>();
const emit = defineEmits<{ (e: "update:modelValue", value: boolean): void }>();
const file = ref<File | null>(null);
const inputRef = ref<HTMLInputElement | null>(null);
const busy = ref(false);
const rows = ref<ImportRowResult[]>([]);
const counts = ref<{ ok: number; fail: number } | null>(null);
const visible = computed({
get: () => props.modelValue,
set: (next: boolean) => emit("update:modelValue", next)
});
const columns = computed(() => [
{ title: "行号", key: "rowNo", width: 70 },
{
title: "结果",
key: "ok",
width: 80,
render: (h: any, p: any) =>
h("Tag", { color: p.row.ok ? "green" : "red" }, p.row.ok ? "已写入" : "未写入")
},
{ title: "答卷ID", key: "answerId", minWidth: 150, ellipsis: true, tooltip: true },
{ title: "原因", key: "error", minWidth: 220 }
]);
function pick(next: Event) {
const picked = (next.target as HTMLInputElement).files?.[0] || null;
file.value = picked;
rows.value = [];
counts.value = null;
}
async function downloadTemplate() {
busy.value = true;
try {
const blob = await importTemplateBlob(props.form.formUuid);
downloadBlob(blob, `import-template-${props.form.code}.xlsx`);
} catch (e: any) {
Message.error(e?.message || "模板下载失败");
} finally {
busy.value = false;
}
}
async function runImport() {
if (!file.value) {
Message.warning("先选择按模板填好的 xlsx 文件");
return;
}
busy.value = true;
try {
const result = await dataApi.import(props.form.formUuid, file.value).send();
rows.value = result?.rows || [];
counts.value = { ok: result?.okCount ?? 0, fail: result?.failCount ?? 0 };
if (counts.value.fail === 0) {
Message.success(`导入完成:${counts.value.ok} 行已写入`);
} else {
Message.warning(`导入完成:${counts.value.ok} 行已写入,${counts.value.fail} 行未写入(见下表原因)`);
}
} catch (e: any) {
// 整批被拒的情形都在这里(不是这张表的模板、label 重复、行数超上限、解析失败):
// 后端给的是可诊断文案,原样亮出来,不在前端改写
Message.error(e?.message || "导入失败");
} finally {
busy.value = false;
}
}
watch(visible, (open) => {
if (!open) return;
file.value = null;
rows.value = [];
counts.value = null;
if (inputRef.value) inputRef.value.value = "";
});
</script>
<template>
<Modal v-model="visible" :title="`批量导入|${form.name}`" :width="760" :mask-closable="false">
<div class="import-bar">
<Button :loading="busy" icon="md-download" @click="downloadTemplate">下载模板</Button>
<input ref="inputRef" type="file" accept=".xlsx" @change="pick" />
<Button type="primary" :loading="busy" :disabled="!file" icon="md-cloud-upload" @click="runImport">
导入
</Button>
</div>
<Alert show-icon type="info" class="import-tip">
每一行都走与手工填报<b>同一条提交管线</b>(值校验、限次、截止、幂等、索引),所以导入的数据与
手工填的完全可比;重复导入同一个文件不会落重复行,而是逐行返回原答卷ID。
第一列「被记录人」留空即以你本人为被记录人;填他人 ID 需要该表单的 ALL 档。
</Alert>
<p v-if="counts" class="import-count">
共 {{ rows.length }} 行:已写入 {{ counts.ok }},未写入 {{ counts.fail }}
</p>
<Table :columns="columns" :data="rows" size="small" :border="false" max-height="320" />
<template #footer>
<Button @click="visible = false">关闭</Button>
</template>
</Modal>
</template>
<style scoped>
.import-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.import-tip {
margin-bottom: 10px;
}
.import-count {
margin: 0 0 8px;
font-size: 13px;
color: #515a6e;
}
</style>