20260804173126

This commit is contained in:
oneao committed 2026-08-04 17:31:27 +08:00
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" />角色维护