Files
form-center-front/src/views/admin/Data.vue
T
chenxing 48e46e367b
build / build (push) Successful in 1m47s
feat(p95-fill): 可填写换源后端接口,快应用导航壳按身份写死,/f/ 模块行消费退役
任务书 P95 W4/W5/W6。填报入口收回中心后,前端不再从应用模块树里挑 /f/{code} 行算清单。

- My.vue「可填写」:数据源 store.modules → GET /api/form/my/fillable,只画 canFill=true;
  空态文案改「表单发布后按面向人群自动出现在这里」,不再引导去 UserBase 配模块行
- AppContext.vue 整页改版:清单吃 fillable?appCode=,三档按 myTier 渲染
  (填报恒显 + 单表单直落保留 / 数据管理 MANAGE∪ADMIN / 统计 ADMIN);
  GetUserMod?appCode 仍拉一次,唯一用途=交给内嵌 Data.vue 判导出锚点(P76 机制原样),
  空树整页告警与「树里没有 /f/ 行」分支退役——取树失败只等于不画导出按钮(fail-closed)
- fillLink 新增 fillRouteOf(与 buildFillLink 同一条 appCode 规则,点进去与分享出去同址);
  modulePerms 删 fillCodesOfModules(唯一消费者已退役),canExport/anchorRows/hasPage 保留勿伤;
  menuRoute 的 /f/ 解析与门户导航未动(分享链接、P87 探针照旧)
- mock:快应用树改四行终态(删填报行),新增 /api/form/my/fillable 桩覆盖 FILL/MANAGE/ADMIN
- vue-tsc --noEmit 与 vite build 双绿;p87 探针由当前源码重打包后 15/15 复跑绿
2026-10-16 12:44:42 +08:00

777 lines
31 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">
/**
* 数据列表 /admin/data(T8 落地,替换 T5 占位)。
*
* 四处分寸,改动前请先读:
* 1. **翻页只有游标**:后端 keyset 响应没有 total、没有页码(方案 §5.5 铁律②),
* 所以这里只有"触底加载下一页",`cursor`=上一页末行 answerId(uuid)(P39),不能跳页;
* 2. **列 = 最新已发布 schema ∪ 值文档里出现过的历史 key**(方案 §5.2):
* 删字段不清历史索引行,历史 key 在值里还在,列上降级显示"(已移除字段)xxx"(V8 前端半句)。
* 筛选候选则**只认后端 `filterFields`**(当前发布版本 searchable 且有算子的字段)——
* 历史已删字段的 key 发给后端会被 `rejectUnknownKeys` 拒(缺口记执行手册 §5 问题单,前端不私造筛选项);
* 3. **可见列按 (归属应用, code) 记忆**(V40),localStorage 键见 `utils/dataValue.columnMemoryKey`;
* 4. **数据范围以后端为准**:响应里的 `scopes` 只用来把"你现在看到的是哪个范围"讲清楚,
* 前端不据此再过滤一遍(任务书 T8 红线)。
*
* 快应用上下文(AppContext)复用本组件:传 `context="app"` + `app-code` + `codes`
* (候选来自 `GET /api/form/my/fillable?appCode=` 按层级筛出的 code,经 resolve 拿 uuid——
* 定义管理权不在快应用上下文里,§3.4;P95 W5 起不再从该应用模块树挑 `/f/` 行)
* + `modules`(**同一棵原始模块树**,只给按钮级权限判定用,P76)。
*/
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal, Tooltip, Checkbox } from "view-ui-plus";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import { useFormCandidates } from "@/utils/hook/useFormCandidates";
import { dataApi, downloadBlob, exportBlob } from "@/api/module/data";
import { useDefaultStore } from "@/store";
import { canExport } from "@/utils/modulePerms";
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
import {
cellText,
clipText,
columnMemoryKey,
deriveColumns,
loadColumnMemory,
reconcileVisibleKeys,
saveColumnMemory,
statusDisplay,
type DataCol
} from "@/utils/dataValue";
import { scopeLabel, type AnswerDetail, type AnswerRow, type FilterField, type FilterItem, type ListView } from "@/types/data";
import type { StatusDefView } from "@/types/form";
import type { DesignField } from "@/types/formSchema";
import type { IModule } from "@/types/Basics";
/** 行视图 + 勾选态(`_checked` 是 view-ui-plus Table 的行内选中字段,见 withSelectionState) */
type RowVM = AnswerRow & { _checked?: boolean };
const props = withDefaults(
defineProps<{
context?: "center" | "app";
/** app 上下文的归属应用(center 留空=form-center) */
appCode?: string;
/** app 上下文的候选 code(来自 /api/form/my/fillable 的层级筛取,P95 W5) */
codes?: string[];
/**
* 按钮级权限判定的**那棵树**(P76):app 上下文必须由宿主传入目标应用的模块树
* (GetUserMod?appCode= 的结果)——中心会话树里没有快应用的"导出"锚点行,
* 拿它判锚点等于永远判不中(fail-closed 方向:有授权也不画按钮)。
* center 上下文不传,仍按 `store.modules`(会话自己的树)判页面行。
*/
modules?: IModule[] | null;
/**
* 被宿主页面内嵌(快应用上下文 /app/:appCode/)时置 true:只渲染内容本体,
* 不再套一层 `.page-wrapper > .main-container`——否则会出现双层白卡与双份内边距。
*/
embedded?: boolean;
}>(),
{ context: "center", appCode: "", codes: () => [], modules: null, embedded: false }
);
const route = useRoute();
const router = useRouter();
const store = useDefaultStore();
const regionRef = ref<HTMLElement | null>(null);
const regionH = useElementHeight(regionRef);
const PAGE_SIZE = 20;
const BATCH_LIMIT = 200;
// ==================== 表单候选与当前版本(与统计页共用 useFormCandidates) ====================
const {
candidates,
candidateError,
current,
formUuid,
loadCandidates,
selectForm: chooseForm,
pickByCode
} = useFormCandidates(() => props.context, () => props.appCode, () => props.codes);
const manualCode = ref("");
/** 定义清单不可用时的直达入口:仍然只经 resolve(契约 §1"resolve 是读接口唯一入口") */
async function useManualCode() {
const code = manualCode.value.trim();
if (!code) {
Message.warning("请先输入表单 code");
return;
}
const uuid = await pickByCode(code);
if (!uuid) {
Message.error(candidateError.value || "按 code 解析失败");
return;
}
manualCode.value = "";
await selectForm(uuid);
}
// ==================== 筛选 / 排序 / 游标 ====================
const filterFields = ref<FilterField[]>([]);
const filterRows = ref<FilterItem[]>([]);
const statusFilter = ref("");
const order = ref<"desc" | "asc">("desc");
const rows = ref<RowVM[]>([]);
const nextCursor = ref<string | null>(null);
const scopes = ref<string[]>([]);
const loading = ref(false);
const loadedOnce = ref(false);
/** 已选行(跨已加载页累积;换表单/换筛选即清空) */
const selected = ref<string[]>([]);
const liveStatusDefs = computed<StatusDefView[]>(() =>
(current.value?.statusDefs || []).filter((d: StatusDefView) => !d.is_deleted)
);
/** 生效范围文案(只展示,不过滤) */
const scopeText = computed(() => (scopes.value.length ? scopes.value.map(scopeLabel).join("、") : "后端未回传"));
async function loadFilterFields(uuid: string): Promise<void> {
try {
filterFields.value = (await dataApi.filterFields(uuid).send()) || [];
} catch (e: any) {
filterFields.value = [];
console.warn("[T8] 筛选候选取不到:", e?.message || e);
}
}
function queryBody() {
const filters = filterRows.value
.filter((f) => f.key)
.map((f) => ({
key: f.key,
value: f.value || undefined,
from: f.from || undefined,
to: f.to || undefined
}));
return {
formUuid: formUuid.value,
status: statusFilter.value || undefined,
filters: filters.length ? filters : undefined
};
}
/** 首屏/条件变更:重置游标从头取(keyset 没有"跳到第 N 页"这回事) */
async function reload() {
if (!formUuid.value) return;
loading.value = true;
try {
const res: ListView = await dataApi
.list({ ...queryBody(), cursor: null, order: order.value, size: PAGE_SIZE })
.send();
selected.value = [];
rows.value = withSelectionState(res?.rows || []);
nextCursor.value = res?.nextCursor ?? null;
scopes.value = res?.scopes || [];
loadedOnce.value = true;
} catch (e: any) {
Message.error(e?.message || "数据加载失败");
} finally {
loading.value = false;
}
}
/** 触底加载:cursor=上一页末行 uuid,服务端按 uk 换库内 id 并同样过范围(P39:不可见即拒) */
async function loadMore() {
if (!nextCursor.value || loading.value) return;
loading.value = true;
try {
const res: ListView = await dataApi
.list({ ...queryBody(), cursor: nextCursor.value, order: order.value, size: PAGE_SIZE })
.send();
rows.value = withSelectionState([...rows.value, ...(res?.rows || [])]);
nextCursor.value = res?.nextCursor ?? null;
if (res?.scopes) scopes.value = res.scopes;
} catch (e: any) {
Message.error(e?.message || "下一页加载失败");
} finally {
loading.value = false;
}
}
/**
* 行级勾选态回填(T9 观察项①落实):view-ui-plus 的 `data` 监听器会用 `makeObjBaseData` 重建
* `objData`,而那里的 `_isChecked` **只认行上的 `_checked`**(点选只改 objData,不回写行),
* 所以整组替换后已勾的行会弹回未选,而"已选 N 条"仍按 selected 累计——两处口径打架。
* 判据统一放这一处:`selected`(onSelectionChange 维护)才是唯一事实源,rows 每次都据它带 `_checked`。
*/
function withSelectionState(list: AnswerRow[]): RowVM[] {
return list.map((row) => ({ ...row, _checked: selected.value.includes(row.answerId) } as RowVM));
}
// ==================== 可见列(V40) ====================
const allColumns = computed<DataCol[]>(() => deriveColumns(current.value?.fields || [], rows.value));
const visibleKeys = ref<string[]>([]);
function applyMemory() {
if (!current.value) {
visibleKeys.value = [];
return;
}
const key = columnMemoryKey(current.value.appCode, current.value.code);
visibleKeys.value = reconcileVisibleKeys(allColumns.value, loadColumnMemory(key));
}
const colPicker = ref(false);
function toggleColumn(colKey: string) {
const next = visibleKeys.value.includes(colKey)
? visibleKeys.value.filter((k) => k !== colKey)
: [...visibleKeys.value, colKey];
visibleKeys.value = next;
if (current.value) saveColumnMemory(columnMemoryKey(current.value.appCode, current.value.code), next);
}
watch(allColumns, applyMemory);
// ==================== 表格列定义 ====================
function renderCell(col: DataCol, values: Record<string, unknown>) {
const text = cellText(col.field, col.key, values);
const { shown, truncated, full } = clipText(text);
const span = h("span", { class: col.removed ? "cell-removed" : "" }, shown);
if (!truncated) return span;
return h(Tooltip, { content: full, transfer: true }, { default: () => [span] });
}
const tableColumns = computed(() => {
const cols: any[] = [{ type: "selection", width: 44 }];
cols.push({ title: "提交时间", key: "submitTime", width: 160 });
cols.push({
title: "状态",
key: "status",
width: 130,
render: (h2: any, p: any) => h2("span", statusDisplay(p.row.status, p.row.statusLabel))
});
for (const col of allColumns.value) {
if (!visibleKeys.value.includes(col.key)) continue;
cols.push({
title: col.label,
key: col.key,
width: 180,
render: (_h2: any, p: any) => renderCell(col, p.row.values)
});
}
cols.push({
title: "报名号",
key: "answerNo",
width: 150,
render: (h2: any, p: any) => h2("span", p.row.answerNo || "—")
});
cols.push({
title: "身份",
key: "identity",
width: 170,
render: (h2: any, p: any) =>
h2(Tooltip, {
content: `submitter_id=${p.row.submitterId}${p.row.subjectId ? `,subject_id=${p.row.subjectId}(他人代填)` : ",无代填主体"}`,
transfer: true
}, {
default: () => [h2("span", `提交人 ${p.row.submitterId}${p.row.subjectId ? `|主体 ${p.row.subjectId}` : ""}`)]
})
});
cols.push(
actionColumn(
[
{ key: "detail", label: "详情", on: (row: AnswerRow) => goDetail(row) },
{ key: "edit", label: "编辑", on: (row: AnswerRow) => openEdit(row) },
{ key: "status", label: "改状态", on: (row: AnswerRow) => openStatus(row) },
{ key: "delete", label: "软删", danger: true, on: (row: AnswerRow) => softDeleteOne(row) }
],
{ mode: "text", maxInline: 2 }
)
);
return cols;
});
/** 附件列的"N 个文件"由 cellText 统一给,这里不再另写一份(同一张表里两套口径迟早漂移) */
// ==================== 行操作 ====================
function goDetail(row: AnswerRow) {
// P77:快应用上下文留在应用壳里看详情(/app/{appCode}/data/detail),中心上下文走 /admin/data/detail;
// 两边都只带 uuid(V5),返回由详情页按 route.params.appCode 认路。
const path = props.context === "app" ? `/app/${props.appCode}/data/detail` : "/admin/data/detail";
router.push({ path, query: { answerId: row.answerId } });
}
const editDetail = ref<AnswerDetail | null>(null);
async function openEdit(row: AnswerRow) {
try {
// 编辑弹层吃的是详情接口——它带的是该答卷**钉住版本**的 groups,不是当前发布版本
editDetail.value = await dataApi.detail(row.answerId).send();
} catch (e: any) {
Message.error(e?.message || "答卷详情取不到");
}
}
/** 等值筛选项的候选值:选项类字段(select/radio/checkbox/multiselect)的已展开 options(契约 §2.1 内联) */
function eqOptionsFor(key: string): Array<{ label: string; value: string }> {
const field = (current.value?.fields || []).find((f: DesignField) => f.key === key);
const options = field?.options;
return Array.isArray(options) ? options : [];
}
const statusModal = ref<{ visible: boolean; answerId: string; target: string; current: string }>({
visible: false,
answerId: "",
target: "",
current: ""
});
function openStatus(row: AnswerRow) {
if (!liveStatusDefs.value.length) {
Message.warning("该表单没有可用状态定义");
return;
}
statusModal.value = {
visible: true,
answerId: row.answerId,
target: row.status || liveStatusDefs.value[0].code,
current: statusDisplay(row.status, row.statusLabel)
};
}
async function submitStatus() {
const { answerId, target } = statusModal.value;
statusModal.value.visible = false;
try {
await dataApi.changeStatus(answerId, target).send();
Message.success("已改状态并写 status_log");
await reload();
} catch (e: any) {
Message.error(e?.message || "改状态失败");
}
}
function softDeleteOne(row: AnswerRow) {
Modal.confirm({
title: "确认软删这条答卷?",
content: "软删是唯一的删除形态(无物理删除接口):列表/详情/统计/导出都不再可见,索引行同步删,限填名额释放。",
onOk: async () => {
try {
await dataApi.softDelete(row.answerId).send();
Message.success("已软删");
await reload();
} catch (e: any) {
Message.error(e?.message || "软删失败");
}
}
});
}
function onSelectionChange(selection: RowVM[]) {
selected.value = selection.map((r) => r.answerId);
}
// ==================== 批量 ====================
function reportBatch(results: { answerId: string; success: boolean; msg: string | null }[], actionText: string) {
const failed = results.filter((r) => !r.success);
const okCount = results.length - failed.length;
if (!failed.length) {
Message.success(`${actionText} ${okCount} 条全部成功(后端逐条校验、逐条留痕)`);
return;
}
Modal.warning({
title: `${actionText}:成功 ${okCount} 条,失败 ${failed.length} 条`,
content: failed
.slice(0, 10)
.map((f) => `${f.answerId.slice(0, 8)}…:${f.msg || "未知原因"}`)
.join("\n") + (failed.length > 10 ? `\n…另有 ${failed.length - 10} 条` : ""),
okText: "知道了"
});
}
function checkedBatch(ids: string[], actionText: string, run: () => Promise<any>) {
if (!ids.length) {
Message.warning("先勾选要处理的答卷");
return;
}
if (ids.length > BATCH_LIMIT) {
Message.warning(`批量单次上限 ${BATCH_LIMIT} 条(后端 P29),当前选了 ${ids.length} 条,请减少选择`);
return;
}
Modal.confirm({
title: `确认${actionText} ${ids.length} 条?`,
content: `处理范围只覆盖${actionText}能覆盖到的行:不在你数据范围(当前=${scopeText.value})里的答卷会逐条被拒并点名原因,成功的部分照常留痕。`,
onOk: async () => {
try {
const results = await run();
reportBatch(results || [], actionText);
await reload();
} catch (e: any) {
Message.error(e?.message || `${actionText}失败`);
}
}
});
}
function batchDelete() {
checkedBatch(selected.value, "批量软删", () => dataApi.batchSoftDelete(selected.value).send());
}
const batchStatusModal = ref(false);
const batchStatusTarget = ref("");
function batchChangeStatus() {
if (!selected.value.length) {
Message.warning("先勾选要改状态的答卷");
return;
}
if (!liveStatusDefs.value.length) {
Message.warning("该表单没有可用状态定义");
return;
}
batchStatusTarget.value = liveStatusDefs.value[0].code;
batchStatusModal.value = true;
}
async function submitBatchStatus() {
const ids = selected.value;
batchStatusModal.value = false;
checkedBatch(ids, `批量改状态为「${liveStatusDefs.value.find((d) => d.code === batchStatusTarget.value)?.label}」`, () =>
dataApi.batchChangeStatus(ids, batchStatusTarget.value).send()
);
}
// ==================== 导出 ====================
/**
* 权限判定用的树(P76):app 上下文=宿主编进来的**目标应用树**(快应用的"导出"锚点行在这里),
* center 上下文=会话自己的树(按 `/admin/data` 页面行判,手册 §2.2 中心表没有导出行)。
* 两种上下文各自吃自己的树,`canExport` 的纯函数判据不变。
*/
const permsTree = computed(() => (props.context === "app" ? props.modules : store.modules));
const exportable = computed(() => canExport(permsTree.value, props.context === "app" ? "app" : "center"));
const exporting = ref(false);
function askExport() {
if (!formUuid.value) {
Message.warning("先选择表单");
return;
}
Modal.confirm({
title: "导出当前筛选结果",
content:
`范围=单表单 + 当前筛选(表单:${current.value?.name || formUuid.value})。` +
"行数口径是你这个数据范围(当前=" + scopeText.value + ")下符合筛选条件的**总数**,不是本页已加载的行数。" +
"导出是同步流式下载,不排任务、不给假进度。",
onOk: async () => {
exporting.value = true;
try {
const blob = await exportBlob(queryBody());
downloadBlob(blob, `${current.value?.code || "form"}-answers.xlsx`);
Message.success("已开始下载");
} catch (e: any) {
Message.error(e?.message || "导出失败");
} finally {
exporting.value = false;
}
}
});
}
// ==================== 触底加载(keyset 的 UI 形态) ====================
let scrollTarget: HTMLElement | null = null;
function onScroll() {
if (!scrollTarget || loading.value || !nextCursor.value) return;
const { scrollTop, clientHeight, scrollHeight } = scrollTarget;
if (scrollTop + clientHeight >= scrollHeight - 40) loadMore();
}
/** view-ui 定了 :height 后内部滚动在 .ivu-table-body,触底事件挂它而不是挂窗口 */
async function bindBottomScroll() {
await nextTick();
scrollTarget?.removeEventListener("scroll", onScroll);
scrollTarget = regionRef.value?.querySelector(".ivu-table-body") ?? null;
scrollTarget?.addEventListener("scroll", onScroll);
}
watch(rows, bindBottomScroll);
onMounted(bindBottomScroll);
onBeforeUnmount(() => scrollTarget?.removeEventListener("scroll", onScroll));
// ==================== 选择与初始化 ====================
async function selectForm(uuid: string) {
formUuid.value = uuid;
filterRows.value = [];
statusFilter.value = "";
rows.value = [];
nextCursor.value = null;
loadedOnce.value = false;
await Promise.all([chooseForm(uuid), loadFilterFields(uuid)]);
applyMemory();
await reload();
}
function addFilterRow() {
filterRows.value = [...filterRows.value, { key: "", value: "", from: "", to: "" }];
}
function removeFilterRow(index: number) {
filterRows.value = filterRows.value.filter((_, i) => i !== index);
}
function filterFieldOf(key: string): FilterField | undefined {
return filterFields.value.find((f) => f.key === key);
}
async function init() {
await loadCandidates();
const fromQuery = String(route.query.form || "");
const picked = candidates.value.find((c: { formUuid: string }) => c.formUuid === fromQuery) || candidates.value[0];
if (picked) await selectForm(picked.formUuid);
}
watch(
() => [props.context, props.appCode, props.codes.join(",")],
() => init()
);
onMounted(init);
</script>
<template>
<div :class="embedded ? 'data-bare' : 'page-wrapper yh-fill'">
<div :class="embedded ? '' : 'main-container'">
<div class="toolbar">
<Select v-model="formUuid" style="width: 260px" placeholder="选择表单" @on-change="selectForm">
<Option v-for="c in candidates" :key="c.formUuid" :value="c.formUuid"
:label="`${c.name}(${c.appCode}/${c.code})`" />
</Select>
<Input v-model="manualCode" search placeholder="按 code 直达" style="width: 200px" clearable
@on-search="useManualCode" />
<Select v-model="statusFilter" style="width: 140px" placeholder="状态" clearable @on-change="reload">
<Option value="">全部状态</Option>
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
</Select>
<Select v-model="order" style="width: 150px" @on-change="reload">
<Option value="desc">最新在前</Option>
<Option value="asc">最早在前</Option>
</Select>
<Button icon="md-refresh" @click="reload">刷新</Button>
<Button icon="md-settings" @click="colPicker = true">列设置</Button>
<span class="toolbar-note">
已加载 {{ rows.length }} 行{{ nextCursor ? "(还有下一页,触底继续取)" : "(已到末尾)" }}
</span>
</div>
<div v-if="filterFields.length || filterRows.length" class="filter-region">
<div v-for="(row, index) in filterRows" :key="`f_${index}`" class="filter-row">
<Select v-model="row.key" style="width: 190px" placeholder="筛选字段">
<Option v-for="f in filterFields" :key="f.key" :value="f.key"
:label="`${f.label}(${f.operator === 'EQ' ? '等值' : f.operator === 'RANGE' ? '区间' : '前缀'})`" />
</Select>
<template v-if="filterFieldOf(row.key)?.operator === 'EQ'">
<Select v-model="row.value" style="width: 200px" placeholder="选择值" clearable>
<Option v-for="opt in eqOptionsFor(row.key)" :key="opt.value" :value="opt.value"
:label="opt.label" />
</Select>
</template>
<template v-else-if="filterFieldOf(row.key)?.operator === 'RANGE'">
<Input v-model="row.from" style="width: 160px"
:placeholder="filterFieldOf(row.key)?.type === 'date' ? '起始日 YYYY-MM-DD' : '下界(含)'" />
<Input v-model="row.to" style="width: 160px"
:placeholder="filterFieldOf(row.key)?.type === 'date' ? '截止日 YYYY-MM-DD' : '上界(含)'" />
</template>
<template v-else>
<Input v-model="row.value" style="width: 200px" placeholder="前缀文本" clearable />
</template>
<Button icon="md-close" @click="removeFilterRow(index)">删</Button>
</div>
<div class="filter-actions">
<Button size="small" icon="md-add" @click="addFilterRow">加筛选条件</Button>
<Button size="small" type="primary" :disabled="!formUuid" @click="reload">应用筛选</Button>
<span class="toolbar-note">
候选=当前发布版本里 searchable 且有算子的字段(附件类不出现,P28);算子按字段类型定,不给自由表达式
</span>
</div>
</div>
<Alert v-if="candidateError" show-icon type="warning" class="region-tip">
表单清单:{{ candidateError }}(这通常是账号只授权了"数据管理"、没有"表单设计"模块行——
用上面"按 code 直达"仍可打开已知表单的数据)
</Alert>
<Alert v-else-if="loadedOnce" show-icon type="info" class="region-tip">
数据范围(后端契约 §8 矩阵 + customId 解析,前端只展示不再过滤):<b>{{ scopeText }}</b>
</Alert>
<div class="table-region" ref="regionRef">
<Table :columns="tableColumns" :data="rows" :height="regionH || undefined" :loading="loading"
no-data-text="没有符合条件的答卷(或还没选表单)" class="link-table"
@on-selection-change="onSelectionChange" />
</div>
<div class="page-panel">
<div class="fl ml-15 align-middle batch-bar">
<span class="toolbar-note">已选 {{ selected.length }} 条</span>
<Button size="small" :disabled="!selected.length" @click="batchDelete">批量软删</Button>
<Button size="small" :disabled="!selected.length" @click="batchChangeStatus">批量改状态</Button>
<Button v-if="exportable" size="small" icon="md-download" :loading="exporting"
@click="askExport">导出</Button>
<Tooltip v-else content="当前身份的授权模块集里没有导出权限位(V26:接口侧的后端拒是 T4 已验的另一半)" transfer>
<span class="toolbar-note">导出(无权限)</span>
</Tooltip>
</div>
<div class="fr page-hint">
<span class="toolbar-note">{{ loading ? "取下一页…" : nextCursor ? "向下滚动继续加载" : "已到最后一页" }}</span>
</div>
</div>
</div>
<Modal v-model="colPicker" title="可见列(按表单记忆)" :width="520" @on-cancel="colPicker = false">
<p class="toolbar-note">
勾选即刻写入本地记忆(键=归属应用 + 表单 code,V40);新出现的列会自动补进可见集,
历史已删字段列默认不铺满——需要看历史残留时再勾。
</p>
<div class="col-list">
<label v-for="col in allColumns" :key="col.key" class="col-item">
<Checkbox :model-value="visibleKeys.includes(col.key)" @on-change="toggleColumn(col.key)">
<span :class="col.removed ? 'cell-removed' : ''">{{ col.label }}</span>
<span class="col-filled">已填 {{ Math.round(col.filledRatio * 100) }}%</span>
</Checkbox>
</label>
</div>
<template #footer>
<Button @click="colPicker = false">关闭</Button>
</template>
</Modal>
<Modal v-model="batchStatusModal" title="批量改状态" :width="420">
<Form :label-width="90">
<FormItem label="目标状态">
<Select v-model="batchStatusTarget">
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
</Select>
</FormItem>
</Form>
<p class="toolbar-note">
逐条走单条校验与留痕(V39):{{ selected.length }} 条里不在你数据范围内的会被逐条拒。
</p>
<template #footer>
<Button @click="batchStatusModal = false">取消</Button>
<Button type="primary" @click="submitBatchStatus">确认</Button>
</template>
</Modal>
<Modal v-model="statusModal.visible" title="改状态" :width="420">
<Form :label-width="90">
<FormItem label="当前状态">
<span>{{ statusModal.current }}</span>
</FormItem>
<FormItem label="目标状态">
<Select v-model="statusModal.target">
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
</Select>
</FormItem>
</Form>
<p class="toolbar-note">
状态是标记不是流转:不校验从哪个状态到哪个状态、无条件分支(方案 §5.3 红线、V15);改动写 status_log。
</p>
<template #footer>
<Button @click="statusModal.visible = false">取消</Button>
<Button type="primary" @click="submitStatus">确认</Button>
</template>
</Modal>
<AnswerEditModal v-if="editDetail" :detail="editDetail" mode="admin" @close="editDetail = null"
@saved="reload" />
</div>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar-note {
color: #808695;
font-size: 12px;
}
.filter-region {
margin-bottom: 12px;
padding: 10px 12px;
background: #f8f8f9;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.filter-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.region-tip {
margin-bottom: 12px;
}
.batch-bar {
display: flex;
align-items: center;
gap: 8px;
}
.page-hint {
display: flex;
align-items: center;
}
.col-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 46vh;
overflow-y: auto;
}
.col-item {
display: flex;
align-items: center;
}
.col-filled {
color: #c5c8ce;
font-size: 12px;
margin-left: 6px;
}
</style>
<style>
.cell-removed {
color: #ff9900;
}
.status-options {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.status-option {
display: flex;
align-items: center;
gap: 6px;
}
.status-note {
color: #808695;
font-size: 12px;
}
</style>