Files
form-center-front/src/components/admin/ImportPanel.vue
T
chenxing d1177a741c 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)。
2026-10-20 17:40:30 +08:00

144 lines
5.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>