20260804223152
This commit is contained in:
1 parent
75b1ccaa6d
commit
7c9761a4fc
71 files changed
+5534
-9113
No files matched your search
@@ -1,27 +1,36 @@
|
||||
<script setup>
|
||||
import { computed, h, nextTick, onActivated, ref, useSlots, watch } from "vue";
|
||||
import {
|
||||
computed,
|
||||
h,
|
||||
nextTick,
|
||||
onActivated,
|
||||
reactive,
|
||||
ref,
|
||||
shallowRef,
|
||||
useSlots,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { message } from "antdv-next";
|
||||
import { Columns3, RefreshCw, Save } from "@lucide/vue";
|
||||
import { saveObjectApi } from "@/services/api";
|
||||
import { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from "@/services/api";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { usePermissionStore } from "@/stores/permissions";
|
||||
import { useModuleRuntime } from "@/module-runtime/useModuleRuntime";
|
||||
import { useModuleDataSource } from "@/module-runtime/useModuleDataSource";
|
||||
import { useModuleEditor } from "@/module-runtime/useModuleEditor";
|
||||
import { createModuleActionRegistry } from "@/module-runtime/actionRegistry";
|
||||
import {
|
||||
identifierRe,
|
||||
buildQueryCondition,
|
||||
defaultQueryValue,
|
||||
escapeSqlText,
|
||||
parseOptions,
|
||||
requireModuleId,
|
||||
} from "./moduleListUtils";
|
||||
import FmsModuleTable from "./FmsModuleTable.vue";
|
||||
import FmsQueryToolbar from "./FmsQueryToolbar.vue";
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, default: "" },
|
||||
profileId: { type: String, default: "" },
|
||||
pageNodeId: { type: String, default: "" },
|
||||
dataCode: { type: String, default: "" },
|
||||
fixedSearchCondition: { type: String, default: "" },
|
||||
columnExtensions: { type: Object, default: () => ({}) },
|
||||
rowActions: { type: Array, default: () => [] },
|
||||
actionHandlers: { type: Object, default: () => ({}) },
|
||||
rowSelection: { type: Object, default: null },
|
||||
rowClassName: { type: Function, default: undefined },
|
||||
expandable: { type: [Boolean, Object], default: false },
|
||||
@@ -46,94 +55,39 @@ const emit = defineEmits([
|
||||
const slots = useSlots();
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const tableRef = ref(null);
|
||||
// keep-alive 中的缓存实例必须保留各自的路由上下文,不能跟随全局 route 变化。
|
||||
const instanceRoutePath = route.path;
|
||||
const instanceRouteDataCode = route.meta.dataCode;
|
||||
|
||||
// ── 内部状态 ──
|
||||
const dataModule = ref(null);
|
||||
const activeFields = shallowRef([]);
|
||||
const activeFieldById = shallowRef(new Map());
|
||||
const listConfig = shallowRef([]);
|
||||
const editConfigById = shallowRef(new Map());
|
||||
const queryItems = shallowRef([]);
|
||||
const dataSource = shallowRef([]);
|
||||
const searchConditions = shallowRef([]);
|
||||
const keyField = ref("");
|
||||
// 可编辑表格:脏行追踪(rowKey -> Set<已修改字段>),仅在 editable 模式下使用。
|
||||
const dirtyRows = shallowRef(new Map());
|
||||
// 可编辑表格:未持久化的新增行 rowKey 集合(新增行保存时作为 inserts 提交)。
|
||||
const newRowKeys = shallowRef(new Set());
|
||||
const saving = ref(false);
|
||||
const configLoading = ref(false);
|
||||
const dataLoading = ref(false);
|
||||
const columnSaving = ref(false);
|
||||
|
||||
function canModuleAction(actionCode) {
|
||||
if (!actionCode || !dataModule.value) return false;
|
||||
return permissionStore.canAction(dataModule.value.b_module_id, actionCode);
|
||||
}
|
||||
|
||||
const moduleRuntime = useModuleRuntime({
|
||||
orgId: () => authStore.orgId,
|
||||
moduleId: () => props.moduleId,
|
||||
profileId: () => props.profileId,
|
||||
pageNodeId: () => props.pageNodeId,
|
||||
routePath: instanceRoutePath,
|
||||
const configError = ref("");
|
||||
const dataError = ref("");
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: Number(props.pageSize) || 20,
|
||||
total: 0,
|
||||
});
|
||||
const {
|
||||
dataModule,
|
||||
activeFields,
|
||||
activeFieldById,
|
||||
listConfig,
|
||||
formConfigByFieldId: editConfigById,
|
||||
queryItems,
|
||||
defaultSearchConditions,
|
||||
groups,
|
||||
actions,
|
||||
keyField,
|
||||
loading: configLoading,
|
||||
error: configError,
|
||||
} = moduleRuntime;
|
||||
|
||||
const moduleDataSource = useModuleDataSource({
|
||||
dataModule,
|
||||
activeFieldById,
|
||||
listConfig,
|
||||
keyField,
|
||||
runtimeKey: moduleRuntime.runtimeKey,
|
||||
fixedSearchCondition: () => props.fixedSearchCondition,
|
||||
pageSize: () => props.pageSize,
|
||||
});
|
||||
const {
|
||||
dataSource,
|
||||
loading: dataLoading,
|
||||
error: dataError,
|
||||
pagination,
|
||||
fetchData,
|
||||
search,
|
||||
resetSearch,
|
||||
changePage,
|
||||
reload,
|
||||
} = moduleDataSource;
|
||||
|
||||
const moduleEditor = useModuleEditor({
|
||||
dataModule,
|
||||
activeFields,
|
||||
activeFieldById,
|
||||
listConfig,
|
||||
keyField,
|
||||
dataSource,
|
||||
editable: () => props.editable,
|
||||
rowDraggable: () => props.rowDraggable,
|
||||
orderField: () => props.orderField,
|
||||
loading: () => configLoading.value || dataLoading.value,
|
||||
canAction: canModuleAction,
|
||||
reload: fetchData,
|
||||
getTable: () => tableRef.value,
|
||||
onDirtyChange: (count) => emit("dirty-change", { count }),
|
||||
});
|
||||
const {
|
||||
dirtyRows,
|
||||
saving,
|
||||
readonlyFields,
|
||||
resolveRowKey,
|
||||
handleCellChange,
|
||||
addRow,
|
||||
saveChanges,
|
||||
deleteSelected,
|
||||
handleRowOrderChange,
|
||||
} = moduleEditor;
|
||||
let dataRequestVersion = 0;
|
||||
|
||||
const loading = computed(() => configLoading.value || dataLoading.value);
|
||||
const permittedRowActions = computed(() =>
|
||||
props.rowActions.filter((action) => canModuleAction(action.permissionCode || action.key)),
|
||||
);
|
||||
|
||||
// ── 外部工具 ──
|
||||
const moreMenuItems = computed(() => [
|
||||
@@ -156,38 +110,293 @@ const moreMenuItems = computed(() => [
|
||||
]);
|
||||
const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.startsWith("cell-")));
|
||||
|
||||
const actionRegistry = createModuleActionRegistry(() => ({
|
||||
moduleId: dataModule.value?.b_module_id,
|
||||
profileId: dataModule.value?.b_profile_id,
|
||||
}));
|
||||
actionRegistry.register("create", () => addRow());
|
||||
actionRegistry.register("update", () => saveChanges());
|
||||
actionRegistry.register("delete", ({ rowKeys } = {}) => deleteSelected(rowKeys));
|
||||
actionRegistry.register("refresh", () => reload());
|
||||
actionRegistry.register("reset-columns", () => resetColumns());
|
||||
actionRegistry.register("save-column-settings", () => saveColumnSettings());
|
||||
|
||||
async function runAction(key, payload) {
|
||||
const customHandler = props.actionHandlers?.[key];
|
||||
if (typeof customHandler === "function") {
|
||||
return customHandler(payload, {
|
||||
module: dataModule.value,
|
||||
profileId: dataModule.value?.b_profile_id,
|
||||
reload,
|
||||
// ── 模块配置加载 ──
|
||||
function resolveKeyField(module, fields) {
|
||||
if (identifierRe.test(module.b_keyfield || "")) return module.b_keyfield;
|
||||
for (const field of fields) {
|
||||
if (field.b_field === "b_id") return field.b_field;
|
||||
}
|
||||
for (const field of fields) {
|
||||
if (identifierRe.test(field.b_field || "")) return field.b_field;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getDataCode() {
|
||||
return String(props.dataCode || instanceRouteDataCode || "").trim();
|
||||
}
|
||||
|
||||
async function loadModuleConfig() {
|
||||
const code = getDataCode();
|
||||
configLoading.value = true;
|
||||
configError.value = "";
|
||||
|
||||
try {
|
||||
let runtimeData = null;
|
||||
|
||||
if (code) {
|
||||
const moduleRes = await loadDataApi(
|
||||
"s_module",
|
||||
`b_code = N'${escapeSqlText(code)}' AND b_module_type = 'data' AND b_canuse = 1`,
|
||||
"b_id ASC",
|
||||
);
|
||||
runtimeData = moduleRes.data?.[0];
|
||||
if (!runtimeData) throw new Error(`未找到编码为 ${code} 的启用数据模块`);
|
||||
|
||||
// 向上追溯找到所属的 page(可能经过 data_category 层级)
|
||||
let parentId = requireModuleId(runtimeData.b_parent_id);
|
||||
let page = null;
|
||||
const maxDepth = 10;
|
||||
for (let i = 0; i < maxDepth; i++) {
|
||||
const res = await loadDataApi(
|
||||
"s_module",
|
||||
`b_id = ${parentId} AND b_canuse = 1`,
|
||||
"b_id ASC",
|
||||
);
|
||||
const node = res.data?.[0];
|
||||
if (!node) break;
|
||||
if (node.b_module_type === "page") {
|
||||
page = node;
|
||||
break;
|
||||
}
|
||||
if (node.b_parent_id == null) break;
|
||||
parentId = requireModuleId(node.b_parent_id);
|
||||
}
|
||||
if (!page) {
|
||||
throw new Error(`数据编码 ${runtimeData.b_code} 没有对应的启用页面`);
|
||||
}
|
||||
} else {
|
||||
const routePath = escapeSqlText(instanceRoutePath);
|
||||
const pageRes = await loadDataApi(
|
||||
"s_module",
|
||||
`b_route = N'${routePath}' AND b_canuse = 1`,
|
||||
"b_id ASC",
|
||||
);
|
||||
const page = pageRes.data?.[0];
|
||||
if (!page) throw new Error(`未找到路由 ${instanceRoutePath} 对应的启用模块`);
|
||||
|
||||
if (page.b_viewtable) {
|
||||
runtimeData = page;
|
||||
} else {
|
||||
// 递归查找 page 下首个有 b_viewtable 的 data 模块(可能经过 data_category 层级)
|
||||
const queue = [requireModuleId(page.b_id)];
|
||||
const visited = new Set();
|
||||
let found = false;
|
||||
while (queue.length && !found) {
|
||||
const curId = queue.shift();
|
||||
if (visited.has(curId)) continue;
|
||||
visited.add(curId);
|
||||
const childrenRes = await loadDataApi(
|
||||
"s_module",
|
||||
`b_parent_id = ${curId} AND b_canuse = 1`,
|
||||
"b_xh ASC, b_id ASC",
|
||||
);
|
||||
for (const child of childrenRes.data || []) {
|
||||
if (child.b_module_type === "data" && child.b_viewtable) {
|
||||
runtimeData = child;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (child.b_module_type === "data_category") {
|
||||
queue.push(requireModuleId(child.b_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!runtimeData) throw new Error("当前页面没有配置可用的数据模块");
|
||||
}
|
||||
}
|
||||
|
||||
const dataId = requireModuleId(runtimeData.b_id);
|
||||
// 列显示配置:普通模式取列表配置,可编辑模式取编辑配置,二者只取其一。
|
||||
const listTable = props.editable ? "s_module_field_edit" : "s_module_field_list";
|
||||
const responses = await Promise.all([
|
||||
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
|
||||
loadDataApi(listTable, `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"),
|
||||
loadDataApi(
|
||||
"s_module_field_query",
|
||||
`b_module_id = ${dataId} AND b_canuse = 1`,
|
||||
"b_xh ASC, b_id ASC",
|
||||
),
|
||||
]);
|
||||
if (getDataCode() !== code) return false;
|
||||
|
||||
const fields = [];
|
||||
const fieldById = new Map();
|
||||
for (const field of responses[0].data || []) {
|
||||
if (field.b_canuse !== 1 || !identifierRe.test(field.b_field || "")) continue;
|
||||
fields.push(field);
|
||||
fieldById.set(String(field.b_id), field);
|
||||
}
|
||||
|
||||
// 可编辑模式下:记录编辑配置的 readonly/disabled,用于生成只读编辑器。
|
||||
const editConfigByIdMap = new Map();
|
||||
for (const item of props.editable ? responses[1].data || [] : []) {
|
||||
if (item.b_visible !== 1) continue;
|
||||
if (!fieldById.has(String(item.b_field_id))) continue;
|
||||
editConfigByIdMap.set(String(item.b_field_id), item);
|
||||
}
|
||||
editConfigById.value = editConfigByIdMap;
|
||||
|
||||
// 列表配置:普通模式为 s_module_field_list,可编辑模式为 s_module_field_edit。
|
||||
const lists = [];
|
||||
for (const item of responses[1].data || []) {
|
||||
if (item.b_visible !== 1) continue;
|
||||
if (!fieldById.has(String(item.b_field_id))) continue;
|
||||
lists.push(item);
|
||||
}
|
||||
|
||||
const queries = [];
|
||||
for (const config of responses[2].data || []) {
|
||||
const field = fieldById.get(String(config.b_field_id));
|
||||
if (!field) continue;
|
||||
const optionInfo = parseOptions(field);
|
||||
queries.push({
|
||||
config,
|
||||
field,
|
||||
optionConfig: optionInfo.optionConfig,
|
||||
options: optionInfo.options,
|
||||
});
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
for (const item of queries) {
|
||||
const condition = buildQueryCondition(item, defaultQueryValue(item));
|
||||
if (condition) conditions.push(condition);
|
||||
}
|
||||
|
||||
dataModule.value = runtimeData;
|
||||
activeFields.value = fields;
|
||||
activeFieldById.value = fieldById;
|
||||
listConfig.value = lists;
|
||||
queryItems.value = queries;
|
||||
searchConditions.value = conditions;
|
||||
keyField.value = resolveKeyField(runtimeData, fields);
|
||||
dirtyRows.value = new Map();
|
||||
newRowKeys.value = new Set();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (getDataCode() === code) {
|
||||
configError.value = error?.message || String(error);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (getDataCode() === code) {
|
||||
configLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据查询 ──
|
||||
function resolveOrderBy() {
|
||||
if (!dataModule.value) throw new Error("数据模块尚未加载");
|
||||
const configured = String(dataModule.value.b_orderfield || "").trim();
|
||||
if (configured) return configured;
|
||||
if (keyField.value) return `${keyField.value} DESC`;
|
||||
return "b_id DESC";
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
if (!dataModule.value || !dataModule.value.b_viewtable) return;
|
||||
|
||||
const currentModule = dataModule.value;
|
||||
const viewName = currentModule.b_viewtable;
|
||||
const currentCode = getDataCode();
|
||||
const requestVersion = ++dataRequestVersion;
|
||||
const conditions = [];
|
||||
const fixedCondition = String(props.fixedSearchCondition || "").trim();
|
||||
if (fixedCondition) conditions.push(`(${fixedCondition})`);
|
||||
conditions.push(...searchConditions.value);
|
||||
dataLoading.value = true;
|
||||
dataError.value = "";
|
||||
|
||||
try {
|
||||
const res = await pageDataApi({
|
||||
viewName,
|
||||
orderBy: resolveOrderBy(),
|
||||
searchColumns: [],
|
||||
searchCondition: conditions.join(" AND "),
|
||||
pageNo: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
|
||||
if (requestVersion !== dataRequestVersion || dataModule.value !== currentModule) return;
|
||||
if (getDataCode() !== currentCode) return;
|
||||
dataSource.value = res.data?.rows || [];
|
||||
pagination.total = Number(res.data?.total ?? res.data?.row_count) || 0;
|
||||
} catch (error) {
|
||||
if (
|
||||
requestVersion === dataRequestVersion &&
|
||||
dataModule.value === currentModule &&
|
||||
getDataCode() === currentCode
|
||||
) {
|
||||
dataSource.value = [];
|
||||
pagination.total = 0;
|
||||
dataError.value = error?.message || String(error);
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
requestVersion === dataRequestVersion &&
|
||||
dataModule.value === currentModule &&
|
||||
getDataCode() === currentCode
|
||||
) {
|
||||
dataLoading.value = false;
|
||||
}
|
||||
}
|
||||
return actionRegistry.run(key, payload);
|
||||
}
|
||||
|
||||
// ── 公开方法 ──
|
||||
async function initialize() {
|
||||
moduleRuntime.reset();
|
||||
moduleDataSource.reset();
|
||||
moduleEditor.reset();
|
||||
const loaded = await moduleRuntime.load();
|
||||
if (!loaded) return;
|
||||
moduleDataSource.reset(defaultSearchConditions.value);
|
||||
await fetchData();
|
||||
dataRequestVersion++;
|
||||
dataModule.value = null;
|
||||
activeFields.value = [];
|
||||
activeFieldById.value = new Map();
|
||||
listConfig.value = [];
|
||||
editConfigById.value = new Map();
|
||||
queryItems.value = [];
|
||||
searchConditions.value = [];
|
||||
keyField.value = "";
|
||||
dirtyRows.value = new Map();
|
||||
newRowKeys.value = new Set();
|
||||
saving.value = false;
|
||||
dataLoading.value = false;
|
||||
dataError.value = "";
|
||||
pagination.current = 1;
|
||||
dataSource.value = [];
|
||||
pagination.total = 0;
|
||||
|
||||
const loaded = await loadModuleConfig();
|
||||
if (loaded) await fetchData();
|
||||
}
|
||||
|
||||
function search({ entries = [] }) {
|
||||
if (loading.value) return;
|
||||
const conditions = [];
|
||||
for (const entry of entries) {
|
||||
const condition = buildQueryCondition(entry.item, entry.value);
|
||||
if (condition) conditions.push(condition);
|
||||
}
|
||||
searchConditions.value = conditions;
|
||||
pagination.current = 1;
|
||||
return fetchData();
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
if (loading.value) return;
|
||||
searchConditions.value = [];
|
||||
pagination.current = 1;
|
||||
return fetchData();
|
||||
}
|
||||
|
||||
function changePage(page, pageSize) {
|
||||
if (loading.value) return;
|
||||
pagination.current = pageSize === pagination.pageSize ? page : 1;
|
||||
pagination.pageSize = pageSize;
|
||||
return fetchData();
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (loading.value) return;
|
||||
return fetchData();
|
||||
}
|
||||
|
||||
async function resetColumns() {
|
||||
@@ -216,14 +425,15 @@ async function saveColumnSettings() {
|
||||
|
||||
columnSaving.value = true;
|
||||
try {
|
||||
// 可编辑表格的列来自编辑配置,栏位设置保存到 s_module_field_edit;普通模式保存到列表配置。
|
||||
const table = props.editable ? "s_module_field_edit" : "s_module_field_list";
|
||||
await saveObjectApi([
|
||||
{
|
||||
table: "s_module_field_list",
|
||||
table,
|
||||
key_field: "b_id",
|
||||
updates,
|
||||
},
|
||||
]);
|
||||
moduleRuntime.invalidate();
|
||||
|
||||
const currentById = new Map();
|
||||
for (const row of listConfig.value) {
|
||||
@@ -246,14 +456,269 @@ async function saveColumnSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoreAction({ key }) {
|
||||
try {
|
||||
await runAction(key);
|
||||
if (key === "reset-columns") {
|
||||
message.success(appStore.t("common.columnSettingsReset", "已恢复栏位设置"));
|
||||
function resolveRowKey(row) {
|
||||
if (keyField.value) return row[keyField.value];
|
||||
return JSON.stringify(row);
|
||||
}
|
||||
|
||||
// ── 可编辑表格:脏行追踪 / 保存 / 批量删除 ──
|
||||
// 主键与系统审计字段(由后端维护)在可编辑表格中一律只读。
|
||||
const SYSTEM_FIELDS = ["b_inputdatetime", "b_inputuser_id", "b_updatedatetime", "b_updateuser_id"];
|
||||
const readonlyFields = computed(() => {
|
||||
const fields = new Set(SYSTEM_FIELDS);
|
||||
if (keyField.value) fields.add(keyField.value);
|
||||
return [...fields];
|
||||
});
|
||||
|
||||
function handleCellChange({ row, field }) {
|
||||
if (!props.editable || !keyField.value) return;
|
||||
const rowKey = String(resolveRowKey(row));
|
||||
const current = dirtyRows.value.get(rowKey);
|
||||
if (current) {
|
||||
current.add(field);
|
||||
} else {
|
||||
dirtyRows.value.set(rowKey, new Set([field]));
|
||||
}
|
||||
emit("dirty-change", { count: dirtyRows.value.size });
|
||||
}
|
||||
|
||||
function resolveSaveTable() {
|
||||
if (!dataModule.value) throw new Error("数据模块尚未加载");
|
||||
return String(dataModule.value.b_savetable || dataModule.value.b_viewtable || "");
|
||||
}
|
||||
|
||||
function dirtyUpdateRows() {
|
||||
if (!keyField.value) return [];
|
||||
const updates = [];
|
||||
const rowByKey = new Map(
|
||||
dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
|
||||
);
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
// 未持久化的新增行走 inserts 分支,避免对不存在的记录执行 UPDATE。
|
||||
if (newRowKeys.value.has(rowKey)) continue;
|
||||
const row = rowByKey.get(rowKey);
|
||||
if (!row) continue;
|
||||
const update = { [keyField.value]: row[keyField.value] };
|
||||
for (const field of fields) {
|
||||
if (field === keyField.value) continue;
|
||||
update[field] = row[field];
|
||||
}
|
||||
updates.push(update);
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
// 未持久化的新增行:仅收集有实际修改的行,作为 inserts 提交。
|
||||
function dirtyInsertRows() {
|
||||
if (!keyField.value) return [];
|
||||
const rowByKey = new Map(
|
||||
dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
|
||||
);
|
||||
const inserts = [];
|
||||
for (const rowKey of newRowKeys.value) {
|
||||
const fields = dirtyRows.value.get(rowKey);
|
||||
const row = rowByKey.get(rowKey);
|
||||
if (!fields || !row) continue;
|
||||
const insert = { [keyField.value]: row[keyField.value] };
|
||||
for (const field of fields) {
|
||||
if (field === keyField.value) continue;
|
||||
insert[field] = row[field];
|
||||
}
|
||||
inserts.push(insert);
|
||||
}
|
||||
return inserts;
|
||||
}
|
||||
|
||||
// 找到第一个可编辑的列字段(按编辑配置顺序,跳过只读列、主键与 checkbox),
|
||||
// 用于新增行后定位光标聚焦的单元格。
|
||||
function firstEditableField() {
|
||||
const readonly = new Set(readonlyFields.value);
|
||||
for (const config of listConfig.value) {
|
||||
const field = activeFieldById.value.get(String(config.b_field_id));
|
||||
if (!field) continue;
|
||||
if (readonly.has(field.b_field)) continue;
|
||||
if (field.b_type === "checkbox") continue;
|
||||
return field.b_field;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 可编辑模式下新增一行:生成雪花主键,追加到表尾并让第一个可编辑单元格进入编辑态并聚焦。
|
||||
async function addRow() {
|
||||
if (!props.editable || saving.value || loading.value) return;
|
||||
if (!keyField.value) {
|
||||
message.error("该模块未配置主键,无法新增");
|
||||
return;
|
||||
}
|
||||
const idRes = await nextIdApi();
|
||||
const newId = String(idRes.data);
|
||||
const newRow = { [keyField.value]: newId };
|
||||
// 排序字段取当前最大值 + 10;checkbox 字段默认 0。
|
||||
if (props.orderField && identifierRe.test(props.orderField)) {
|
||||
let maxXh = 0;
|
||||
for (const row of dataSource.value) {
|
||||
maxXh = Math.max(maxXh, Number(row[props.orderField]) || 0);
|
||||
}
|
||||
newRow[props.orderField] = maxXh + 10;
|
||||
}
|
||||
for (const field of activeFields.value) {
|
||||
if (field.b_field === keyField.value || field.b_field === props.orderField) continue;
|
||||
if (newRow[field.b_field] !== undefined) continue;
|
||||
if (field.b_type === "checkbox") newRow[field.b_field] = 0;
|
||||
}
|
||||
dataSource.value = [...dataSource.value, newRow];
|
||||
newRowKeys.value = new Set([...newRowKeys.value, newId]);
|
||||
await nextTick();
|
||||
// 聚焦第一个可编辑字段,让用户可直接输入。
|
||||
tableRef.value?.editRow?.(newId, firstEditableField());
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (saving.value) return;
|
||||
const table = resolveSaveTable();
|
||||
if (!table) {
|
||||
message.error("该模块未配置保存表,无法保存");
|
||||
return;
|
||||
}
|
||||
const updates = dirtyUpdateRows();
|
||||
const inserts = dirtyInsertRows();
|
||||
if (!updates.length && !inserts.length) {
|
||||
message.info("没有需要保存的修改");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const operation = { table, key_field: keyField.value };
|
||||
if (updates.length) operation.updates = updates;
|
||||
if (inserts.length) operation.inserts = inserts;
|
||||
await saveObjectApi([operation]);
|
||||
// 已提交的新增行不再视为未持久化。
|
||||
const insertedSet = new Set(inserts.map((row) => String(row[keyField.value])));
|
||||
newRowKeys.value = new Set(
|
||||
[...newRowKeys.value].filter((key) => !insertedSet.has(key)),
|
||||
);
|
||||
dirtyRows.value = new Map();
|
||||
// 保存成功后让正在编辑的行退出编辑态。
|
||||
tableRef.value?.clearEditing?.();
|
||||
message.success("保存成功");
|
||||
emit("dirty-change", { count: 0 });
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
message.error(error?.message || String(error));
|
||||
message.error("保存失败:" + (error?.message || error));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected(rowKeys) {
|
||||
if (saving.value || !rowKeys?.length) return;
|
||||
const table = resolveSaveTable();
|
||||
if (!table) {
|
||||
message.error("该模块未配置保存表,无法删除");
|
||||
return;
|
||||
}
|
||||
if (!keyField.value) {
|
||||
message.error("该模块未配置主键,无法删除");
|
||||
return;
|
||||
}
|
||||
// 未持久化的新增行直接本地移除,不调删除接口。
|
||||
const newSet = new Set(newRowKeys.value);
|
||||
const pendingKeys = rowKeys.filter((key) => newSet.has(String(key)));
|
||||
const persistedKeys = rowKeys.filter((key) => !newSet.has(String(key)));
|
||||
if (pendingKeys.length) {
|
||||
const pendingSet = new Set(pendingKeys.map((key) => String(key)));
|
||||
dataSource.value = dataSource.value.filter(
|
||||
(row) => !pendingSet.has(String(resolveRowKey(row))),
|
||||
);
|
||||
const remaining = new Map();
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
if (!pendingSet.has(rowKey)) remaining.set(rowKey, fields);
|
||||
}
|
||||
dirtyRows.value = remaining;
|
||||
newRowKeys.value = new Set(
|
||||
[...newRowKeys.value].filter((key) => !pendingSet.has(key)),
|
||||
);
|
||||
tableRef.value?.clearEditing?.();
|
||||
emit("dirty-change", { count: dirtyRows.value.size });
|
||||
}
|
||||
if (!persistedKeys.length) {
|
||||
message.success(`已删除 ${rowKeys.length} 条记录`);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{
|
||||
table,
|
||||
key_field: keyField.value,
|
||||
deletes: persistedKeys.map((key) => ({ [keyField.value]: key })),
|
||||
},
|
||||
]);
|
||||
// 清除被删行的脏记录
|
||||
const deletedSet = new Set(persistedKeys.map((key) => String(key)));
|
||||
const remaining = new Map();
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
if (!deletedSet.has(rowKey)) remaining.set(rowKey, fields);
|
||||
}
|
||||
dirtyRows.value = remaining;
|
||||
// 删除成功后让正在编辑的行退出编辑态。
|
||||
tableRef.value?.clearEditing?.();
|
||||
message.success(`已删除 ${rowKeys.length} 条记录`);
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
message.error("删除失败:" + (error?.message || error));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 行拖拽排序:回灌排序结果,按 orderField 重新编号并标记为脏,随保存一起提交。
|
||||
function handleRowOrderChange(orderedRows) {
|
||||
if (!props.rowDraggable || !props.orderField) return;
|
||||
if (!identifierRe.test(props.orderField)) return;
|
||||
const rowByOld = new Map(
|
||||
dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
|
||||
);
|
||||
const dirtyFieldsByKey = new Map();
|
||||
const nextRows = [];
|
||||
let order = 10;
|
||||
for (const row of orderedRows) {
|
||||
const key = String(resolveRowKey(row));
|
||||
const oldRow = rowByOld.get(key);
|
||||
const oldValue = oldRow ? Number(oldRow[props.orderField]) || 0 : 0;
|
||||
const copy = { ...row, [props.orderField]: order };
|
||||
nextRows.push(copy);
|
||||
if (oldValue !== order) {
|
||||
const fields = dirtyFieldsByKey.get(key) || new Set();
|
||||
fields.add(props.orderField);
|
||||
dirtyFieldsByKey.set(key, fields);
|
||||
}
|
||||
order += 10;
|
||||
}
|
||||
dataSource.value = nextRows;
|
||||
for (const [key, fields] of dirtyFieldsByKey) {
|
||||
const current = dirtyRows.value.get(key);
|
||||
if (current) {
|
||||
fields.forEach((field) => current.add(field));
|
||||
} else {
|
||||
dirtyRows.value.set(key, fields);
|
||||
}
|
||||
}
|
||||
emit("dirty-change", { count: dirtyRows.value.size });
|
||||
}
|
||||
|
||||
async function handleMoreAction({ key }) {
|
||||
if (key === "refresh") {
|
||||
await reload();
|
||||
return;
|
||||
}
|
||||
if (key === "reset-columns") {
|
||||
await resetColumns();
|
||||
message.success(appStore.t("common.columnSettingsReset", "已恢复栏位设置"));
|
||||
return;
|
||||
}
|
||||
if (key === "save-column-settings") {
|
||||
await saveColumnSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +735,7 @@ onActivated(async () => {
|
||||
tableRef.value?.refreshLayout?.();
|
||||
});
|
||||
|
||||
watch(() => [props.moduleId, props.profileId, props.pageNodeId], initialize);
|
||||
watch(() => props.dataCode, initialize);
|
||||
|
||||
watch(
|
||||
() => props.fixedSearchCondition,
|
||||
@@ -299,9 +764,6 @@ defineExpose({
|
||||
deleteSelected,
|
||||
addRow,
|
||||
dirtyRows,
|
||||
groups,
|
||||
actions,
|
||||
runAction,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -355,7 +817,7 @@ defineExpose({
|
||||
:data-source="dataSource"
|
||||
:row-key="resolveRowKey"
|
||||
:column-extensions="columnExtensions"
|
||||
:row-actions="permittedRowActions"
|
||||
:row-actions="rowActions"
|
||||
:row-selection="rowSelection"
|
||||
:row-class-name="rowClassName"
|
||||
:expandable="expandable"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, h, ref, shallowRef, useSlots, watch } from "vue";
|
||||
import { Checkbox } from "antdv-next";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { createFieldEditor, formatFieldValue } from "@/module-runtime/fieldRegistry";
|
||||
import { parseOptions } from "./moduleListUtils";
|
||||
import { formatDateValue, parseOptions } from "./moduleListUtils";
|
||||
|
||||
const props = defineProps({
|
||||
fields: { type: Array, default: () => [] },
|
||||
@@ -48,24 +48,49 @@ const expansionOptions = computed(() =>
|
||||
);
|
||||
const expandConfig = computed(() => ({ height: Number(expansionOptions.value.height) || 180 }));
|
||||
|
||||
function formatCellValue(value, field, optionInfo) {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
if (field.b_type === "date") return formatDateValue(value, false);
|
||||
if (field.b_type === "datetime") return formatDateValue(value, true);
|
||||
if (field.b_type === "checkbox") {
|
||||
return h(Checkbox, { checked: value === true || Number(value) === 1 });
|
||||
}
|
||||
if (field.b_type === "select") {
|
||||
const option = optionInfo.options.find((item) => String(item.value) === String(value));
|
||||
return option?.label ?? String(value);
|
||||
}
|
||||
if (field.b_type === "money") {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return String(value);
|
||||
const precision = Number.isInteger(optionInfo.optionConfig.precision)
|
||||
? optionInfo.optionConfig.precision
|
||||
: 2;
|
||||
return number.toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function resolveExtension(fieldName) {
|
||||
return props.columnExtensions?.[fieldName] || {};
|
||||
}
|
||||
|
||||
// 根据字段类型与编辑配置生成行内编辑器配置。
|
||||
function buildEditor(field, config) {
|
||||
const optionInfo = parseOptions(field);
|
||||
const editor = createFieldEditor(field, config, optionInfo, {
|
||||
moduleId: field.b_module_id,
|
||||
profileId: config?.b_profile_id,
|
||||
});
|
||||
if (
|
||||
props.readonlyFields.includes(field.b_field) ||
|
||||
(config && (Number(config.b_readonly) === 1 || Number(config.b_disabled) === 1))
|
||||
) {
|
||||
return { ...editor, disabled: true };
|
||||
return { type: "input", disabled: true };
|
||||
}
|
||||
return editor;
|
||||
if (field.b_type === "checkbox") return { type: "checkbox" };
|
||||
if (field.b_type === "select") {
|
||||
const { options } = parseOptions(field);
|
||||
return { type: "select", options };
|
||||
}
|
||||
return { type: "input" };
|
||||
}
|
||||
|
||||
function createRuntimeColumn(config, field) {
|
||||
@@ -73,7 +98,9 @@ function createRuntimeColumn(config, field) {
|
||||
const extension = resolveExtension(field.b_field);
|
||||
const { formatter, clickable, cellClassName, onClick: _onClick, ...columnProps } = extension;
|
||||
const slot = slots[`cell-${field.b_field}`];
|
||||
const editConfig = props.editable ? props.editConfigById.get(String(config.b_field_id)) : null;
|
||||
const editConfig = props.editable
|
||||
? props.editConfigById.get(String(config.b_field_id))
|
||||
: null;
|
||||
const column = {
|
||||
title: appStore.t(field.b_i18n, field.b_name || field.b_field),
|
||||
dataIndex: field.b_field,
|
||||
@@ -92,10 +119,7 @@ function createRuntimeColumn(config, field) {
|
||||
column.customCell = ({ row, cellValue, rowIndex, colIndex }) => {
|
||||
const formattedValue = formatter
|
||||
? formatter({ value: cellValue, record: row, field, config })
|
||||
: formatFieldValue(cellValue, field, optionInfo, {
|
||||
moduleId: field.b_module_id,
|
||||
profileId: config.b_profile_id,
|
||||
});
|
||||
: formatCellValue(cellValue, field, optionInfo);
|
||||
const cellClass =
|
||||
typeof cellClassName === "function"
|
||||
? cellClassName({ value: cellValue, record: row, field, config })
|
||||
@@ -328,9 +352,7 @@ defineExpose({
|
||||
@row-click="handleRowClick"
|
||||
@toggle-row-expand="handleToggleExpand"
|
||||
@change="handleCellChange"
|
||||
@row-order-change="
|
||||
(rows, sourceIndex, targetIndex) => emit('row-order-change', rows, sourceIndex, targetIndex)
|
||||
"
|
||||
@row-order-change="(rows, sourceIndex, targetIndex) => emit('row-order-change', rows, sourceIndex, targetIndex)"
|
||||
>
|
||||
<template v-if="expansionEnabled" #expand="{ row, col }">
|
||||
<slot name="expanded-row" :record="row" :column="col" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h, toRaw } from "vue";
|
||||
import { Input, InputNumber, Select } from "antdv-next";
|
||||
import { Input, Select } from "antdv-next";
|
||||
|
||||
const stop = (event) => event.stopPropagation();
|
||||
const inputDrafts = new WeakMap();
|
||||
@@ -44,15 +44,13 @@ function resolveOptions(editor) {
|
||||
|
||||
function displayValue(row, col, editor) {
|
||||
const value = row[col.dataIndex];
|
||||
const text = editor.format
|
||||
? editor.format(value)
|
||||
: editor.type === "select"
|
||||
const text =
|
||||
editor.type === "select"
|
||||
? (resolveOptions(editor).find((item) => String(item.value) === String(value))?.label ??
|
||||
value)
|
||||
: value;
|
||||
const content = text ?? "";
|
||||
const title = typeof content === "string" || typeof content === "number" ? String(content) : "";
|
||||
return h("span", { class: "fms-cell-display", title }, content);
|
||||
return h("span", { class: "fms-cell-display", title: String(content) }, content);
|
||||
}
|
||||
|
||||
function notifyChange(ctx, editor, row, col, value) {
|
||||
@@ -88,15 +86,9 @@ function createFocusHandler(ctx, row, col, selectText = false) {
|
||||
|
||||
function createInput(row, col, editor, ctx) {
|
||||
const focus = createFocusHandler(ctx, row, col, true);
|
||||
let currentValue = getInputDraft(row, col.dataIndex) ?? "";
|
||||
if (editor.inputType === "datetime-local") {
|
||||
currentValue = String(currentValue).replace(" ", "T").slice(0, 16);
|
||||
} else if (editor.inputType === "date") {
|
||||
currentValue = String(currentValue).slice(0, 10);
|
||||
}
|
||||
return h(Input, {
|
||||
type: editor.inputType || "text",
|
||||
value: currentValue,
|
||||
value: getInputDraft(row, col.dataIndex) ?? "",
|
||||
size: "small",
|
||||
class: "fms-cell-editor fms-cell-editor-root",
|
||||
"aria-label": col.title || col.dataIndex,
|
||||
@@ -104,10 +96,7 @@ function createInput(row, col, editor, ctx) {
|
||||
setInputDraft(row, col.dataIndex, value);
|
||||
},
|
||||
onBlur: (event) => {
|
||||
const value =
|
||||
editor.inputType === "datetime-local"
|
||||
? event.target.value.replace("T", " ")
|
||||
: event.target.value;
|
||||
const value = event.target.value;
|
||||
commitInputDraft(row, col.dataIndex, value);
|
||||
notifyChange(ctx, editor, row, col, value);
|
||||
},
|
||||
@@ -127,48 +116,6 @@ function createInput(row, col, editor, ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
function createTextarea(row, col, editor, ctx) {
|
||||
const focus = createFocusHandler(ctx, row, col, true);
|
||||
return h(Input.TextArea, {
|
||||
value: getInputDraft(row, col.dataIndex) ?? "",
|
||||
autoSize: { minRows: 1, maxRows: 4 },
|
||||
size: "small",
|
||||
class: "fms-cell-editor fms-cell-editor-root",
|
||||
"aria-label": col.title || col.dataIndex,
|
||||
"onUpdate:value": (value) => setInputDraft(row, col.dataIndex, value),
|
||||
onBlur: (event) => {
|
||||
const value = event.target.value;
|
||||
commitInputDraft(row, col.dataIndex, value);
|
||||
notifyChange(ctx, editor, row, col, value);
|
||||
},
|
||||
onClick: stop,
|
||||
onVnodeMounted: focus,
|
||||
onVnodeUpdated: focus,
|
||||
onVnodeBeforeUnmount: () => preserveInputDraft(row, col.dataIndex),
|
||||
});
|
||||
}
|
||||
|
||||
function createNumber(row, col, editor, ctx) {
|
||||
const focus = createFocusHandler(ctx, row, col, true);
|
||||
return h(InputNumber, {
|
||||
value: row[col.dataIndex] ?? null,
|
||||
precision: editor.precision,
|
||||
stringMode: editor.stringMode ?? true,
|
||||
size: "small",
|
||||
class: "fms-cell-editor fms-cell-editor-root",
|
||||
"aria-label": col.title || col.dataIndex,
|
||||
style: { width: "100%" },
|
||||
"onUpdate:value": (value) => {
|
||||
const resolved = value ?? null;
|
||||
row[col.dataIndex] = resolved;
|
||||
notifyChange(ctx, editor, row, col, resolved);
|
||||
},
|
||||
onClick: stop,
|
||||
onVnodeMounted: focus,
|
||||
onVnodeUpdated: focus,
|
||||
});
|
||||
}
|
||||
|
||||
function createSelect(row, col, editor, ctx) {
|
||||
const options = resolveOptions(editor);
|
||||
const focus = createFocusHandler(ctx, row, col);
|
||||
@@ -193,7 +140,7 @@ function createSelect(row, col, editor, ctx) {
|
||||
}
|
||||
|
||||
function createCheckbox(row, col, editor, ctx) {
|
||||
const checked = row[col.dataIndex] === true || Number(row[col.dataIndex]) === 1;
|
||||
const checked = row[col.dataIndex] === 1;
|
||||
const focus = ctx.isEditing(row) ? createFocusHandler(ctx, row, col) : undefined;
|
||||
const update = (value) => {
|
||||
row[col.dataIndex] = value;
|
||||
@@ -251,8 +198,6 @@ export function createEditableCell(col, ctx) {
|
||||
return displayValue(row, col, editor);
|
||||
}
|
||||
if (editor.type === "select") return createSelect(row, col, editor, ctx);
|
||||
if (editor.type === "textarea") return createTextarea(row, col, editor, ctx);
|
||||
if (editor.type === "number") return createNumber(row, col, editor, ctx);
|
||||
return createInput(row, col, editor, ctx);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user