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:
@@ -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>
|
||||
Reference in New Issue
Block a user