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
+10 -1
View File
@@ -48,8 +48,17 @@ async function probe<T>(path: string): Promise<Envelope<T>> {
} }
} }
/** 探针 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<SessionProbe> { export async function probeSession(): Promise<SessionProbe> {
if (conf.isMock && window.location.href.includes("mockSession=0")) {
return { logged: false, user: null };
}
const envelope = await probe("/api/base/GetUserInfo"); const envelope = await probe("/api/base/GetUserInfo");
return { logged: envelope.code === 0, user: envelope.code === 0 ? envelope.data ?? null : null }; return { logged: envelope.code === 0, user: envelope.code === 0 ? envelope.data ?? null : null };
} }
+44
View File
@@ -18,6 +18,7 @@ import type {
AnswerDetail, AnswerDetail,
BatchItemResult, BatchItemResult,
FilterField, FilterField,
ImportResult,
ListRequest, ListRequest,
ListView, ListView,
QueryRequest, QueryRequest,
@@ -93,10 +94,53 @@ class DataApi {
params: paramsOf({ ...scope }) 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<ImportResult>(`${BASE}/import`, formData, {
params: paramsOf({ ...scope, formUuid }),
headers: { "Content-Type": "multipart/form-data" }
});
}
} }
export const dataApi = new DataApi(); export const dataApi = new DataApi();
/**
* 导入模板下载(C14,T18)。与 {@link exportBlob} 同一支原生 fetch:这两个端点都直接写
* OutputStream、不套 JSON 信封,本仓的响应拦截器对每个响应都 `response.json()`,文件流过它必报错。
*/
export async function importTemplateBlob(formUuid: string, scope: Scope = {}): Promise<Blob> {
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<string, string> = {
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 铁律④:不设行数上限、不做异步任务与下载中心)。 * 导出(xlsx 流式同步下载,方案 §5.5 铁律④:不设行数上限、不做异步任务与下载中心)。
* *
+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>
+21
View File
@@ -191,3 +191,24 @@ export interface AppRow {
export function scopeLabel(name: string): string { export function scopeLabel(name: string): string {
return DATA_SCOPE_LABELS[name] ?? name; return DATA_SCOPE_LABELS[name] ?? name;
} }
/**
* 批量导入的逐行结果(契约 v2 C14,T18,`POST /api/form/admin/data/import`)。
*
* 失败原因直接是提交管线那句话(导入侧不改写),所以"这一行为什么没进去"在界面上与手工填报报错同文;
* 重复导入同一个文件时该行仍是 ok 且带<b>原</b> 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;
}
+41 -4
View File
@@ -20,6 +20,7 @@ import { useRoute, useRouter } from "vue-router";
import { listFillableForms, listManagedForms } from "@/api/module/fill"; import { listFillableForms, listManagedForms } from "@/api/module/fill";
import { fillRouteOf } from "@/utils/fillLink"; import { fillRouteOf } from "@/utils/fillLink";
import Data from "@/views/admin/Data.vue"; import Data from "@/views/admin/Data.vue";
import ImportPanel from "@/components/admin/ImportPanel.vue";
import type { FillableForm, ManagedForm } from "@/types/data"; import type { FillableForm, ManagedForm } from "@/types/data";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
@@ -85,6 +86,25 @@ function goFill(row: FillableForm) {
router.push(fillRouteOf(row)); router.push(fillRouteOf(row));
} }
/**
* 代录入口(T18):进同一张填报页,带 {@code proxy=1} 让它把"被记录人"选择器画出来。
* 这个 query 只是界面开关——<b>能不能真的挂到别人名下完全由后端判</b>(该表单 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<ManagedForm | null>(null);
const importVisible = ref(false);
function openImport(row: ManagedForm) {
importTarget.value = row;
importVisible.value = true;
}
function fillPathText(row: FillableForm): string { function fillPathText(row: FillableForm): string {
const target = fillRouteOf(row); const target = fillRouteOf(row);
return `${location.origin}${location.pathname}#${target.path}${target.query.appCode ? `?appCode=${target.query.appCode}` : ""}`; return `${location.origin}${location.pathname}#${target.path}${target.query.appCode ? `?appCode=${target.query.appCode}` : ""}`;
@@ -135,22 +155,33 @@ watch(appCode, () => loadAll());
<h5 class="section-title">{{ currentTitle }}</h5> <h5 class="section-title">{{ currentTitle }}</h5>
<!-- 管理员视图:数据列表(内嵌 Data,候选=managed 的 code)+ 复制填报路径。 <!-- 管理员视图:数据列表(内嵌 Data,候选=managed 的 code)+ 复制填报路径
「添加(代录)」与「导入」在 T18 接进这一档(同一条 submit 管线、同一 ALL 档守卫)。 --> + 添加(代录)+ 导入(T18 接进这一档:同一条 submit 管线、同一 ALL 档守卫)。 -->
<template v-if="activeView === 'data'"> <template v-if="activeView === 'data'">
<div class="manage-rows"> <div class="manage-rows">
<div v-for="row in managedRows" :key="row.code" class="manage-row"> <div v-for="row in managedRows" :key="row.code" class="manage-row">
<div class="manage-main"> <div class="manage-main">
<div class="manage-name">{{ row.name }}</div> <div class="manage-name">
{{ row.name }}
<Tag v-if="row.status !== 1" color="warning" size="small">
{{ row.status === 0 ? "草稿" : "已停用" }}
</Tag>
</div>
<code class="manage-path">{{ fillPathText(row) }}</code> <code class="manage-path">{{ fillPathText(row) }}</code>
<span class="toolbar-note"> <span class="toolbar-note">
来源:{{ (row.role || []).map((r) => (r === 'creator' ? '创建人' : '表单管理员')).join('、') }} 来源:{{ (row.role || []).map((r) => (r === 'creator' ? '创建人' : '表单管理员')).join('、') }}
</span> </span>
</div> </div>
<Button size="small" icon="md-copy" @click="copyFillPath(row)">复制填报路径</Button> <div class="manage-ops">
<Button size="small" icon="md-copy" @click="copyFillPath(row)">复制填报路径</Button>
<Button size="small" icon="md-person-add" @click="goProxy(row)">添加(代录)</Button>
<Button size="small" icon="md-cloud-upload" :disabled="row.status !== 1"
@click="openImport(row)">导入</Button>
</div>
</div> </div>
</div> </div>
<Data v-if="adminCodes.length" context="app" :app-code="appCode" :codes="adminCodes" embedded /> <Data v-if="adminCodes.length" context="app" :app-code="appCode" :codes="adminCodes" embedded />
<ImportPanel v-if="importTarget" v-model="importVisible" :form="importTarget" />
</template> </template>
<template v-else-if="activeView === 'fill'"> <template v-else-if="activeView === 'fill'">
<!-- 单表单直落:只有一个可填项时不叠列表,内容区直接给这张表的填报入口 --> <!-- 单表单直落:只有一个可填项时不叠列表,内容区直接给这张表的填报入口 -->
@@ -277,6 +308,12 @@ watch(appCode, () => loadAll());
font-weight: 600; font-weight: 600;
color: #17233d; color: #17233d;
} }
.manage-ops {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.manage-path { .manage-path {
display: inline-block; display: inline-block;
margin: 2px 8px 2px 0; margin: 2px 8px 2px 0;
+54 -1
View File
@@ -19,11 +19,15 @@ import type { DesignField } from "@/types/formSchema";
import type { ResolvedForm, MyAnswerItem, SubmitRequest } from "@/types/answer"; import type { ResolvedForm, MyAnswerItem, SubmitRequest } from "@/types/answer";
import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer, getMyAnswerDetail } from "@/api/module/fill"; import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer, getMyAnswerDetail } from "@/api/module/fill";
import { probeSession, probeAnonForm, submitAnonAnswer, anonUploadPath } from "@/api/module/anon"; import { probeSession, probeAnonForm, submitAnonAnswer, anonUploadPath } from "@/api/module/anon";
import { baseApi } from "@/api/module/base";
import UserCheckModal from "@/components/UserCheckModal.vue";
import { CENTER_APP_CODE, managesForm, useDefaultStore } from "@/store";
import { startHeightReporting, reportSubmitResult, reportNeedLogin } from "@/utils/embed"; import { startHeightReporting, reportSubmitResult, reportNeedLogin } from "@/utils/embed";
import Utils from "@/utils"; import Utils from "@/utils";
import { Message, Modal } from "view-ui-plus"; import { Message, Modal } from "view-ui-plus";
const route = useRoute(); const route = useRoute();
const store = useDefaultStore();
// ==================== mode 闭集(契约 §7) ==================== // ==================== mode 闭集(契约 §7) ====================
@@ -80,7 +84,10 @@ async function loadForm() {
if ((await probeSession()).logged) { if ((await probeSession()).logged) {
channel.value = "session"; channel.value = "session";
form.value = await resolveForm(code.value, queryAppCode); form.value = await resolveForm(code.value, queryAppCode);
await loadMyAnswers(); // 代录态不取"我的提交":那一段是本人视角的历史,代录的是别人的数据,混在一起会让人以为
// 自己已经给这个人记过一次(后端限次键按 coalesce(subject, submitter) 判,也不能靠界面猜)
if (String(route.query.proxy ?? "") === "1") myAnswers.value = [];
else await loadMyAnswers();
return; return;
} }
const anonymous = await probeAnonForm(code.value, queryAppCode); const anonymous = await probeAnonForm(code.value, queryAppCode);
@@ -114,6 +121,29 @@ function guideToLogin() {
window.location.href = "/cas/login?service=" + encodeURIComponent(window.location.href); window.location.href = "/cas/login?service=" + encodeURIComponent(window.location.href);
} }
// ==================== 代录(T18,§5.1 管理员视图「添加」的落点) ====================
/**
* {@code ?proxy=1} 只是<b>界面开关</b>:它让这一页画出"被记录人"选择器,并在提交体里带 {@code subjectId}。
* 真正的把关完全在后端——{@code AnswerService#requireSubject} 按"该表单 ALL 档"判,非档者带了也是 403;
* 而 {@code subjectId} 从来不是"操作者身份"(操作者身份仍由会话推导,V17 不变),它是被写入数据的一个字段。
*/
const proxyMode = computed(() => String(route.query.proxy ?? "") === "1" && channel.value === "session");
const canProxy = computed(() =>
proxyMode.value && managesForm(store.managed, appCode.value || CENTER_APP_CODE, code.value)
);
const subject = ref<{ id: number; name: string } | null>(null);
const checkRef = ref<InstanceType<typeof UserCheckModal> | null>(null);
/** 选人数据源=本校组织树(与管理名单同一件,跨校的人根本选不出来,P33 同一条) */
const subjectSources = [{ name: "本校组织", loader: () => baseApi.getSchoolAll().send() }];
function onSubjectPicked(users: any[]) {
const first = (users || []).find((u) => u && u.id != null);
if (!first) return;
subject.value = { id: Number(first.id), name: String(first.name || "").trim() || `用户 ${first.id}` };
}
// ==================== 字段渲染与校验 ==================== // ==================== 字段渲染与校验 ====================
const formValues = ref<Record<string, any>>({}); const formValues = ref<Record<string, any>>({});
@@ -190,6 +220,9 @@ async function handleSubmit() {
formCode: code.value, formCode: code.value,
appCode: appCode.value || undefined, appCode: appCode.value || undefined,
idempotencyKey, idempotencyKey,
// 代录:subject=被记录人(后端按该表单 ALL 档把关,非档者带这个字段就是 403);
// 没选人就不带——"以我自己为被记录人"与手工填报同一条路径
subjectId: canProxy.value ? subject.value?.id : undefined,
values: formValues.value values: formValues.value
}; };
// 两条通道同一条请求形状(匿名体里没有 subjectId,服务端也不读身份字段——C8/V17) // 两条通道同一条请求形状(匿名体里没有 subjectId,服务端也不读身份字段——C8/V17)
@@ -376,6 +409,20 @@ const readonlyReason = computed(() => {
<div v-else class="form-content"> <div v-else class="form-content">
<h2 class="form-title">{{ form.name }}</h2> <h2 class="form-title">{{ form.name }}</h2>
<!-- 代录条(?proxy=1 且这张表在你名下是 ALL 档时才画;判权在后端,这里只是界面开关) -->
<Alert v-if="canProxy" show-icon type="warning" class="readonly-tip">
代录模式:这条记录挂到「被记录人」名下,提交人仍是你本人(不选人即以你自己为被记录人)。
<Button size="small" type="primary" ghost @click="checkRef?.show()">
{{ subject ? "重新选人" : "选择被记录人" }}
</Button>
<span v-if="subject" class="proxy-subject">
{{ subject.name }}
<a href="javascript:;" @click="subject = null">清除</a>
</span>
</Alert>
<UserCheckModal ref="checkRef" :sources="subjectSources" :userids="subject ? [subject.id] : []"
@on-submit="onSubjectPicked" />
<!-- 只读态提示 --> <!-- 只读态提示 -->
<Alert v-if="isReadonly" show-icon type="info" class="readonly-tip"> <Alert v-if="isReadonly" show-icon type="info" class="readonly-tip">
{{ readonlyReason }},当前为只读模式 {{ readonlyReason }},当前为只读模式
@@ -496,6 +543,12 @@ const readonlyReason = computed(() => {
margin-bottom: 16px; margin-bottom: 16px;
} }
.proxy-subject {
margin-left: 10px;
font-weight: 600;
color: #17233d;
}
.success-state { .success-state {
margin-bottom: 24px; margin-bottom: 24px;
} }