20260804173126
This commit is contained in:
1 parent
e17bbe9db3
commit
75b1ccaa6d
36 files changed
+3521
-718
No files matched your search
@@ -1,40 +1,27 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
h,
|
||||
nextTick,
|
||||
onActivated,
|
||||
reactive,
|
||||
ref,
|
||||
shallowRef,
|
||||
useSlots,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { computed, h, nextTick, onActivated, ref, useSlots, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { message } from "antdv-next";
|
||||
import { Columns3, RefreshCw, Save } from "@lucide/vue";
|
||||
import { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from "@/services/api";
|
||||
import { saveObjectApi } from "@/services/api";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { usePermissionStore } from "@/stores/permissions";
|
||||
import {
|
||||
identifierRe,
|
||||
buildQueryCondition,
|
||||
defaultQueryValue,
|
||||
escapeSqlText,
|
||||
parseOptions,
|
||||
requireModuleId,
|
||||
} from "./moduleListUtils";
|
||||
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 FmsModuleTable from "./FmsModuleTable.vue";
|
||||
import FmsQueryToolbar from "./FmsQueryToolbar.vue";
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, default: "" },
|
||||
profileId: { type: String, default: "" },
|
||||
moduleCode: { type: String, default: "" },
|
||||
profileCode: { type: String, default: "default" },
|
||||
pageNodeId: { 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 },
|
||||
@@ -59,54 +46,95 @@ 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 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);
|
||||
const configError = ref("");
|
||||
const dataError = ref("");
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: Number(props.pageSize) || 20,
|
||||
total: 0,
|
||||
|
||||
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,
|
||||
});
|
||||
let dataRequestVersion = 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;
|
||||
|
||||
const loading = computed(() => configLoading.value || dataLoading.value);
|
||||
const permittedRowActions = computed(() =>
|
||||
props.rowActions.filter((action) => canModuleAction(action.permissionCode || action.key)),
|
||||
);
|
||||
|
||||
function canModuleAction(actionCode) {
|
||||
if (!actionCode || !dataModule.value) return false;
|
||||
return permissionStore.canAction(dataModule.value.b_id || dataModule.value.b_code, actionCode);
|
||||
}
|
||||
|
||||
function requireModuleAction(actionCode) {
|
||||
if (canModuleAction(actionCode)) return true;
|
||||
message.warning("没有该操作的权限");
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 外部工具 ──
|
||||
const moreMenuItems = computed(() => [
|
||||
{
|
||||
@@ -128,285 +156,38 @@ const moreMenuItems = computed(() => [
|
||||
]);
|
||||
const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.startsWith("cell-")));
|
||||
|
||||
// ── 模块配置加载 ──
|
||||
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 "";
|
||||
}
|
||||
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());
|
||||
|
||||
function getRuntimeKey() {
|
||||
return [
|
||||
props.moduleId,
|
||||
props.profileId,
|
||||
props.moduleCode,
|
||||
props.profileCode,
|
||||
instanceRoutePath,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
async function resolveRuntimeData() {
|
||||
const moduleId = String(props.moduleId || "").trim();
|
||||
const profileId = String(props.profileId || "").trim();
|
||||
const moduleCode = String(props.moduleCode || "").trim();
|
||||
const profileCode = String(props.profileCode || "default").trim();
|
||||
let condition = "";
|
||||
|
||||
if (moduleId || profileId) {
|
||||
if (!moduleId || !profileId) throw new Error("moduleId 和 profileId 必须同时提供");
|
||||
condition = `b_module_id = ${requireModuleId(moduleId)} AND b_profile_id = ${requireModuleId(profileId)}`;
|
||||
} else if (moduleCode) {
|
||||
condition =
|
||||
`b_module_code = N'${escapeSqlText(moduleCode)}' ` +
|
||||
`AND b_profile_code = N'${escapeSqlText(profileCode)}'`;
|
||||
} else {
|
||||
const routePath = escapeSqlText(instanceRoutePath);
|
||||
const functionResponse = await loadDataApi(
|
||||
"v_s_function_node",
|
||||
`b_route = N'${routePath}' AND b_node_type = 'entry' AND b_canuse = 1`,
|
||||
"b_id ASC",
|
||||
);
|
||||
const pageModuleId = functionResponse.data?.[0]?.b_module_id;
|
||||
if (!pageModuleId) throw new Error(`路由 ${instanceRoutePath} 没有对应的启用功能入口`);
|
||||
const pageNodeResponse = await loadDataApi(
|
||||
"v_s_page_node",
|
||||
`b_page_module_id = ${requireModuleId(pageModuleId)} AND b_node_type = 'module_ref' AND b_canuse = 1`,
|
||||
"b_xh ASC, b_id ASC",
|
||||
);
|
||||
const firstDataNode = (pageNodeResponse.data || []).find(
|
||||
(item) => item.b_module_id != null && item.b_profile_id != null,
|
||||
);
|
||||
if (!firstDataNode) throw new Error("当前页面没有配置数据模块引用");
|
||||
condition =
|
||||
`b_module_id = ${requireModuleId(firstDataNode.b_module_id)} ` +
|
||||
`AND b_profile_id = ${requireModuleId(firstDataNode.b_profile_id)}`;
|
||||
}
|
||||
|
||||
const response = await loadDataApi(
|
||||
"v_s_module_runtime",
|
||||
`${condition} AND b_module_type = 'data' AND b_canuse = 1 AND b_profile_canuse = 1`,
|
||||
"b_module_id ASC, b_profile_id ASC",
|
||||
);
|
||||
const runtime = response.data?.[0];
|
||||
if (!runtime) throw new Error("未找到启用的数据模块使用方式");
|
||||
return {
|
||||
...runtime,
|
||||
b_id: String(runtime.b_module_id),
|
||||
b_code: runtime.b_module_code,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadModuleConfig() {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
configLoading.value = true;
|
||||
configError.value = "";
|
||||
|
||||
try {
|
||||
const runtimeData = await resolveRuntimeData();
|
||||
|
||||
const dataId = requireModuleId(runtimeData.b_id);
|
||||
const profileId = requireModuleId(runtimeData.b_profile_id);
|
||||
const responses = await Promise.all([
|
||||
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
|
||||
loadDataApi("s_module_field_list", `b_profile_id = ${profileId}`, "b_xh ASC, b_id ASC"),
|
||||
loadDataApi("s_module_field_form", `b_profile_id = ${profileId}`, "b_xh ASC, b_id ASC"),
|
||||
loadDataApi(
|
||||
"s_module_field_query",
|
||||
`b_profile_id = ${profileId} AND b_canuse = 1`,
|
||||
"b_xh ASC, b_id ASC",
|
||||
),
|
||||
]);
|
||||
if (getRuntimeKey() !== runtimeKey) 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[2].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;
|
||||
|
||||
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[3].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 (getRuntimeKey() === runtimeKey) {
|
||||
configError.value = error?.message || String(error);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (getRuntimeKey() === runtimeKey) {
|
||||
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`;
|
||||
|
||||
if (listConfig.value.length > 0) {
|
||||
const firstConfig = listConfig.value[0];
|
||||
const firstField = activeFieldById.value.get(String(firstConfig.b_field_id));
|
||||
if (firstField && identifierRe.test(firstField.b_field || "")) {
|
||||
return `${firstField.b_field} ASC`;
|
||||
}
|
||||
}
|
||||
throw new Error("数据模块未配置可用的默认排序字段");
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
if (!dataModule.value || !dataModule.value.b_viewtable) return;
|
||||
|
||||
const currentModule = dataModule.value;
|
||||
const viewName = currentModule.b_viewtable;
|
||||
const currentRuntimeKey = getRuntimeKey();
|
||||
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,
|
||||
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,
|
||||
});
|
||||
|
||||
if (requestVersion !== dataRequestVersion || dataModule.value !== currentModule) return;
|
||||
if (getRuntimeKey() !== currentRuntimeKey) 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 &&
|
||||
getRuntimeKey() === currentRuntimeKey
|
||||
) {
|
||||
dataSource.value = [];
|
||||
pagination.total = 0;
|
||||
dataError.value = error?.message || String(error);
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
requestVersion === dataRequestVersion &&
|
||||
dataModule.value === currentModule &&
|
||||
getRuntimeKey() === currentRuntimeKey
|
||||
) {
|
||||
dataLoading.value = false;
|
||||
}
|
||||
}
|
||||
return actionRegistry.run(key, payload);
|
||||
}
|
||||
|
||||
// ── 公开方法 ──
|
||||
async function initialize() {
|
||||
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();
|
||||
moduleRuntime.reset();
|
||||
moduleDataSource.reset();
|
||||
moduleEditor.reset();
|
||||
const loaded = await moduleRuntime.load();
|
||||
if (!loaded) return;
|
||||
moduleDataSource.reset(defaultSearchConditions.value);
|
||||
await fetchData();
|
||||
}
|
||||
|
||||
async function resetColumns() {
|
||||
@@ -442,6 +223,7 @@ async function saveColumnSettings() {
|
||||
updates,
|
||||
},
|
||||
]);
|
||||
moduleRuntime.invalidate();
|
||||
|
||||
const currentById = new Map();
|
||||
for (const row of listConfig.value) {
|
||||
@@ -464,262 +246,14 @@ async function saveColumnSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
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 (!requireModuleAction("create")) 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;
|
||||
if (!requireModuleAction("update")) 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 || error));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected(rowKeys) {
|
||||
if (saving.value || !rowKeys?.length) return;
|
||||
if (!requireModuleAction("delete")) 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();
|
||||
try {
|
||||
await runAction(key);
|
||||
if (key === "reset-columns") {
|
||||
message.success(appStore.t("common.columnSettingsReset", "已恢复栏位设置"));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error?.message || String(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,7 +270,7 @@ onActivated(async () => {
|
||||
tableRef.value?.refreshLayout?.();
|
||||
});
|
||||
|
||||
watch(() => [props.moduleId, props.profileId, props.moduleCode, props.profileCode], initialize);
|
||||
watch(() => [props.moduleId, props.profileId, props.pageNodeId], initialize);
|
||||
|
||||
watch(
|
||||
() => props.fixedSearchCondition,
|
||||
@@ -765,6 +299,9 @@ defineExpose({
|
||||
deleteSelected,
|
||||
addRow,
|
||||
dirtyRows,
|
||||
groups,
|
||||
actions,
|
||||
runAction,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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 { formatDateValue, parseOptions } from "./moduleListUtils";
|
||||
import { createFieldEditor, formatFieldValue } from "@/module-runtime/fieldRegistry";
|
||||
import { parseOptions } from "./moduleListUtils";
|
||||
|
||||
const props = defineProps({
|
||||
fields: { type: Array, default: () => [] },
|
||||
@@ -48,49 +48,24 @@ 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 { type: "input", disabled: true };
|
||||
return { ...editor, disabled: true };
|
||||
}
|
||||
if (field.b_type === "checkbox") return { type: "checkbox" };
|
||||
if (field.b_type === "select") {
|
||||
const { options } = parseOptions(field);
|
||||
return { type: "select", options };
|
||||
}
|
||||
return { type: "input" };
|
||||
return editor;
|
||||
}
|
||||
|
||||
function createRuntimeColumn(config, field) {
|
||||
@@ -98,9 +73,7 @@ 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,
|
||||
@@ -119,7 +92,10 @@ function createRuntimeColumn(config, field) {
|
||||
column.customCell = ({ row, cellValue, rowIndex, colIndex }) => {
|
||||
const formattedValue = formatter
|
||||
? formatter({ value: cellValue, record: row, field, config })
|
||||
: formatCellValue(cellValue, field, optionInfo);
|
||||
: formatFieldValue(cellValue, field, optionInfo, {
|
||||
moduleId: field.b_module_id,
|
||||
profileId: config.b_profile_id,
|
||||
});
|
||||
const cellClass =
|
||||
typeof cellClassName === "function"
|
||||
? cellClassName({ value: cellValue, record: row, field, config })
|
||||
@@ -352,7 +328,9 @@ 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, Select } from "antdv-next";
|
||||
import { Input, InputNumber, Select } from "antdv-next";
|
||||
|
||||
const stop = (event) => event.stopPropagation();
|
||||
const inputDrafts = new WeakMap();
|
||||
@@ -44,13 +44,15 @@ function resolveOptions(editor) {
|
||||
|
||||
function displayValue(row, col, editor) {
|
||||
const value = row[col.dataIndex];
|
||||
const text =
|
||||
editor.type === "select"
|
||||
const text = editor.format
|
||||
? editor.format(value)
|
||||
: editor.type === "select"
|
||||
? (resolveOptions(editor).find((item) => String(item.value) === String(value))?.label ??
|
||||
value)
|
||||
: value;
|
||||
const content = text ?? "";
|
||||
return h("span", { class: "fms-cell-display", title: String(content) }, content);
|
||||
const title = typeof content === "string" || typeof content === "number" ? String(content) : "";
|
||||
return h("span", { class: "fms-cell-display", title }, content);
|
||||
}
|
||||
|
||||
function notifyChange(ctx, editor, row, col, value) {
|
||||
@@ -86,9 +88,15 @@ 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: getInputDraft(row, col.dataIndex) ?? "",
|
||||
value: currentValue,
|
||||
size: "small",
|
||||
class: "fms-cell-editor fms-cell-editor-root",
|
||||
"aria-label": col.title || col.dataIndex,
|
||||
@@ -96,7 +104,10 @@ function createInput(row, col, editor, ctx) {
|
||||
setInputDraft(row, col.dataIndex, value);
|
||||
},
|
||||
onBlur: (event) => {
|
||||
const value = event.target.value;
|
||||
const value =
|
||||
editor.inputType === "datetime-local"
|
||||
? event.target.value.replace("T", " ")
|
||||
: event.target.value;
|
||||
commitInputDraft(row, col.dataIndex, value);
|
||||
notifyChange(ctx, editor, row, col, value);
|
||||
},
|
||||
@@ -116,6 +127,48 @@ 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);
|
||||
@@ -140,7 +193,7 @@ function createSelect(row, col, editor, ctx) {
|
||||
}
|
||||
|
||||
function createCheckbox(row, col, editor, ctx) {
|
||||
const checked = row[col.dataIndex] === 1;
|
||||
const checked = row[col.dataIndex] === true || Number(row[col.dataIndex]) === 1;
|
||||
const focus = ctx.isEditing(row) ? createFocusHandler(ctx, row, col) : undefined;
|
||||
const update = (value) => {
|
||||
row[col.dataIndex] = value;
|
||||
@@ -198,6 +251,8 @@ 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);
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,9 @@ const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 });
|
||||
|
||||
function buildFunctionMenu() {
|
||||
const nodes = permissionStore.functionNodes.filter(
|
||||
(item) => Number(item.b_canmenu) === 1 && permissionStore.canAccessFunctionNode(item.b_id),
|
||||
(item) =>
|
||||
(permissionStore.isSystemUser || Number(item.b_canmenu) === 1) &&
|
||||
permissionStore.canAccessFunctionNode(item.b_id),
|
||||
);
|
||||
const menuById = new Map();
|
||||
const roots = [];
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
function contextSuffix(context) {
|
||||
const parts = [
|
||||
context?.moduleId ? `moduleId=${context.moduleId}` : "",
|
||||
context?.profileId ? `profileId=${context.profileId}` : "",
|
||||
].filter(Boolean);
|
||||
return parts.length ? `(${parts.join(",")})` : "";
|
||||
}
|
||||
|
||||
export function createModuleActionRegistry(context = () => ({})) {
|
||||
const handlers = new Map();
|
||||
|
||||
function register(key, handler) {
|
||||
const normalizedKey = String(key || "").trim();
|
||||
if (!normalizedKey) throw new Error("模块操作 key 不能为空");
|
||||
if (typeof handler !== "function") throw new Error(`模块操作 ${normalizedKey} 缺少处理函数`);
|
||||
handlers.set(normalizedKey, handler);
|
||||
return () => handlers.delete(normalizedKey);
|
||||
}
|
||||
|
||||
function has(key) {
|
||||
return handlers.has(String(key || "").trim());
|
||||
}
|
||||
|
||||
async function run(key, payload) {
|
||||
const normalizedKey = String(key || "").trim();
|
||||
const handler = handlers.get(normalizedKey);
|
||||
const currentContext = typeof context === "function" ? context() : context;
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`未注册模块操作:${normalizedKey || "<empty>"}${contextSuffix(currentContext)}`,
|
||||
);
|
||||
}
|
||||
return handler(payload, currentContext);
|
||||
}
|
||||
|
||||
return { register, has, run };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { buildQueryCondition } from "@/components/fms-module-list/moduleListUtils";
|
||||
|
||||
export function buildSearchConditions(entries = []) {
|
||||
const conditions = [];
|
||||
for (const entry of entries) {
|
||||
const condition = buildQueryCondition(entry.item, entry.value);
|
||||
if (condition) conditions.push(condition);
|
||||
}
|
||||
return conditions;
|
||||
}
|
||||
|
||||
export function combineSearchConditions(fixedCondition, conditions = []) {
|
||||
const result = [];
|
||||
const fixed = String(fixedCondition || "").trim();
|
||||
if (fixed) result.push(`(${fixed})`);
|
||||
for (const condition of conditions) {
|
||||
const normalized = String(condition || "").trim();
|
||||
if (normalized) result.push(normalized);
|
||||
}
|
||||
return result.join(" AND ");
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { loadDataApi } from "@/services/api";
|
||||
import {
|
||||
buildQueryCondition,
|
||||
defaultQueryValue,
|
||||
identifierRe,
|
||||
parseOptions,
|
||||
requireModuleId,
|
||||
} from "@/components/fms-module-list/moduleListUtils";
|
||||
import { MODULE_TABLES } from "./constants";
|
||||
import { requireFieldType } from "./fieldRegistry";
|
||||
|
||||
const configCache = new Map();
|
||||
|
||||
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 configKey(runtime, orgId) {
|
||||
return JSON.stringify([
|
||||
String(orgId || ""),
|
||||
String(runtime.b_module_id),
|
||||
String(runtime.b_profile_id),
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadConfig(runtime) {
|
||||
const moduleId = requireModuleId(runtime.b_module_id);
|
||||
const profileId = requireModuleId(runtime.b_profile_id);
|
||||
const responses = await Promise.all([
|
||||
loadDataApi(MODULE_TABLES.field, `b_module_id = ${moduleId}`, "b_id ASC"),
|
||||
loadDataApi(MODULE_TABLES.fieldList, `b_profile_id = ${profileId}`, "b_xh ASC, b_id ASC"),
|
||||
loadDataApi(MODULE_TABLES.fieldForm, `b_profile_id = ${profileId}`, "b_xh ASC, b_id ASC"),
|
||||
loadDataApi(
|
||||
MODULE_TABLES.fieldQuery,
|
||||
`b_profile_id = ${profileId} AND b_canuse = 1`,
|
||||
"b_xh ASC, b_id ASC",
|
||||
),
|
||||
loadDataApi(MODULE_TABLES.layoutGroup, `b_profile_id = ${profileId}`, "b_xh ASC, b_id ASC"),
|
||||
loadDataApi(
|
||||
MODULE_TABLES.action,
|
||||
`b_module_id = ${moduleId} AND b_canuse = 1`,
|
||||
"b_xh ASC, b_id ASC",
|
||||
),
|
||||
]);
|
||||
|
||||
const fields = [];
|
||||
const fieldById = new Map();
|
||||
for (const field of responses[0].data || []) {
|
||||
if (Number(field.b_canuse) !== 1 || !identifierRe.test(field.b_field || "")) continue;
|
||||
requireFieldType(field, {
|
||||
moduleId: runtime.b_module_id,
|
||||
profileId: runtime.b_profile_id,
|
||||
});
|
||||
fields.push(field);
|
||||
fieldById.set(String(field.b_id), field);
|
||||
}
|
||||
|
||||
const listConfig = [];
|
||||
for (const item of responses[1].data || []) {
|
||||
if (Number(item.b_visible) !== 1) continue;
|
||||
if (!fieldById.has(String(item.b_field_id))) continue;
|
||||
listConfig.push(item);
|
||||
}
|
||||
|
||||
const formConfig = [];
|
||||
const formConfigByFieldId = new Map();
|
||||
for (const item of responses[2].data || []) {
|
||||
if (Number(item.b_visible) !== 1) continue;
|
||||
if (!fieldById.has(String(item.b_field_id))) continue;
|
||||
formConfig.push(item);
|
||||
formConfigByFieldId.set(String(item.b_field_id), item);
|
||||
}
|
||||
|
||||
const queryItems = [];
|
||||
for (const config of responses[3].data || []) {
|
||||
const field = fieldById.get(String(config.b_field_id));
|
||||
if (!field) continue;
|
||||
const optionInfo = parseOptions(field);
|
||||
queryItems.push({
|
||||
config,
|
||||
field,
|
||||
optionConfig: optionInfo.optionConfig,
|
||||
options: optionInfo.options,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultSearchConditions = [];
|
||||
for (const item of queryItems) {
|
||||
const condition = buildQueryCondition(item, defaultQueryValue(item));
|
||||
if (condition) defaultSearchConditions.push(condition);
|
||||
}
|
||||
|
||||
return {
|
||||
fields,
|
||||
fieldById,
|
||||
listConfig,
|
||||
formConfig,
|
||||
formConfigByFieldId,
|
||||
queryItems,
|
||||
defaultSearchConditions,
|
||||
groups: responses[4].data || [],
|
||||
actions: responses[5].data || [],
|
||||
keyField: resolveKeyField(runtime, fields),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadModuleRuntimeConfig(runtime, options = {}) {
|
||||
const key = configKey(runtime, options.orgId);
|
||||
if (!options.force && configCache.has(key)) return configCache.get(key).promise;
|
||||
const entry = {
|
||||
orgId: String(options.orgId || ""),
|
||||
moduleId: String(runtime.b_module_id),
|
||||
profileId: String(runtime.b_profile_id),
|
||||
promise: null,
|
||||
};
|
||||
entry.promise = loadConfig(runtime).catch((error) => {
|
||||
if (configCache.get(key) === entry) configCache.delete(key);
|
||||
throw error;
|
||||
});
|
||||
configCache.set(key, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
export function invalidateModuleConfigCache(filter = {}) {
|
||||
const orgId = String(filter.orgId || "");
|
||||
const moduleId = String(filter.moduleId || "");
|
||||
const profileId = String(filter.profileId || "");
|
||||
for (const [key, entry] of configCache) {
|
||||
if (orgId && entry.orgId !== orgId) continue;
|
||||
if (moduleId && entry.moduleId !== moduleId) continue;
|
||||
if (profileId && entry.profileId !== profileId) continue;
|
||||
configCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const MODULE_TABLES = Object.freeze({
|
||||
module: "s_module",
|
||||
profile: "s_module_profile",
|
||||
field: "s_module_field",
|
||||
fieldList: "s_module_field_list",
|
||||
fieldForm: "s_module_field_form",
|
||||
fieldQuery: "s_module_field_query",
|
||||
layoutGroup: "s_module_layout_group",
|
||||
action: "s_module_action",
|
||||
});
|
||||
|
||||
export const MODULE_VIEWS = Object.freeze({
|
||||
runtime: "v_s_module_runtime",
|
||||
functionNode: "v_s_function_node",
|
||||
pageNode: "v_s_page_node",
|
||||
});
|
||||
|
||||
export const MODULE_TYPES = Object.freeze({
|
||||
page: "page",
|
||||
data: "data",
|
||||
report: "report",
|
||||
dashboard: "dashboard",
|
||||
workflow: "workflow",
|
||||
});
|
||||
|
||||
export const FUNCTION_NODE_TYPES = Object.freeze({
|
||||
directory: "directory",
|
||||
entry: "entry",
|
||||
});
|
||||
|
||||
export const PAGE_NODE_TYPES = Object.freeze({
|
||||
group: "group",
|
||||
tab: "tab",
|
||||
section: "section",
|
||||
moduleRef: "module_ref",
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { h } from "vue";
|
||||
import { Checkbox } from "antdv-next";
|
||||
import { formatDateValue } from "@/components/fms-module-list/moduleListUtils";
|
||||
|
||||
const fieldTypes = new Map();
|
||||
|
||||
function textValue(value) {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function groupedNumberValue(value, minimumFractionDigits = 0) {
|
||||
if (value == null || value === "") return "";
|
||||
const text = String(value).trim();
|
||||
const match = /^([+-]?)(\d+)(?:\.(\d*))?$/.exec(text);
|
||||
if (!match) return text;
|
||||
const integer = match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
const fraction = String(match[3] || "").padEnd(minimumFractionDigits, "0");
|
||||
return `${match[1]}${integer}${fraction ? `.${fraction}` : ""}`;
|
||||
}
|
||||
|
||||
function moneyValue(value, { optionInfo }) {
|
||||
const precision = Number.isInteger(optionInfo.optionConfig.precision)
|
||||
? optionInfo.optionConfig.precision
|
||||
: 2;
|
||||
return groupedNumberValue(value, precision);
|
||||
}
|
||||
|
||||
function checkboxValue(value) {
|
||||
return h(Checkbox, {
|
||||
checked: value === true || Number(value) === 1,
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
function selectValue(value, { optionInfo }) {
|
||||
if (value == null || value === "") return "";
|
||||
const option = optionInfo.options.find((item) => String(item.value) === String(value));
|
||||
return option?.label ?? String(value);
|
||||
}
|
||||
|
||||
function dateEditor(type) {
|
||||
return ({ format }) => ({
|
||||
type: "input",
|
||||
inputType: type,
|
||||
format,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerFieldType(key, definition) {
|
||||
const normalizedKey = String(key || "").trim();
|
||||
if (!normalizedKey) throw new Error("字段控件 key 不能为空");
|
||||
if (!definition || typeof definition !== "object") {
|
||||
throw new Error(`字段控件 ${normalizedKey} 的定义无效`);
|
||||
}
|
||||
fieldTypes.set(normalizedKey, Object.freeze({ ...definition }));
|
||||
}
|
||||
|
||||
export function hasFieldType(key) {
|
||||
return fieldTypes.has(String(key || "").trim());
|
||||
}
|
||||
|
||||
export function requireFieldType(field, context = {}) {
|
||||
const key = String(field?.b_type || "input").trim();
|
||||
const definition = fieldTypes.get(key);
|
||||
if (definition) return definition;
|
||||
|
||||
const locations = [
|
||||
context.moduleId ? `moduleId=${context.moduleId}` : "",
|
||||
context.profileId ? `profileId=${context.profileId}` : "",
|
||||
field?.b_field ? `field=${field.b_field}` : "",
|
||||
].filter(Boolean);
|
||||
const suffix = locations.length ? `(${locations.join(",")})` : "";
|
||||
throw new Error(`未注册字段控件:${key || "<empty>"}${suffix}`);
|
||||
}
|
||||
|
||||
export function formatFieldValue(value, field, optionInfo, context = {}) {
|
||||
const definition = requireFieldType(field, context);
|
||||
return (definition.format || textValue)(value, { field, optionInfo, context });
|
||||
}
|
||||
|
||||
export function createFieldEditor(field, formConfig, optionInfo, context = {}) {
|
||||
const definition = requireFieldType(field, context);
|
||||
const format = (value) => formatFieldValue(value, field, optionInfo, context);
|
||||
const editor =
|
||||
typeof definition.editor === "function"
|
||||
? definition.editor({ field, formConfig, optionInfo, context, format })
|
||||
: definition.editor;
|
||||
return { ...(editor || { type: "input" }), format };
|
||||
}
|
||||
|
||||
registerFieldType("input", { format: textValue, editor: { type: "input" } });
|
||||
registerFieldType("textarea", { format: textValue, editor: { type: "textarea" } });
|
||||
registerFieldType("number", {
|
||||
format: (value) => groupedNumberValue(value),
|
||||
editor: { type: "number", stringMode: true },
|
||||
});
|
||||
registerFieldType("money", {
|
||||
format: moneyValue,
|
||||
editor: ({ optionInfo }) => ({
|
||||
type: "number",
|
||||
stringMode: true,
|
||||
precision: Number.isInteger(optionInfo.optionConfig.precision)
|
||||
? optionInfo.optionConfig.precision
|
||||
: 2,
|
||||
}),
|
||||
});
|
||||
registerFieldType("date", {
|
||||
format: (value) => formatDateValue(value, false),
|
||||
editor: dateEditor("date"),
|
||||
});
|
||||
registerFieldType("datetime", {
|
||||
format: (value) => formatDateValue(value, true),
|
||||
editor: dateEditor("datetime-local"),
|
||||
});
|
||||
registerFieldType("checkbox", { format: checkboxValue, editor: { type: "checkbox" } });
|
||||
registerFieldType("select", {
|
||||
format: selectValue,
|
||||
editor: ({ optionInfo }) => ({ type: "select", options: optionInfo.options }),
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export { invalidateModuleConfigCache } from "./configLoader";
|
||||
export { invalidateModuleRuntimeCache, resolveRoutePageModuleId } from "./runtimeResolver";
|
||||
export { createModuleActionRegistry } from "./actionRegistry";
|
||||
export { buildSearchConditions, combineSearchConditions } from "./conditionBuilder";
|
||||
export {
|
||||
createFieldEditor,
|
||||
formatFieldValue,
|
||||
hasFieldType,
|
||||
registerFieldType,
|
||||
requireFieldType,
|
||||
} from "./fieldRegistry";
|
||||
@@ -0,0 +1,146 @@
|
||||
import { loadDataApi } from "@/services/api";
|
||||
import { escapeSqlText, requireModuleId } from "@/components/fms-module-list/moduleListUtils";
|
||||
import { FUNCTION_NODE_TYPES, MODULE_TYPES, MODULE_VIEWS, PAGE_NODE_TYPES } from "./constants";
|
||||
|
||||
const runtimeCache = new Map();
|
||||
|
||||
function normalizeId(value) {
|
||||
return value == null || value === "" ? "" : requireModuleId(value);
|
||||
}
|
||||
|
||||
function locatorValue(locator, key) {
|
||||
return String(locator?.[key] || "").trim();
|
||||
}
|
||||
|
||||
export function createRuntimeKey(locator) {
|
||||
return [
|
||||
String(locator?.orgId || ""),
|
||||
locatorValue(locator, "moduleId"),
|
||||
locatorValue(locator, "profileId"),
|
||||
locatorValue(locator, "pageNodeId"),
|
||||
locatorValue(locator, "routePath"),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export async function resolveRoutePageModuleId(routePath) {
|
||||
const escapedRoute = escapeSqlText(routePath);
|
||||
const response = await loadDataApi(
|
||||
MODULE_VIEWS.functionNode,
|
||||
`b_route = N'${escapedRoute}' AND b_node_type = '${FUNCTION_NODE_TYPES.entry}' AND b_canuse = 1`,
|
||||
"b_id ASC",
|
||||
);
|
||||
const pageModuleIds = new Set();
|
||||
for (const row of response.data || []) {
|
||||
if (row.b_module_id != null) pageModuleIds.add(String(row.b_module_id));
|
||||
}
|
||||
if (!pageModuleIds.size) throw new Error(`路由 ${routePath} 没有对应的启用功能入口`);
|
||||
if (pageModuleIds.size > 1) throw new Error(`路由 ${routePath} 对应多个页面模块,无法自动定位`);
|
||||
return [...pageModuleIds][0];
|
||||
}
|
||||
|
||||
async function resolvePageTarget(locator) {
|
||||
const pageNodeId = locatorValue(locator, "pageNodeId");
|
||||
let condition = "";
|
||||
if (pageNodeId) {
|
||||
condition =
|
||||
`b_id = ${normalizeId(pageNodeId)} AND b_node_type = '${PAGE_NODE_TYPES.moduleRef}' ` +
|
||||
"AND b_canuse = 1";
|
||||
} else {
|
||||
const routePath = locatorValue(locator, "routePath");
|
||||
if (!routePath) throw new Error("未提供 moduleId、profileId、pageNodeId 或页面路由");
|
||||
const pageModuleId = await resolveRoutePageModuleId(routePath);
|
||||
condition =
|
||||
`b_page_module_id = ${normalizeId(pageModuleId)} ` +
|
||||
`AND b_node_type = '${PAGE_NODE_TYPES.moduleRef}' AND b_canuse = 1`;
|
||||
}
|
||||
|
||||
const response = await loadDataApi(MODULE_VIEWS.pageNode, condition, "b_xh ASC, b_id ASC");
|
||||
const targets = [];
|
||||
for (const row of response.data || []) {
|
||||
if (row.b_module_id == null || row.b_profile_id == null) continue;
|
||||
targets.push(row);
|
||||
}
|
||||
if (!targets.length) throw new Error("当前页面节点没有配置启用的数据模块引用");
|
||||
if (targets.length > 1) {
|
||||
throw new Error("当前页面包含多个数据模块引用,请使用 pageNodeId 明确定位");
|
||||
}
|
||||
return {
|
||||
moduleId: normalizeId(targets[0].b_module_id),
|
||||
profileId: normalizeId(targets[0].b_profile_id),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRuntime(locator) {
|
||||
let moduleId = normalizeId(locatorValue(locator, "moduleId"));
|
||||
let profileId = normalizeId(locatorValue(locator, "profileId"));
|
||||
|
||||
if (!moduleId && !profileId) {
|
||||
const target = await resolvePageTarget(locator);
|
||||
moduleId = target.moduleId;
|
||||
profileId = target.profileId;
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
`b_module_type = '${MODULE_TYPES.data}'`,
|
||||
"b_canuse = 1",
|
||||
"b_profile_canuse = 1",
|
||||
];
|
||||
if (moduleId) conditions.push(`b_module_id = ${moduleId}`);
|
||||
if (profileId) {
|
||||
conditions.push(`b_profile_id = ${profileId}`);
|
||||
} else {
|
||||
conditions.push("b_default = 1");
|
||||
}
|
||||
|
||||
const response = await loadDataApi(
|
||||
MODULE_VIEWS.runtime,
|
||||
conditions.join(" AND "),
|
||||
"b_module_id ASC, b_profile_id ASC",
|
||||
);
|
||||
const rows = response.data || [];
|
||||
if (!rows.length) throw new Error("未找到启用的数据模块使用方式");
|
||||
if (rows.length > 1) throw new Error("模块运行时定位结果不唯一");
|
||||
const runtime = rows[0];
|
||||
return {
|
||||
...runtime,
|
||||
b_id: String(runtime.b_module_id),
|
||||
b_module_id: String(runtime.b_module_id),
|
||||
b_profile_id: String(runtime.b_profile_id),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveModuleRuntime(locator, options = {}) {
|
||||
const key = createRuntimeKey(locator);
|
||||
if (!options.force && runtimeCache.has(key)) return runtimeCache.get(key).promise;
|
||||
|
||||
const entry = {
|
||||
orgId: String(locator?.orgId || ""),
|
||||
moduleId: "",
|
||||
profileId: "",
|
||||
promise: null,
|
||||
};
|
||||
entry.promise = resolveRuntime(locator)
|
||||
.then((runtime) => {
|
||||
entry.moduleId = runtime.b_module_id;
|
||||
entry.profileId = runtime.b_profile_id;
|
||||
return runtime;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (runtimeCache.get(key) === entry) runtimeCache.delete(key);
|
||||
throw error;
|
||||
});
|
||||
runtimeCache.set(key, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
export function invalidateModuleRuntimeCache(filter = {}) {
|
||||
const orgId = String(filter.orgId || "");
|
||||
const moduleId = String(filter.moduleId || "");
|
||||
const profileId = String(filter.profileId || "");
|
||||
for (const [key, entry] of runtimeCache) {
|
||||
if (orgId && entry.orgId !== orgId) continue;
|
||||
if (moduleId && entry.moduleId && entry.moduleId !== moduleId) continue;
|
||||
if (profileId && entry.profileId && entry.profileId !== profileId) continue;
|
||||
runtimeCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function buildTableSaveRequest({ table, keyField, inserts, updates, deletes }) {
|
||||
const request = { table, key_field: keyField };
|
||||
if (inserts?.length) request.inserts = inserts;
|
||||
if (updates?.length) request.updates = updates;
|
||||
if (deletes?.length) request.deletes = deletes;
|
||||
return request;
|
||||
}
|
||||
|
||||
export function buildKeyDeletes(keyField, keys) {
|
||||
const rows = [];
|
||||
for (const key of keys || []) rows.push({ [keyField]: key });
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { reactive, ref, shallowRef } from "vue";
|
||||
import { pageDataApi } from "@/services/api";
|
||||
import { identifierRe } from "@/components/fms-module-list/moduleListUtils";
|
||||
import { buildSearchConditions, combineSearchConditions } from "./conditionBuilder";
|
||||
|
||||
function optionValue(option) {
|
||||
return typeof option === "function" ? option() : option;
|
||||
}
|
||||
|
||||
export function useModuleDataSource(options) {
|
||||
const dataSource = shallowRef([]);
|
||||
const searchConditions = shallowRef([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: Number(optionValue(options.pageSize)) || 20,
|
||||
total: 0,
|
||||
});
|
||||
let requestVersion = 0;
|
||||
|
||||
function resolveOrderBy() {
|
||||
const module = options.dataModule.value;
|
||||
if (!module) throw new Error("数据模块尚未加载");
|
||||
const configured = String(module.b_orderfield || "").trim();
|
||||
if (configured) return configured;
|
||||
if (options.keyField.value) return `${options.keyField.value} DESC`;
|
||||
|
||||
if (options.listConfig.value.length > 0) {
|
||||
const firstConfig = options.listConfig.value[0];
|
||||
const firstField = options.activeFieldById.value.get(String(firstConfig.b_field_id));
|
||||
if (firstField && identifierRe.test(firstField.b_field || "")) {
|
||||
return `${firstField.b_field} ASC`;
|
||||
}
|
||||
}
|
||||
throw new Error("数据模块未配置可用的默认排序字段");
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
const currentModule = options.dataModule.value;
|
||||
if (!currentModule?.b_viewtable) return;
|
||||
|
||||
const currentRuntimeKey = options.runtimeKey();
|
||||
const version = ++requestVersion;
|
||||
const fixedCondition = String(optionValue(options.fixedSearchCondition) || "").trim();
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const response = await pageDataApi({
|
||||
viewName: currentModule.b_viewtable,
|
||||
orderBy: resolveOrderBy(),
|
||||
searchCondition: combineSearchConditions(fixedCondition, searchConditions.value),
|
||||
pageNo: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
if (version !== requestVersion || options.dataModule.value !== currentModule) return;
|
||||
if (options.runtimeKey() !== currentRuntimeKey) return;
|
||||
dataSource.value = response.data?.rows || [];
|
||||
pagination.total = Number(response.data?.total ?? response.data?.row_count) || 0;
|
||||
} catch (loadError) {
|
||||
if (
|
||||
version === requestVersion &&
|
||||
options.dataModule.value === currentModule &&
|
||||
options.runtimeKey() === currentRuntimeKey
|
||||
) {
|
||||
dataSource.value = [];
|
||||
pagination.total = 0;
|
||||
error.value = loadError?.message || String(loadError);
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
version === requestVersion &&
|
||||
options.dataModule.value === currentModule &&
|
||||
options.runtimeKey() === currentRuntimeKey
|
||||
) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reset(initialConditions = []) {
|
||||
requestVersion++;
|
||||
dataSource.value = [];
|
||||
searchConditions.value = [...initialConditions];
|
||||
loading.value = false;
|
||||
error.value = "";
|
||||
pagination.current = 1;
|
||||
pagination.pageSize = Number(optionValue(options.pageSize)) || 20;
|
||||
pagination.total = 0;
|
||||
}
|
||||
|
||||
function search({ entries = [] }) {
|
||||
if (loading.value) return;
|
||||
searchConditions.value = buildSearchConditions(entries);
|
||||
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();
|
||||
}
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
searchConditions,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
reset,
|
||||
fetchData,
|
||||
search,
|
||||
resetSearch,
|
||||
changePage,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { computed, nextTick, ref, shallowRef } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import { nextIdApi, saveObjectApi } from "@/services/api";
|
||||
import { identifierRe } from "@/components/fms-module-list/moduleListUtils";
|
||||
import { buildKeyDeletes, buildTableSaveRequest } from "./saveRequestBuilder";
|
||||
|
||||
const SYSTEM_FIELDS = ["b_inputdatetime", "b_inputuser_id", "b_updatedatetime", "b_updateuser_id"];
|
||||
|
||||
function optionValue(option) {
|
||||
return typeof option === "function" ? option() : option;
|
||||
}
|
||||
|
||||
export function useModuleEditor(options) {
|
||||
const dirtyRows = shallowRef(new Map());
|
||||
const newRowKeys = shallowRef(new Set());
|
||||
const saving = ref(false);
|
||||
|
||||
const readonlyFields = computed(() => {
|
||||
const fields = new Set(SYSTEM_FIELDS);
|
||||
if (options.keyField.value) fields.add(options.keyField.value);
|
||||
return [...fields];
|
||||
});
|
||||
|
||||
function emitDirty() {
|
||||
options.onDirtyChange?.(dirtyRows.value.size);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
dirtyRows.value = new Map();
|
||||
newRowKeys.value = new Set();
|
||||
saving.value = false;
|
||||
emitDirty();
|
||||
}
|
||||
|
||||
function resolveRowKey(row) {
|
||||
if (options.keyField.value) return row[options.keyField.value];
|
||||
return JSON.stringify(row);
|
||||
}
|
||||
|
||||
function canAction(actionCode) {
|
||||
return options.canAction?.(actionCode) === true;
|
||||
}
|
||||
|
||||
function requireAction(actionCode) {
|
||||
if (canAction(actionCode)) return true;
|
||||
message.warning("没有该操作的权限");
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleCellChange({ row, field }) {
|
||||
if (!optionValue(options.editable) || !options.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]));
|
||||
}
|
||||
emitDirty();
|
||||
}
|
||||
|
||||
function resolveSaveTable() {
|
||||
const module = options.dataModule.value;
|
||||
if (!module) throw new Error("数据模块尚未加载");
|
||||
return String(module.b_savetable || module.b_viewtable || "");
|
||||
}
|
||||
|
||||
function dirtyUpdateRows() {
|
||||
const keyField = options.keyField.value;
|
||||
if (!keyField) return [];
|
||||
const updates = [];
|
||||
const rowByKey = new Map(
|
||||
options.dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
|
||||
);
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
if (newRowKeys.value.has(rowKey)) continue;
|
||||
const row = rowByKey.get(rowKey);
|
||||
if (!row) continue;
|
||||
const update = { [keyField]: row[keyField] };
|
||||
for (const field of fields) {
|
||||
if (field !== keyField) update[field] = row[field];
|
||||
}
|
||||
updates.push(update);
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
function dirtyInsertRows() {
|
||||
const keyField = options.keyField.value;
|
||||
if (!keyField) return [];
|
||||
const rowByKey = new Map(
|
||||
options.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]: row[keyField] };
|
||||
for (const field of fields) {
|
||||
if (field !== keyField) insert[field] = row[field];
|
||||
}
|
||||
inserts.push(insert);
|
||||
}
|
||||
return inserts;
|
||||
}
|
||||
|
||||
function firstEditableField() {
|
||||
const readonly = new Set(readonlyFields.value);
|
||||
for (const config of options.listConfig.value) {
|
||||
const field = options.activeFieldById.value.get(String(config.b_field_id));
|
||||
if (!field || readonly.has(field.b_field) || field.b_type === "checkbox") continue;
|
||||
return field.b_field;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function addRow() {
|
||||
if (!optionValue(options.editable) || saving.value || optionValue(options.loading)) return;
|
||||
if (!requireAction("create")) return;
|
||||
const keyField = options.keyField.value;
|
||||
if (!keyField) {
|
||||
message.error("该模块未配置主键,无法新增");
|
||||
return;
|
||||
}
|
||||
const response = await nextIdApi();
|
||||
const newId = String(response.data);
|
||||
const newRow = { [keyField]: newId };
|
||||
const orderField = String(optionValue(options.orderField) || "");
|
||||
if (orderField && identifierRe.test(orderField)) {
|
||||
let maximum = 0;
|
||||
for (const row of options.dataSource.value) {
|
||||
maximum = Math.max(maximum, Number(row[orderField]) || 0);
|
||||
}
|
||||
newRow[orderField] = maximum + 10;
|
||||
}
|
||||
for (const field of options.activeFields.value) {
|
||||
if (field.b_field === keyField || field.b_field === orderField) continue;
|
||||
if (newRow[field.b_field] !== undefined) continue;
|
||||
if (field.b_type === "checkbox") newRow[field.b_field] = 0;
|
||||
}
|
||||
options.dataSource.value = [...options.dataSource.value, newRow];
|
||||
newRowKeys.value = new Set([...newRowKeys.value, newId]);
|
||||
await nextTick();
|
||||
options.getTable?.()?.editRow?.(newId, firstEditableField());
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (saving.value || !requireAction("update")) 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 {
|
||||
await saveObjectApi([
|
||||
buildTableSaveRequest({
|
||||
table,
|
||||
keyField: options.keyField.value,
|
||||
updates,
|
||||
inserts,
|
||||
}),
|
||||
]);
|
||||
const insertedSet = new Set(inserts.map((row) => String(row[options.keyField.value])));
|
||||
newRowKeys.value = new Set([...newRowKeys.value].filter((key) => !insertedSet.has(key)));
|
||||
dirtyRows.value = new Map();
|
||||
options.getTable?.()?.clearEditing?.();
|
||||
message.success("保存成功");
|
||||
emitDirty();
|
||||
await options.reload?.();
|
||||
} catch (saveError) {
|
||||
message.error("保存失败:" + (saveError?.message || saveError));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected(rowKeys) {
|
||||
if (saving.value || !rowKeys?.length || !requireAction("delete")) return;
|
||||
const table = resolveSaveTable();
|
||||
const keyField = options.keyField.value;
|
||||
if (!table) {
|
||||
message.error("该模块未配置保存表,无法删除");
|
||||
return;
|
||||
}
|
||||
if (!keyField) {
|
||||
message.error("该模块未配置主键,无法删除");
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingKeys = [];
|
||||
const persistedKeys = [];
|
||||
for (const key of rowKeys) {
|
||||
if (newRowKeys.value.has(String(key))) pendingKeys.push(key);
|
||||
else persistedKeys.push(key);
|
||||
}
|
||||
if (pendingKeys.length) {
|
||||
const pendingSet = new Set(pendingKeys.map((key) => String(key)));
|
||||
options.dataSource.value = options.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)));
|
||||
options.getTable?.()?.clearEditing?.();
|
||||
emitDirty();
|
||||
}
|
||||
if (!persistedKeys.length) {
|
||||
message.success(`已删除 ${rowKeys.length} 条记录`);
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveObjectApi([
|
||||
buildTableSaveRequest({
|
||||
table,
|
||||
keyField,
|
||||
deletes: buildKeyDeletes(keyField, persistedKeys),
|
||||
}),
|
||||
]);
|
||||
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;
|
||||
options.getTable?.()?.clearEditing?.();
|
||||
message.success(`已删除 ${rowKeys.length} 条记录`);
|
||||
emitDirty();
|
||||
await options.reload?.();
|
||||
} catch (deleteError) {
|
||||
message.error("删除失败:" + (deleteError?.message || deleteError));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRowOrderChange(orderedRows) {
|
||||
if (!optionValue(options.rowDraggable)) return;
|
||||
const orderField = String(optionValue(options.orderField) || "");
|
||||
if (!orderField || !identifierRe.test(orderField)) return;
|
||||
const rowByOld = new Map(
|
||||
options.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[orderField]) || 0 : 0;
|
||||
nextRows.push({ ...row, [orderField]: order });
|
||||
if (oldValue !== order) dirtyFieldsByKey.set(key, new Set([orderField]));
|
||||
order += 10;
|
||||
}
|
||||
options.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);
|
||||
}
|
||||
emitDirty();
|
||||
}
|
||||
|
||||
return {
|
||||
dirtyRows,
|
||||
newRowKeys,
|
||||
saving,
|
||||
readonlyFields,
|
||||
reset,
|
||||
resolveRowKey,
|
||||
handleCellChange,
|
||||
addRow,
|
||||
saveChanges,
|
||||
deleteSelected,
|
||||
handleRowOrderChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ref, shallowRef } from "vue";
|
||||
import { loadModuleRuntimeConfig, invalidateModuleConfigCache } from "./configLoader";
|
||||
import {
|
||||
createRuntimeKey,
|
||||
invalidateModuleRuntimeCache,
|
||||
resolveModuleRuntime,
|
||||
} from "./runtimeResolver";
|
||||
|
||||
function optionValue(option) {
|
||||
return typeof option === "function" ? option() : option;
|
||||
}
|
||||
|
||||
export function useModuleRuntime(options) {
|
||||
const dataModule = shallowRef(null);
|
||||
const activeFields = shallowRef([]);
|
||||
const activeFieldById = shallowRef(new Map());
|
||||
const listConfig = shallowRef([]);
|
||||
const formConfig = shallowRef([]);
|
||||
const formConfigByFieldId = shallowRef(new Map());
|
||||
const queryItems = shallowRef([]);
|
||||
const defaultSearchConditions = shallowRef([]);
|
||||
const groups = shallowRef([]);
|
||||
const actions = shallowRef([]);
|
||||
const keyField = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
let loadVersion = 0;
|
||||
|
||||
function locator() {
|
||||
return {
|
||||
orgId: String(optionValue(options.orgId) || ""),
|
||||
moduleId: String(optionValue(options.moduleId) || ""),
|
||||
profileId: String(optionValue(options.profileId) || ""),
|
||||
pageNodeId: String(optionValue(options.pageNodeId) || ""),
|
||||
routePath: String(optionValue(options.routePath) || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeKey() {
|
||||
return createRuntimeKey(locator());
|
||||
}
|
||||
|
||||
function reset() {
|
||||
loadVersion++;
|
||||
dataModule.value = null;
|
||||
activeFields.value = [];
|
||||
activeFieldById.value = new Map();
|
||||
listConfig.value = [];
|
||||
formConfig.value = [];
|
||||
formConfigByFieldId.value = new Map();
|
||||
queryItems.value = [];
|
||||
defaultSearchConditions.value = [];
|
||||
groups.value = [];
|
||||
actions.value = [];
|
||||
keyField.value = "";
|
||||
loading.value = false;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
async function load(loadOptions = {}) {
|
||||
const version = ++loadVersion;
|
||||
const currentLocator = locator();
|
||||
const currentKey = createRuntimeKey(currentLocator);
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const runtime = await resolveModuleRuntime(currentLocator, loadOptions);
|
||||
const config = await loadModuleRuntimeConfig(runtime, {
|
||||
orgId: currentLocator.orgId,
|
||||
force: loadOptions.force,
|
||||
});
|
||||
if (version !== loadVersion || currentKey !== runtimeKey()) return false;
|
||||
dataModule.value = runtime;
|
||||
activeFields.value = [...config.fields];
|
||||
activeFieldById.value = new Map(config.fieldById);
|
||||
listConfig.value = [...config.listConfig];
|
||||
formConfig.value = [...config.formConfig];
|
||||
formConfigByFieldId.value = new Map(config.formConfigByFieldId);
|
||||
queryItems.value = [...config.queryItems];
|
||||
defaultSearchConditions.value = [...config.defaultSearchConditions];
|
||||
groups.value = [...config.groups];
|
||||
actions.value = [...config.actions];
|
||||
keyField.value = config.keyField;
|
||||
return true;
|
||||
} catch (loadError) {
|
||||
if (version === loadVersion && currentKey === runtimeKey()) {
|
||||
error.value = loadError?.message || String(loadError);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (version === loadVersion && currentKey === runtimeKey()) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
const currentLocator = locator();
|
||||
const runtime = dataModule.value;
|
||||
const filter = {
|
||||
orgId: currentLocator.orgId,
|
||||
moduleId: runtime?.b_module_id || currentLocator.moduleId,
|
||||
profileId: runtime?.b_profile_id || currentLocator.profileId,
|
||||
};
|
||||
invalidateModuleRuntimeCache(filter);
|
||||
invalidateModuleConfigCache(filter);
|
||||
}
|
||||
|
||||
return {
|
||||
dataModule,
|
||||
activeFields,
|
||||
activeFieldById,
|
||||
listConfig,
|
||||
formConfig,
|
||||
formConfigByFieldId,
|
||||
queryItems,
|
||||
defaultSearchConditions,
|
||||
groups,
|
||||
actions,
|
||||
keyField,
|
||||
loading,
|
||||
error,
|
||||
runtimeKey,
|
||||
reset,
|
||||
load,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from "./auth";
|
||||
export const usePermissionStore = defineStore("permissions", () => {
|
||||
const authStore = useAuthStore();
|
||||
const loadedPrincipalKey = ref("");
|
||||
const modules = ref([]);
|
||||
const functionNodes = ref([]);
|
||||
const pageNodes = ref([]);
|
||||
const actions = ref([]);
|
||||
@@ -23,6 +24,9 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
const grantedFunctionSet = computed(() => new Set(grantedFunctionNodeIds.value));
|
||||
const grantedPageSet = computed(() => new Set(grantedPageNodeIds.value));
|
||||
const grantedActionSet = computed(() => new Set(grantedActionIds.value));
|
||||
const isSystemUser = computed(
|
||||
() => String(authStore.user?.account || "").toLowerCase() === "g3soft",
|
||||
);
|
||||
|
||||
async function load() {
|
||||
const userId = String(authStore.user?.id || "");
|
||||
@@ -34,6 +38,7 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
const requestVersion = ++loadVersion;
|
||||
loadingPrincipalKey = principalKey;
|
||||
const request = Promise.all([
|
||||
loadDataApi("v_s_module_runtime", "b_canuse = 1", "b_module_id ASC, b_profile_id ASC"),
|
||||
loadDataApi("v_s_function_node", "b_canuse = 1", "b_xh ASC, b_id ASC"),
|
||||
loadDataApi("v_s_page_node", "b_canuse = 1", "b_xh ASC, b_id ASC"),
|
||||
loadDataApi("v_s_module_action", "b_canuse = 1", "b_xh ASC, b_id ASC"),
|
||||
@@ -47,6 +52,7 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
])
|
||||
.then(
|
||||
([
|
||||
moduleResponse,
|
||||
functionResponse,
|
||||
pageResponse,
|
||||
actionResponse,
|
||||
@@ -55,6 +61,7 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
actionGrants,
|
||||
]) => {
|
||||
if (requestVersion !== loadVersion) return;
|
||||
modules.value = moduleResponse.data || [];
|
||||
functionNodes.value = functionResponse.data || [];
|
||||
pageNodes.value = pageResponse.data || [];
|
||||
actions.value = actionResponse.data || [];
|
||||
@@ -97,6 +104,11 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
if (!id) return false;
|
||||
const node = functionNodeById.value.get(id);
|
||||
if (!node) return false;
|
||||
if (isSystemUser.value) {
|
||||
if (node.b_node_type === "directory") return true;
|
||||
if (node.b_page_mode === "custom" && !hasPageComponent(node.b_component_key)) return false;
|
||||
return true;
|
||||
}
|
||||
if (node.b_node_type === "directory") return hasGrantedDescendant(id);
|
||||
if (node.b_page_mode === "custom" && !hasPageComponent(node.b_component_key)) return false;
|
||||
return grantedFunctionSet.value.has(id);
|
||||
@@ -104,6 +116,7 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
|
||||
function canAccessRoute(path) {
|
||||
if (path === "/dashboard") return true;
|
||||
if (isSystemUser.value) return true;
|
||||
const node = functionNodes.value.find(
|
||||
(item) => item.b_node_type === "entry" && String(item.b_route || "") === String(path || ""),
|
||||
);
|
||||
@@ -112,10 +125,12 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
|
||||
function canViewPageNode(nodeId, permissionRequired = true) {
|
||||
if (!permissionRequired) return true;
|
||||
if (isSystemUser.value) return true;
|
||||
return grantedPageSet.value.has(String(nodeId || ""));
|
||||
}
|
||||
|
||||
function canAction(moduleIdOrCode, actionCode) {
|
||||
if (isSystemUser.value) return true;
|
||||
const action = actions.value.find(
|
||||
(item) =>
|
||||
(String(item.b_module_id) === String(moduleIdOrCode) ||
|
||||
@@ -130,6 +145,7 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
loadingPromise = null;
|
||||
loadingPrincipalKey = "";
|
||||
loadedPrincipalKey.value = "";
|
||||
modules.value = [];
|
||||
functionNodes.value = [];
|
||||
pageNodes.value = [];
|
||||
actions.value = [];
|
||||
@@ -149,6 +165,8 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
canAction,
|
||||
canAccess: canAccessRoute,
|
||||
canPower: canAction,
|
||||
isSystemUser,
|
||||
modules,
|
||||
functionNodes,
|
||||
pageNodes,
|
||||
actions,
|
||||
|
||||
@@ -4,7 +4,7 @@ import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vu
|
||||
|
||||
<template>
|
||||
<div class="contact-page">
|
||||
<FmsModuleListPage module-code="v_b_contact" />
|
||||
<FmsModuleListPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -396,7 +396,6 @@ onMounted(() => {
|
||||
<a-splitter-panel :min="520">
|
||||
<main class="partner-panel">
|
||||
<FmsModuleListPage
|
||||
module-code="v_b_othercompany"
|
||||
:fixed-search-condition="categoryFilterCondition"
|
||||
:column-extensions="columnExtensions"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { message, Modal } from "antdv-next";
|
||||
import { Pencil, Plus, RefreshCw, Save, Trash2 } from "@lucide/vue";
|
||||
import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vue";
|
||||
@@ -7,11 +8,13 @@ import FmsTree from "@/components/FmsTree.vue";
|
||||
import { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from "@/services/api";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { usePermissionStore } from "@/stores/permissions";
|
||||
import { resolveRoutePageModuleId } from "@/module-runtime/runtimeResolver";
|
||||
import { buildTree, sortTree } from "@/utils/tree";
|
||||
|
||||
const ALL_CATEGORIES_KEY = "__fms_tree_root__";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const route = useRoute();
|
||||
const permissionStore = usePermissionStore();
|
||||
const categories = ref([]);
|
||||
const dataModules = ref([]);
|
||||
@@ -21,7 +24,7 @@ const categoryLoading = ref(false);
|
||||
const moduleLoading = ref(false);
|
||||
const selectedCategoryKey = ref("");
|
||||
const selectedModuleId = ref("");
|
||||
const selectedProfileId = ref("");
|
||||
const selectedPageNodeId = ref("");
|
||||
const expandedCategoryKeys = ref([ALL_CATEGORIES_KEY]);
|
||||
const modalVisible = ref(false);
|
||||
const modalParentId = ref(null);
|
||||
@@ -35,14 +38,14 @@ const form = reactive({ b_name: "", b_module_id: "" });
|
||||
const categoryById = computed(
|
||||
() => new Map(categories.value.map((item) => [String(item.b_id), item])),
|
||||
);
|
||||
const tableKey = computed(() => `${selectedModuleId.value}:${selectedProfileId.value}`);
|
||||
const shouldLoadData = computed(() => Boolean(selectedModuleId.value && selectedProfileId.value));
|
||||
const tableKey = computed(() => selectedPageNodeId.value);
|
||||
const shouldLoadData = computed(() => Boolean(selectedPageNodeId.value));
|
||||
const canCreateRow = computed(() => permissionStore.canAction(selectedModuleId.value, "create"));
|
||||
const canUpdateRow = computed(() => permissionStore.canAction(selectedModuleId.value, "update"));
|
||||
const canDeleteRow = computed(() => permissionStore.canAction(selectedModuleId.value, "delete"));
|
||||
|
||||
function canManageTree(actionCode) {
|
||||
return permissionStore.canAction("base_otherdata", actionCode);
|
||||
return permissionStore.canAction(otherDataPageId.value, actionCode);
|
||||
}
|
||||
|
||||
// 编辑表格:当前模块列表实例、选中行、保存/删除状态
|
||||
@@ -62,7 +65,7 @@ const selectedCategoryKeys = computed(() =>
|
||||
);
|
||||
|
||||
// 切换数据模块时清空编辑状态,避免残留脏标记与选中项。
|
||||
watch([selectedModuleId, selectedProfileId], () => {
|
||||
watch(selectedPageNodeId, () => {
|
||||
selectedRowKeys.value = [];
|
||||
dirtyCount.value = 0;
|
||||
});
|
||||
@@ -71,17 +74,16 @@ const modalTitle = computed(() => {
|
||||
return modalType.value === "node" ? "添加节点" : "添加分类";
|
||||
});
|
||||
const moduleOptions = computed(() => {
|
||||
const usedCodes = new Set();
|
||||
const usedModuleIds = new Set();
|
||||
for (const item of categories.value) {
|
||||
if (String(item.b_id) === String(editingCategoryId.value || "")) continue;
|
||||
if (getNodeType(item) !== "module") continue;
|
||||
const module = moduleById.value.get(String(item.b_module_id));
|
||||
if (module?.b_code) usedCodes.add(String(module.b_code));
|
||||
if (item.b_module_id != null) usedModuleIds.add(String(item.b_module_id));
|
||||
}
|
||||
|
||||
const options = [];
|
||||
for (const item of dataModules.value) {
|
||||
if (!item.b_code || usedCodes.has(String(item.b_code))) continue;
|
||||
if (usedModuleIds.has(String(item.b_id))) continue;
|
||||
options.push({
|
||||
label: `${appStore.t(item.b_i18n, item.b_name || item.b_code)}(${item.b_code})`,
|
||||
value: String(item.b_id),
|
||||
@@ -117,10 +119,10 @@ function buildCategoryTreeNode(item) {
|
||||
let title = "";
|
||||
const moduleId = item.b_module_id == null ? "" : String(item.b_module_id);
|
||||
const module = moduleById.value.get(moduleId);
|
||||
const moduleCode = module?.b_code || "";
|
||||
const fallbackLabel = module?.b_code || "";
|
||||
const nodeType = getNodeType(item);
|
||||
if (nodeType === "module") {
|
||||
title = appStore.t(module?.b_i18n, module?.b_name || item.b_name || moduleCode);
|
||||
title = appStore.t(module?.b_i18n, module?.b_name || item.b_name || fallbackLabel);
|
||||
} else {
|
||||
title = appStore.t(item.b_i18n, item.b_name);
|
||||
}
|
||||
@@ -133,7 +135,6 @@ function buildCategoryTreeNode(item) {
|
||||
b_id: String(item.b_id),
|
||||
b_parent_id: item.b_parent_id == null ? null : String(item.b_parent_id),
|
||||
b_node_type: nodeType,
|
||||
b_module_code: moduleCode,
|
||||
b_module_id: moduleId || null,
|
||||
b_xh: item.b_xh,
|
||||
children: [],
|
||||
@@ -142,9 +143,7 @@ function buildCategoryTreeNode(item) {
|
||||
|
||||
function getNodeType(item) {
|
||||
if (!item) return "category";
|
||||
if (item.b_node_type === "module_ref" || item.b_node_type === "widget") return "module";
|
||||
if (item.b_node_type === "module") return "module";
|
||||
if (item.b_module_id != null) return "module";
|
||||
if (item.b_node_type === "module_ref") return "module";
|
||||
return "category";
|
||||
}
|
||||
|
||||
@@ -153,20 +152,20 @@ function updateSelectedModule(key) {
|
||||
|
||||
if (!key) {
|
||||
selectedModuleId.value = "";
|
||||
selectedProfileId.value = "";
|
||||
selectedPageNodeId.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === ALL_CATEGORIES_KEY) {
|
||||
selectedModuleId.value = "";
|
||||
selectedProfileId.value = "";
|
||||
selectedPageNodeId.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const category = categoryById.value.get(key);
|
||||
if (!category || getNodeType(category) !== "module") {
|
||||
selectedModuleId.value = "";
|
||||
selectedProfileId.value = "";
|
||||
selectedPageNodeId.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,12 +173,12 @@ function updateSelectedModule(key) {
|
||||
const profileId = String(category.b_profile_id || "");
|
||||
if (Number(category.b_canuse) !== 1 || !module?.b_id || !profileId) {
|
||||
selectedModuleId.value = "";
|
||||
selectedProfileId.value = "";
|
||||
selectedPageNodeId.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModuleId.value = String(module.b_id);
|
||||
selectedProfileId.value = profileId;
|
||||
selectedPageNodeId.value = String(category.b_id);
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
@@ -258,12 +257,7 @@ async function loadDataModules() {
|
||||
}
|
||||
|
||||
async function loadOtherDataPage() {
|
||||
const response = await loadDataApi(
|
||||
"s_module",
|
||||
"b_code = N'base_otherdata' AND b_module_type = 'page' AND b_canuse = 1",
|
||||
"b_id ASC",
|
||||
);
|
||||
otherDataPageId.value = response.data?.[0]?.b_id || null;
|
||||
otherDataPageId.value = await resolveRoutePageModuleId(route.path);
|
||||
}
|
||||
|
||||
function onCategorySelect(keys, info) {
|
||||
@@ -280,7 +274,7 @@ function onCategorySelect(keys, info) {
|
||||
|
||||
function buildCategoryContextMenu(node) {
|
||||
const items = [];
|
||||
const isCategory = !node?.isFmsRoot && node?.b_node_type === "category";
|
||||
const isCategory = !node?.isFmsRoot && getNodeType(node) === "category";
|
||||
if ((node?.isFmsRoot || isCategory) && canManageTree("create")) {
|
||||
items.push(
|
||||
{
|
||||
@@ -640,8 +634,7 @@ onMounted(() => {
|
||||
v-if="shouldLoadData"
|
||||
ref="moduleListRef"
|
||||
:key="tableKey"
|
||||
:module-id="selectedModuleId"
|
||||
:profile-id="selectedProfileId"
|
||||
:page-node-id="selectedPageNodeId"
|
||||
:editable="true"
|
||||
:row-draggable="true"
|
||||
:row-selection="rowSelection"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { message, Modal } from "antdv-next";
|
||||
import { Copy, Plus, RefreshCw, Save, Trash2 } from "@lucide/vue";
|
||||
import { AlertTriangle, CheckCircle2, Copy, Plus, RefreshCw, Save, Trash2 } from "@lucide/vue";
|
||||
import FmsModuleTable from "@/components/fms-module-list/FmsModuleTable.vue";
|
||||
import { hasFieldType } from "@/module-runtime/fieldRegistry";
|
||||
import ModuleFieldsPanel from "./ModuleFieldsPanel.vue";
|
||||
import ModuleListConfigPanel from "./ModuleListConfigPanel.vue";
|
||||
import ModuleEditConfigPanel from "./ModuleEditConfigPanel.vue";
|
||||
@@ -10,7 +12,14 @@ import ModuleAutoCodePanel from "./ModuleAutoCodePanel.vue";
|
||||
import ModulePowersPanel from "./ModulePowersPanel.vue";
|
||||
import ModuleI18nPanel from "./ModuleI18nPanel.vue";
|
||||
|
||||
const props = defineProps({ controller: { type: Object, required: true } });
|
||||
const props = defineProps({
|
||||
controller: { type: Object, required: true },
|
||||
showList: { type: Boolean, default: true },
|
||||
allowDelete: { type: Boolean, default: true },
|
||||
showActions: { type: Boolean, default: true },
|
||||
showI18n: { type: Boolean, default: true },
|
||||
});
|
||||
const emit = defineEmits(["saved"]);
|
||||
const activeTab = ref("basic");
|
||||
const createOpen = ref(false);
|
||||
const cloneOpen = ref(false);
|
||||
@@ -32,6 +41,31 @@ const createForm = reactive({
|
||||
b_engine_key: "",
|
||||
});
|
||||
const cloneForm = reactive({ b_code: "", b_name: "" });
|
||||
const moduleTypeLabels = {
|
||||
page: "页面",
|
||||
data: "数据",
|
||||
report: "报表",
|
||||
dashboard: "仪表盘",
|
||||
workflow: "工作流",
|
||||
};
|
||||
|
||||
function moduleTypeLabel(type) {
|
||||
return moduleTypeLabels[type] || type || "";
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.controller.selectedId.value,
|
||||
() => {
|
||||
activeTab.value = "basic";
|
||||
},
|
||||
);
|
||||
|
||||
const previewFields = computed(() =>
|
||||
(props.controller.selected.value?.fields || []).filter((field) => Number(field.b_canuse) === 1),
|
||||
);
|
||||
const previewAvailable = computed(() =>
|
||||
previewFields.value.every((field) => hasFieldType(field.b_type || "input")),
|
||||
);
|
||||
|
||||
const currentProfiles = computed(() => {
|
||||
const selected = props.controller.selected.value;
|
||||
@@ -164,6 +198,7 @@ async function saveResource() {
|
||||
const i18nRequest = await i18nPanelRef.value?.buildSaveRequest?.();
|
||||
await props.controller.save(i18nRequest ? [i18nRequest] : []);
|
||||
i18nPanelRef.value?.markSaved?.();
|
||||
emit("saved", props.controller.selected.value);
|
||||
message.success("模块配置已保存");
|
||||
} catch (error) {
|
||||
message.error(error.message || String(error));
|
||||
@@ -224,8 +259,8 @@ function onGroupsSaved(groups, idMapping) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace">
|
||||
<aside class="resource-pane">
|
||||
<div class="workspace" :class="{ 'workspace--detail-only': !showList }">
|
||||
<aside v-if="showList" class="resource-pane">
|
||||
<div class="toolbar">
|
||||
<a-button v-if="controller.can('create')" type="primary" @click="resetCreate"
|
||||
><Plus :size="15" />新增资源</a-button
|
||||
@@ -243,13 +278,36 @@ function onGroupsSaved(groups, idMapping) {
|
||||
@click="controller.selectResource(item.b_id)"
|
||||
>
|
||||
<span>{{ item.b_name }}</span
|
||||
><small>{{ item.b_code }} · {{ item.b_module_type }}</small>
|
||||
><small>{{ item.b_code }} · {{ moduleTypeLabel(item.b_module_type) }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="detail-pane">
|
||||
<template v-if="controller.selected.value">
|
||||
<div class="toolbar toolbar--right">
|
||||
<a-popover title="配置检查" trigger="click">
|
||||
<template #content>
|
||||
<div v-if="controller.configurationIssues.value.length" class="validation-list">
|
||||
<div
|
||||
v-for="(issue, index) in controller.configurationIssues.value"
|
||||
:key="`${issue.level}-${index}`"
|
||||
:class="`validation-item validation-item--${issue.level}`"
|
||||
>
|
||||
{{ issue.message }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>当前配置完整</span>
|
||||
</template>
|
||||
<a-button>
|
||||
<AlertTriangle v-if="controller.configurationIssues.value.length" :size="15" />
|
||||
<CheckCircle2 v-else :size="15" />
|
||||
{{
|
||||
controller.configurationIssues.value.length
|
||||
? `${controller.configurationIssues.value.length} 项配置问题`
|
||||
: "配置完整"
|
||||
}}
|
||||
</a-button>
|
||||
</a-popover>
|
||||
<template
|
||||
v-if="controller.selected.value.b_module_type === 'data' && controller.can('update')"
|
||||
>
|
||||
@@ -278,7 +336,7 @@ function onGroupsSaved(groups, idMapping) {
|
||||
@click="saveResource"
|
||||
><Save :size="15" />保存</a-button
|
||||
>
|
||||
<a-button v-if="controller.can('delete')" danger @click="removeResource"
|
||||
<a-button v-if="allowDelete && controller.can('delete')" danger @click="removeResource"
|
||||
><Trash2 :size="15" />删除</a-button
|
||||
>
|
||||
</div>
|
||||
@@ -299,7 +357,7 @@ function onGroupsSaved(groups, idMapping) {
|
||||
<a-col :span="8"
|
||||
><a-form-item label="资源类型"
|
||||
><a-input
|
||||
:value="controller.selected.value.b_module_type"
|
||||
:value="moduleTypeLabel(controller.selected.value.b_module_type)"
|
||||
disabled /></a-form-item
|
||||
></a-col>
|
||||
<a-col v-if="controller.selected.value.b_module_type === 'page'" :span="8"
|
||||
@@ -481,20 +539,34 @@ function onGroupsSaved(groups, idMapping) {
|
||||
@update="(rows) => (controller.selected.value.autoCode = rows)"
|
||||
/></a-tab-pane>
|
||||
</template>
|
||||
<a-tab-pane v-if="controller.can('update')" key="actions" tab="操作"
|
||||
<a-tab-pane v-if="showActions && controller.can('update')" key="actions" tab="操作"
|
||||
><ModulePowersPanel
|
||||
:module-id="controller.selectedId.value"
|
||||
:powers="controller.selected.value.actions"
|
||||
:loading="controller.loading.value"
|
||||
@update="(rows) => (controller.selected.value.actions = rows)"
|
||||
/></a-tab-pane>
|
||||
<a-tab-pane v-if="controller.can('update')" key="i18n" tab="多语言"
|
||||
<a-tab-pane v-if="showI18n && controller.can('update')" key="i18n" tab="多语言"
|
||||
><ModuleI18nPanel
|
||||
ref="i18nPanelRef"
|
||||
:module-id="controller.selectedId.value"
|
||||
:i18n-items="i18nItems"
|
||||
:loading="controller.loading.value"
|
||||
/></a-tab-pane>
|
||||
<a-tab-pane
|
||||
v-if="controller.selected.value.b_module_type === 'data'"
|
||||
key="preview"
|
||||
tab="列表预览"
|
||||
>
|
||||
<div v-if="previewAvailable" class="runtime-preview">
|
||||
<FmsModuleTable
|
||||
:fields="previewFields"
|
||||
:list-config="controller.selected.value.listConfig || []"
|
||||
:data-source="[]"
|
||||
/>
|
||||
</div>
|
||||
<a-result v-else status="warning" title="存在未注册的字段控件,暂时无法预览" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
<a-empty v-else description="请选择模块资源" />
|
||||
@@ -510,9 +582,6 @@ function onGroupsSaved(groups, idMapping) {
|
||||
:options="[
|
||||
{ label: '页面', value: 'page' },
|
||||
{ label: '数据模块', value: 'data' },
|
||||
{ label: '报表', value: 'report' },
|
||||
{ label: '仪表盘', value: 'dashboard' },
|
||||
{ label: '工作流', value: 'workflow' },
|
||||
]" /></a-form-item
|
||||
></a-col>
|
||||
<a-col :span="8"
|
||||
@@ -590,6 +659,34 @@ function onGroupsSaved(groups, idMapping) {
|
||||
min-height: 0;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workspace--detail-only {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.validation-list {
|
||||
display: grid;
|
||||
max-width: 420px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.validation-item {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.validation-item--error {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.validation-item--warning {
|
||||
color: #ad6800;
|
||||
}
|
||||
|
||||
.runtime-preview {
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
}
|
||||
.resource-pane {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
@@ -646,6 +743,7 @@ function onGroupsSaved(groups, idMapping) {
|
||||
.resource-tabs :deep(.ant-tabs-tabpane) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.resource-tabs :deep(.ant-tabs-nav) {
|
||||
margin: 0;
|
||||
@@ -658,4 +756,21 @@ function onGroupsSaved(groups, idMapping) {
|
||||
.detail-pane > :deep(.ant-empty) {
|
||||
margin-top: 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.toolbar--right {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.basic-form {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.basic-form :deep(.ant-col-8),
|
||||
.basic-form :deep(.ant-col-12) {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
|
||||
import { buildTree, sortTree } from "@/utils/tree";
|
||||
import { invalidateModuleRuntimeCache } from "@/module-runtime";
|
||||
|
||||
export function useFunctionStructure(appStore, authorize = () => false) {
|
||||
const rows = ref([]);
|
||||
@@ -76,6 +77,7 @@ export function useFunctionStructure(appStore, authorize = () => false) {
|
||||
b_xh: nextSort(values.b_parent_id),
|
||||
};
|
||||
await saveObjectApi([{ table: "s_function_node", key_field: "b_id", inserts: [row] }]);
|
||||
invalidateModuleRuntimeCache();
|
||||
await load();
|
||||
select(id);
|
||||
}
|
||||
@@ -94,6 +96,7 @@ export function useFunctionStructure(appStore, authorize = () => false) {
|
||||
b_xh: Number(values.b_xh) || 0,
|
||||
};
|
||||
await saveObjectApi([{ table: "s_function_node", key_field: "b_id", updates: [update] }]);
|
||||
invalidateModuleRuntimeCache();
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -125,6 +128,7 @@ export function useFunctionStructure(appStore, authorize = () => false) {
|
||||
updates: [{ b_id: String(nodeId), b_parent_id: parentId || null, b_xh: Number(xh) || 0 }],
|
||||
},
|
||||
]);
|
||||
invalidateModuleRuntimeCache();
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -144,6 +148,7 @@ export function useFunctionStructure(appStore, authorize = () => false) {
|
||||
await saveObjectApi([
|
||||
{ table: "s_function_node", key_field: "b_id", deletes: [{ b_id: nodeId }] },
|
||||
]);
|
||||
invalidateModuleRuntimeCache();
|
||||
selectedId.value = "";
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, ref } from "vue";
|
||||
import { describeApi, loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
|
||||
import { allocateTemporaryIds, cloneData, diffRows, remapForeignKeys } from "@/utils/dataChanges";
|
||||
import { validateModuleCode } from "@/utils/i18n";
|
||||
import { invalidateModuleConfigCache, invalidateModuleRuntimeCache } from "@/module-runtime";
|
||||
|
||||
const CAPABILITY_TABLES = [
|
||||
["s_module_field", "fields"],
|
||||
@@ -75,6 +76,87 @@ function validateResource(resource) {
|
||||
}
|
||||
}
|
||||
|
||||
function findDuplicateValues(rows, field) {
|
||||
const seen = new Set();
|
||||
const duplicates = new Set();
|
||||
for (const row of rows || []) {
|
||||
const value = String(row?.[field] ?? "");
|
||||
if (!value) continue;
|
||||
if (seen.has(value)) duplicates.add(value);
|
||||
seen.add(value);
|
||||
}
|
||||
return [...duplicates];
|
||||
}
|
||||
|
||||
function collectConfigurationIssues(resource, profiles) {
|
||||
if (!resource) return [];
|
||||
const issues = [];
|
||||
try {
|
||||
validateResource(resource);
|
||||
} catch (error) {
|
||||
issues.push({ level: "error", message: error.message || String(error) });
|
||||
}
|
||||
|
||||
const duplicateActions = findDuplicateValues(resource.actions, "b_code");
|
||||
if (duplicateActions.length) {
|
||||
issues.push({ level: "error", message: `操作编码重复:${duplicateActions.join("、")}` });
|
||||
}
|
||||
if (resource.b_module_type !== "data") return issues;
|
||||
|
||||
const moduleProfiles = profiles.filter(
|
||||
(item) => String(item.b_module_id) === String(resource.b_id) && Number(item.b_canuse) === 1,
|
||||
);
|
||||
const defaultProfiles = moduleProfiles.filter((item) => Number(item.b_default) === 1);
|
||||
if (defaultProfiles.length !== 1) {
|
||||
issues.push({ level: "error", message: "启用的数据模块必须且只能有一个默认使用方式" });
|
||||
}
|
||||
|
||||
const activeFields = (resource.fields || []).filter((field) => Number(field.b_canuse) === 1);
|
||||
const activeFieldIds = new Set(activeFields.map((field) => String(field.b_id)));
|
||||
const activeFieldNames = new Set(activeFields.map((field) => String(field.b_field)));
|
||||
if (!activeFields.length) {
|
||||
issues.push({ level: "warning", message: "当前没有启用字段,请先同步字段" });
|
||||
}
|
||||
if (resource.b_keyfield && !activeFieldNames.has(String(resource.b_keyfield))) {
|
||||
issues.push({ level: "error", message: `主键字段 ${resource.b_keyfield} 不在启用字段中` });
|
||||
}
|
||||
|
||||
const groupIds = new Set((resource.groups || []).map((group) => String(group.b_id)));
|
||||
for (const [rowsKey, label] of [
|
||||
["listConfig", "列表"],
|
||||
["formConfig", "表单"],
|
||||
["queryConfig", "查询"],
|
||||
]) {
|
||||
const rows = resource[rowsKey] || [];
|
||||
const duplicateFields = findDuplicateValues(rows, "b_field_id");
|
||||
if (duplicateFields.length) {
|
||||
issues.push({ level: "error", message: `${label}配置存在重复字段` });
|
||||
}
|
||||
if (rows.some((row) => !activeFieldIds.has(String(row.b_field_id)))) {
|
||||
issues.push({ level: "error", message: `${label}配置引用了未启用或不存在的字段` });
|
||||
}
|
||||
if (
|
||||
rowsKey !== "queryConfig" &&
|
||||
rows.some((row) => row.b_group_id != null && !groupIds.has(String(row.b_group_id)))
|
||||
) {
|
||||
issues.push({ level: "error", message: `${label}配置引用了不存在的布局分组` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of activeFields) {
|
||||
if (!field.b_options) continue;
|
||||
try {
|
||||
JSON.parse(field.b_options);
|
||||
} catch {
|
||||
issues.push({
|
||||
level: "error",
|
||||
message: `字段 ${field.b_field} 的控件参数不是有效 JSON`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function useModuleResources(appStore, authorize = () => false) {
|
||||
const resources = ref([]);
|
||||
const profiles = ref([]);
|
||||
@@ -85,6 +167,11 @@ export function useModuleResources(appStore, authorize = () => false) {
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
function invalidateRuntime(moduleId, profileId = "") {
|
||||
invalidateModuleRuntimeCache({ moduleId, profileId });
|
||||
invalidateModuleConfigCache({ moduleId, profileId });
|
||||
}
|
||||
|
||||
const selected = computed(() => draft.value);
|
||||
const selectedProfile = computed(() => {
|
||||
return (
|
||||
@@ -92,6 +179,9 @@ export function useModuleResources(appStore, authorize = () => false) {
|
||||
);
|
||||
});
|
||||
const isDirty = computed(() => JSON.stringify(draft.value) !== JSON.stringify(original.value));
|
||||
const configurationIssues = computed(() =>
|
||||
collectConfigurationIssues(draft.value, profiles.value),
|
||||
);
|
||||
|
||||
async function loadResources() {
|
||||
const [moduleResponse, profileResponse] = await Promise.all([
|
||||
@@ -303,6 +393,7 @@ export function useModuleResources(appStore, authorize = () => false) {
|
||||
});
|
||||
}
|
||||
await saveObjectApi(saves);
|
||||
invalidateRuntime(moduleId);
|
||||
selectedId.value = moduleId;
|
||||
activeProfileId.value = values.b_module_type === "data" ? String(ids[1]) : "";
|
||||
await loadResources();
|
||||
@@ -423,6 +514,7 @@ export function useModuleResources(appStore, authorize = () => false) {
|
||||
appendCapabilityChanges(saves, changesByTable);
|
||||
saves.push(...extraRequests);
|
||||
await saveObjectApi(saves);
|
||||
invalidateRuntime(moduleId, draft.value.b_profile_id);
|
||||
await loadResources();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -572,6 +664,7 @@ export function useModuleResources(appStore, authorize = () => false) {
|
||||
{ table: "s_module_field_form", key_field: "b_id", inserts: forms },
|
||||
{ table: "s_module_field_query", key_field: "b_id", inserts: queries },
|
||||
]);
|
||||
invalidateRuntime(moduleId, profileId);
|
||||
selectedId.value = moduleId;
|
||||
activeProfileId.value = profileId;
|
||||
await loadResources();
|
||||
@@ -638,6 +731,7 @@ export function useModuleResources(appStore, authorize = () => false) {
|
||||
await saveObjectApi(
|
||||
deletions.map(([table, key]) => ({ table, key_field: key, deletes: [{ [key]: moduleId }] })),
|
||||
);
|
||||
invalidateRuntime(moduleId);
|
||||
selectedId.value = "";
|
||||
await loadResources();
|
||||
}
|
||||
@@ -653,6 +747,7 @@ export function useModuleResources(appStore, authorize = () => false) {
|
||||
saving,
|
||||
can: authorize,
|
||||
isDirty,
|
||||
configurationIssues,
|
||||
load: loadResources,
|
||||
selectResource,
|
||||
createResource,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
|
||||
import { buildTree, sortTree } from "@/utils/tree";
|
||||
import { invalidateModuleRuntimeCache } from "@/module-runtime";
|
||||
|
||||
export function usePageComposition(appStore, authorize = () => false) {
|
||||
const pages = ref([]);
|
||||
const resources = ref([]);
|
||||
const profiles = ref([]);
|
||||
const allNodes = ref([]);
|
||||
const nodes = ref([]);
|
||||
const selectedPageId = ref("");
|
||||
const selectedNodeId = ref("");
|
||||
@@ -30,7 +32,7 @@ export function usePageComposition(appStore, authorize = () => false) {
|
||||
});
|
||||
|
||||
async function loadCatalog() {
|
||||
const [pageResponse, resourceResponse, profileResponse] = await Promise.all([
|
||||
const [pageResponse, resourceResponse, profileResponse, nodeResponse] = await Promise.all([
|
||||
loadDataApi("s_module", "b_module_type = 'page' AND b_canuse = 1", "b_code ASC"),
|
||||
loadDataApi(
|
||||
"s_module",
|
||||
@@ -38,26 +40,23 @@ export function usePageComposition(appStore, authorize = () => false) {
|
||||
"b_module_type ASC, b_code ASC",
|
||||
),
|
||||
loadDataApi("s_module_profile", "b_canuse = 1", "b_module_id ASC, b_xh ASC, b_id ASC"),
|
||||
loadDataApi("v_s_page_node", "", "b_page_module_id ASC, b_xh ASC, b_id ASC"),
|
||||
]);
|
||||
pages.value = pageResponse.data || [];
|
||||
resources.value = resourceResponse.data || [];
|
||||
profiles.value = profileResponse.data || [];
|
||||
allNodes.value = nodeResponse.data || [];
|
||||
if (!selectedPageId.value && pages.value.length)
|
||||
selectedPageId.value = String(pages.value[0].b_id);
|
||||
}
|
||||
|
||||
async function loadNodes() {
|
||||
function syncSelectedPageNodes() {
|
||||
const pageId = String(selectedPageId.value || "");
|
||||
if (!pageId) {
|
||||
nodes.value = [];
|
||||
return;
|
||||
}
|
||||
const response = await loadDataApi(
|
||||
"v_s_page_node",
|
||||
`b_page_module_id = ${pageId}`,
|
||||
"b_xh ASC, b_id ASC",
|
||||
);
|
||||
nodes.value = response.data || [];
|
||||
nodes.value = allNodes.value.filter((item) => String(item.b_page_module_id) === pageId);
|
||||
if (
|
||||
selectedNodeId.value &&
|
||||
!nodes.value.some((item) => String(item.b_id) === selectedNodeId.value)
|
||||
@@ -66,11 +65,21 @@ export function usePageComposition(appStore, authorize = () => false) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodes() {
|
||||
const response = await loadDataApi(
|
||||
"v_s_page_node",
|
||||
"",
|
||||
"b_page_module_id ASC, b_xh ASC, b_id ASC",
|
||||
);
|
||||
allNodes.value = response.data || [];
|
||||
syncSelectedPageNodes();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadCatalog();
|
||||
await loadNodes();
|
||||
syncSelectedPageNodes();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -79,7 +88,7 @@ export function usePageComposition(appStore, authorize = () => false) {
|
||||
async function selectPage(id) {
|
||||
selectedPageId.value = String(id || "");
|
||||
selectedNodeId.value = "";
|
||||
await loadNodes();
|
||||
syncSelectedPageNodes();
|
||||
}
|
||||
|
||||
function selectNode(id) {
|
||||
@@ -122,6 +131,7 @@ export function usePageComposition(appStore, authorize = () => false) {
|
||||
b_xh: nextSort(values.b_parent_id),
|
||||
};
|
||||
await saveObjectApi([{ table: "s_page_node", key_field: "b_id", inserts: [row] }]);
|
||||
invalidateModuleRuntimeCache();
|
||||
await loadNodes();
|
||||
selectNode(id);
|
||||
}
|
||||
@@ -143,6 +153,7 @@ export function usePageComposition(appStore, authorize = () => false) {
|
||||
],
|
||||
},
|
||||
]);
|
||||
invalidateModuleRuntimeCache();
|
||||
await loadNodes();
|
||||
}
|
||||
|
||||
@@ -178,6 +189,7 @@ export function usePageComposition(appStore, authorize = () => false) {
|
||||
updates: [{ b_id: String(nodeId), b_parent_id: parentId || null, b_xh: Number(xh) || 0 }],
|
||||
},
|
||||
]);
|
||||
invalidateModuleRuntimeCache();
|
||||
await loadNodes();
|
||||
}
|
||||
|
||||
@@ -195,6 +207,7 @@ export function usePageComposition(appStore, authorize = () => false) {
|
||||
throw new Error("该页面节点仍被角色或用户授权引用");
|
||||
}
|
||||
await saveObjectApi([{ table: "s_page_node", key_field: "b_id", deletes: [{ b_id: nodeId }] }]);
|
||||
invalidateModuleRuntimeCache();
|
||||
selectedNodeId.value = "";
|
||||
await loadNodes();
|
||||
}
|
||||
@@ -203,6 +216,7 @@ export function usePageComposition(appStore, authorize = () => false) {
|
||||
pages,
|
||||
resources,
|
||||
profiles,
|
||||
allNodes,
|
||||
nodes,
|
||||
treeData,
|
||||
selectedPageId,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { h, onMounted, ref } from "vue";
|
||||
import { message, Modal } from "antdv-next";
|
||||
import { Boxes, LayoutPanelTop, Network } from "@lucide/vue";
|
||||
import { Boxes, LayoutPanelTop, Network, SlidersHorizontal, Workflow } from "@lucide/vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { usePermissionStore } from "@/stores/permissions";
|
||||
import BusinessStructurePanel from "./components/BusinessStructurePanel.vue";
|
||||
import FunctionStructurePanel from "./components/FunctionStructurePanel.vue";
|
||||
import PageCompositionPanel from "./components/PageCompositionPanel.vue";
|
||||
import ModuleResourcePanel from "./components/ModuleResourcePanel.vue";
|
||||
@@ -14,7 +15,8 @@ import { useModuleResources } from "./composables/useModuleResources";
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const canManage = (actionCode) => permissionStore.canAction("m_metadata", actionCode);
|
||||
const activeArea = ref("function");
|
||||
const activeMode = ref("structure");
|
||||
const advancedArea = ref("function");
|
||||
const loadedAreas = new Set();
|
||||
const functionController = useFunctionStructure(appStore, canManage);
|
||||
const pageController = usePageComposition(appStore, canManage);
|
||||
@@ -28,51 +30,92 @@ const controllers = {
|
||||
async function loadArea(area) {
|
||||
if (loadedAreas.has(area)) return;
|
||||
try {
|
||||
await controllers[area].load();
|
||||
loadedAreas.add(area);
|
||||
if (area === "structure") {
|
||||
await Promise.all(Object.values(controllers).map((controller) => controller.load()));
|
||||
loadedAreas.add("structure");
|
||||
for (const key of Object.keys(controllers)) loadedAreas.add(key);
|
||||
} else {
|
||||
await controllers[area].load();
|
||||
loadedAreas.add(area);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function changeArea(area) {
|
||||
if (area === activeArea.value) return;
|
||||
if (activeArea.value === "resource" && resourceController.isDirty.value) {
|
||||
function changeContext(nextMode, nextArea = advancedArea.value) {
|
||||
const currentUsesResource = activeMode.value === "structure" || advancedArea.value === "resource";
|
||||
const nextUsesResource = nextMode === "structure" || nextArea === "resource";
|
||||
const apply = async () => {
|
||||
activeMode.value = nextMode;
|
||||
advancedArea.value = nextArea;
|
||||
await loadArea(nextMode === "structure" ? "structure" : nextArea);
|
||||
};
|
||||
if (currentUsesResource && !nextUsesResource && resourceController.isDirty.value) {
|
||||
Modal.confirm({
|
||||
title: "当前模块有未保存修改",
|
||||
content: "切换维护区域将放弃这些修改。",
|
||||
okText: "放弃并切换",
|
||||
cancelText: "继续编辑",
|
||||
onOk: async () => {
|
||||
activeArea.value = area;
|
||||
await loadArea(area);
|
||||
await resourceController.load();
|
||||
await apply();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
activeArea.value = area;
|
||||
loadArea(area);
|
||||
apply();
|
||||
}
|
||||
|
||||
onMounted(() => loadArea(activeArea.value));
|
||||
function changeMode(mode) {
|
||||
if (mode === activeMode.value) return;
|
||||
changeContext(mode);
|
||||
}
|
||||
|
||||
function changeAdvancedArea(area) {
|
||||
if (area === advancedArea.value) return;
|
||||
changeContext("advanced", area);
|
||||
}
|
||||
|
||||
onMounted(() => loadArea("structure"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="module-management-page">
|
||||
<header class="area-switcher">
|
||||
<a-segmented
|
||||
:value="activeArea"
|
||||
:value="activeMode"
|
||||
:options="[
|
||||
{ value: 'structure', label: '目录与页面', icon: () => h(Workflow, { size: 15 }) },
|
||||
{ value: 'advanced', label: '高级维护', icon: () => h(SlidersHorizontal, { size: 15 }) },
|
||||
]"
|
||||
@change="changeMode"
|
||||
/>
|
||||
<a-segmented
|
||||
v-if="activeMode === 'advanced'"
|
||||
class="advanced-switcher"
|
||||
:value="advancedArea"
|
||||
:options="[
|
||||
{ value: 'function', label: '功能结构', icon: () => h(Network, { size: 15 }) },
|
||||
{ value: 'page', label: '页面编排', icon: () => h(LayoutPanelTop, { size: 15 }) },
|
||||
{ value: 'resource', label: '模块资源', icon: () => h(Boxes, { size: 15 }) },
|
||||
]"
|
||||
@change="changeArea"
|
||||
@change="changeAdvancedArea"
|
||||
/>
|
||||
</header>
|
||||
<section class="area-content">
|
||||
<FunctionStructurePanel v-if="activeArea === 'function'" :controller="functionController" />
|
||||
<PageCompositionPanel v-else-if="activeArea === 'page'" :controller="pageController" />
|
||||
<BusinessStructurePanel
|
||||
v-if="activeMode === 'structure'"
|
||||
:app-store="appStore"
|
||||
:function-controller="functionController"
|
||||
:page-controller="pageController"
|
||||
:resource-controller="resourceController"
|
||||
/>
|
||||
<FunctionStructurePanel
|
||||
v-else-if="advancedArea === 'function'"
|
||||
:controller="functionController"
|
||||
/>
|
||||
<PageCompositionPanel v-else-if="advancedArea === 'page'" :controller="pageController" />
|
||||
<ModuleResourcePanel v-else :controller="resourceController" />
|
||||
</section>
|
||||
</div>
|
||||
@@ -97,9 +140,24 @@ onMounted(() => loadArea(activeArea.value));
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
.advanced-switcher {
|
||||
margin-left: auto;
|
||||
}
|
||||
.area-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.area-switcher {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.advanced-switcher {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -69,7 +69,7 @@ const rowActions = [
|
||||
|
||||
<template>
|
||||
<div class="user-page">
|
||||
<FmsModuleListPage module-code="b_user" :row-actions="rowActions">
|
||||
<FmsModuleListPage :row-actions="rowActions">
|
||||
<template #actions>
|
||||
<a-button v-if="permissionStore.canAction('b_user', 'update')" @click="openRoleManager">
|
||||
<ShieldCheck :size="15" />角色维护
|
||||
|
||||
Reference in new issue
Block a user