Files
form-center-front/src/views/admin/Data.vue
T
chenxing 6401a69c18 t14:前端模块行退役——左导航三支路自解析、AppContext 重构为通用壳两视图
- App.vue:引导只剩 GetUserInfo,之后并行取 fillable/managed/capabilities 三支构造导航
  (取不到按空集/false 走,fail-closed,不造兜底菜单树);摘 GetUserMod 调用与 setModules
- store:modules/IModule 状态与 setModules 删除,改存三支路数据 + managesForm() 判"这张表我管不管得动"
- AppContext.vue:重写为通用壳两视图(§5.1)——管理员视图=数据列表+复制填报路径
  (添加/导入 T18 接进这一档),填报者视图=仅填报 tab;判定只看 managed 是否非空,
  壳零自有权限逻辑;GetUserMod?appCode= 拉目标应用树那一路与 :modules prop 链整条拆除
- Data.vue:导出按钮改"该表单 ALL 档(managed 成员)",中心与快应用两种上下文从此同一条判据;
  AnswerDetail.vue 的管理动作区同判据;utils/modulePerms.ts(canExport/hasPage)删除
- utils/menuNav.ts:buildNavTree 与"从树渲染菜单"三条规则删除,只留导航数据结构与
  高亮/面包屑/默认跳转;menuRoute 的 /f/{code} 形态识别按任务书保留(分享直链/历史链接)
- mock:baseapi 去 GetUserMod 与两棵模块树;dataapi 补 /my/managed 与 /my/capabilities 桩
2026-10-20 13:21:23 +08:00

769 lines
30 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 红线)。
*
* 快应用上下文(通用壳)复用本组件:传 `context="app"` + `app-code` + `codes`
* (候选由壳按"我管理的表单 ∩ 该应用"算出,经 resolve 拿 uuid——定义管理权不在通用壳里,§3.4)。
* 导出按钮的显隐不再需要宿主传任何权限载体:本组件自己按 `my/managed` 清单判 ALL 档(契约 v2 §2.4)。
*/
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 { managesForm, useDefaultStore } from "@/store";
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";
/** 行视图 + 勾选态(`_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[];
/**
* 被宿主页面内嵌(快应用上下文 /app/:appCode/)时置 true:只渲染内容本体,
* 不再套一层 `.page-wrapper > .main-container`——否则会出现双层白卡与双份内边距。
*/
embedded?: boolean;
}>(),
{ context: "center", appCode: "", codes: () => [], 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 && p.row.subjectId !== p.row.submitterId ? `,subject_id=${p.row.subjectId}(他人代填)` : ",无代填主体"}`,
transfer: true
}, {
default: () => [h2("span", `提交人 ${p.row.submitterId}${p.row.subjectId && p.row.subjectId !== p.row.submitterId ? `|主体 ${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()
);
}
// ==================== 导出 ====================
/**
* 导出按钮显隐(契约 v2 §2.4:`modulePerms.canExport` 随模块行退役删除):
* 判据换成"<b>该表单 ALL 档持有</b>"的清单形态——当前选中的这张表在 `my/managed` 里,就画导出。
* 中心与快应用两种上下文从此**同一条判据**(不再有"两棵树"的差别,B13/P78 遗迹清零);
* 接口侧的 403 兜底仍在后端守卫,这里只决定画不画。
*/
const exportable = computed(() =>
managesForm(store.managed, current.value?.appCode || props.appCode, current.value?.code)
);
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>