P76(T8 验收遗留):快应用上下文的导出按钮此前恒按中心会话树判"导出"锚点 (Data.vue exportable 读 store.modules),锚点判定不生效(fail-closed 方向, 有授权也不画按钮)。本轮把 AppContext 拉到的**目标应用树**经 modules prop 下传, Data.vue 的权限判据改为 permsTree(app=应用树 / center=会话树),canExport 纯函数不动。 Stats.vue 未接:本页无锚点判定也无 store.modules 引用,加 prop 是死代码(差异记 T9 报告)。 P77(T8 验收遗留):应用上下文内的"详情"不再跳回中心路由 /admin/data/detail。 形态二选一取**子路由 /app/:appCode/data/detail**(同一个 AnswerDetail.vue): 地址保留 uuid 可深链/刷新、详情页只有一份实现、back() 本就按 app 回应用数据视图, 写 parentPath 反而造假(menuRoute 的 ROUTE_PATHS 只收静态 path,应用段导航在页内轻导航)。 goDetail 按 props.context 分派;back() 改读 route.params.appCode,撤掉 query.app 双轨。 观察项①(T8 验收记档):确认跨触底页勾选**会被清空**——view-ui-plus 的 data 监听器 用 makeObjBaseData 重建 objData,_isChecked 只认行上的 _checked(点选不回写行), 整组替换后勾选视觉与 getSelection 归零而"已选 N 条"仍累计。新增 withSelectionState() 在 reload/loadMore 两条装载路径按 selected 回填 _checked,selected 为唯一事实源。 演练桩(本地件,dev:mock 生效):baseapi 增 fc-drill 快应用模块树(方案 §6.2 五行含 导入/导出两个 path 空锚点),dataapi 增 /api/form/resolve 桩(快应用数据视图唯一的 合规取数路径是 code→resolve→uuid,契约 §1)。其余外指 appCode 仍旧返回空树(P3 口径)。 门禁:npm run build ✓ / npm run type-check 0 错 ✓; t1~t9 全量复跑:t5 13/0/1、t6 22/0/2、t7 12/0/0、t8 21/0/1、t9 12/0/1 (skip 均为 headless Edge --dump-dom 吐 0 字节的 P73 环境事实,不假通过)。 本提交含 T6~T8 三轮堆在工作区的既有改动(沿 T8 验收 §5 推送分层)。
This commit is contained in:
+770
-6
@@ -1,11 +1,775 @@
|
||||
<script setup lang="ts">
|
||||
import PageStub from "@/components/PageStub.vue";
|
||||
/**
|
||||
* 数据列表 /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`
|
||||
* (候选来自该应用模块树的 `/f/{code}` 行,经 resolve 拿 uuid——定义管理权不在快应用上下文里,§3.4)
|
||||
* + `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(来自该应用模块树的 /f/{code} 行) */
|
||||
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>
|
||||
<PageStub
|
||||
title="数据管理"
|
||||
stage="T8"
|
||||
note="两段式列表 + keyset(提交时间序)、可见列选择记忆、批量软删/改状态、按钉住版本编辑在 T8 落地;数据范围(scope)由后端裁决,前端只消费。"
|
||||
/>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user