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

@@ -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" />角色维护