20260729231854

This commit is contained in:
oneao committed 2026-07-29 23:18:54 +08:00
1 parent 1c839b707a
commit 84673b3d3d
39 files changed
+4683 -281

No files matched your search

+113 -7
View File
@@ -1,22 +1,93 @@
<script setup>
import { computed, ref } from "vue";
import { computed, ref, useAttrs, watch } from "vue";
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const handledEventAttrs = new Set([
"onDrop",
"onDragStart",
"onDragstart",
"onDragEnter",
"onDragenter",
"onDragLeave",
"onDragleave",
"onDragOver",
"onDragover",
"onDragEnd",
"onDragend",
"onRightClick",
"onUpdate:expandedKeys",
]);
const treeAttrs = computed(() => {
const result = {};
for (const key in attrs) {
if (!handledEventAttrs.has(key)) result[key] = attrs[key];
}
return result;
});
const props = defineProps({
treeData: { type: Array, default: () => [] },
expandedKeys: { type: Array, default: undefined },
blockNode: { type: Boolean, default: true },
virtual: { type: Boolean, default: true },
draggable: { type: [Boolean, Function, Object], default: false },
allowDrop: { type: Function, default: null },
rootKey: { type: [String, Number], default: "__fms_tree_root__" },
rootTitle: { type: String, default: "全部分类" },
// 右键菜单生成函数:(node) => { items: [...] }
contextMenu: { type: Function, default: null },
contextMenuDisabled: { type: Boolean, default: false },
});
const emit = defineEmits(["menuClick"]);
// 虚拟滚动与拖拽冲突:draggable 开启时强制关闭 virtual
const effectiveVirtual = computed(() => {
if (props.draggable) return false;
return props.virtual;
});
const effectiveTreeData = computed(() => {
return [
{
key: props.rootKey,
title: props.rootTitle,
isFmsRoot: true,
children: props.treeData,
},
];
});
const emit = defineEmits([
"menuClick",
"drop",
"dragStart",
"dragEnter",
"dragLeave",
"dragOver",
"dragEnd",
"rightClick",
"update:expandedKeys",
]);
let rootExpansionInitialized = false;
watch(
() => props.expandedKeys,
(keys) => {
if (rootExpansionInitialized || !Array.isArray(keys)) return;
rootExpansionInitialized = true;
const hasRoot = keys.some((key) => String(key) === String(props.rootKey));
if (!hasRoot) emit("update:expandedKeys", [props.rootKey, ...keys]);
},
{ immediate: true },
);
const contextNode = ref(null);
function onTreeRightClick({ node }) {
contextNode.value = node;
function onTreeRightClick(info) {
contextNode.value = info.node;
emit("rightClick", info);
}
const contextMenuItems = computed(() => {
@@ -31,6 +102,10 @@ function onMenuClick({ key }) {
contextNode.value = null;
if (node) emit("menuClick", { key, node });
}
function onExpandedKeysChange(keys) {
emit("update:expandedKeys", keys);
}
</script>
<template>
@@ -42,10 +117,23 @@ function onMenuClick({ key }) {
>
<div class="fms-tree-wrapper">
<a-tree
v-bind="$attrs"
v-bind="treeAttrs"
:tree-data="effectiveTreeData"
:expanded-keys="expandedKeys"
:default-expanded-keys="[rootKey]"
:block-node="blockNode"
:virtual="virtual"
:virtual="effectiveVirtual"
show-line
:draggable="draggable"
:allow-drop="allowDrop"
@right-click="onTreeRightClick"
@drop="$emit('drop', $event)"
@dragstart="$emit('dragStart', $event)"
@dragenter="$emit('dragEnter', $event)"
@dragleave="$emit('dragLeave', $event)"
@dragover="$emit('dragOver', $event)"
@dragend="$emit('dragEnd', $event)"
@update:expanded-keys="onExpandedKeysChange"
>
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
@@ -53,7 +141,25 @@ function onMenuClick({ key }) {
</a-tree>
</div>
</a-dropdown>
<a-tree v-else v-bind="$attrs" :block-node="blockNode" :virtual="virtual">
<a-tree
v-else
v-bind="treeAttrs"
:tree-data="effectiveTreeData"
:expanded-keys="expandedKeys"
:default-expanded-keys="[rootKey]"
:block-node="blockNode"
:virtual="effectiveVirtual"
show-line
:draggable="draggable"
:allow-drop="allowDrop"
@drop="$emit('drop', $event)"
@dragstart="$emit('dragStart', $event)"
@dragenter="$emit('dragEnter', $event)"
@dragleave="$emit('dragLeave', $event)"
@dragover="$emit('dragOver', $event)"
@dragend="$emit('dragEnd', $event)"
@update:expanded-keys="onExpandedKeysChange"
>
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
@@ -322,7 +322,6 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
grid-template-columns: minmax(0, 1fr) 172px;
gap: 14px;
padding: 10px 12px 12px;
border-left: 1px solid var(--fms-border, #e5e7eb);
border-bottom: 1px solid var(--fms-border, #e5e7eb);
background: var(--fms-surface, #fff);
+1 -2
View File
@@ -22,8 +22,7 @@ const routes = [
title: "合作伙伴",
section: "基础数据",
menuKey: "/base/othercompany",
moduleId: "base_othercompany",
dataCode: "b_othercompany",
moduleId: "b_othercompany",
},
},
{
@@ -1,24 +1,15 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { message } from "antdv-next";
import { RefreshCw } from "@lucide/vue";
import { computed, h, onMounted, reactive, ref } from "vue";
import { message, Modal } from "antdv-next";
import { Pencil, Plus, RefreshCw, Trash2 } from "@lucide/vue";
import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vue";
import FmsTree from "@/components/FmsTree.vue";
import { escapeSqlText } from "@/components/fms-module-list/moduleListUtils";
import { loadDataApi } from "@/services/api";
import { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from "@/services/api";
import { buildTree, sortTree } from "@/utils/tree";
const ALL_CATEGORIES_KEY = "__all_categories__";
const CATEGORY_COLUMNS = [
"b_id",
"b_parent_id",
"b_code",
"b_name",
"b_level",
"b_path",
"b_xh",
"b_canuse",
];
const ALL_CATEGORIES_KEY = "__fms_tree_root__";
const CATEGORY_COLUMNS = ["b_id", "b_parent_id", "b_name", "b_level", "b_path", "b_xh", "b_canuse"];
const categories = ref([]);
const categoryLoading = ref(false);
@@ -41,14 +32,7 @@ const categoryTree = computed(() => {
}),
});
sortTree(roots);
return [
{
key: ALL_CATEGORIES_KEY,
title: "全部合作伙伴",
b_xh: 0,
children: roots,
},
];
return roots;
});
const categoryFilterCondition = computed(() => {
@@ -121,6 +105,265 @@ function onCategorySelect(keys) {
if (keys.length) selectedCategoryKey.value = String(keys[0]);
}
// ---- 右键菜单 & 分类维护 ----
const addModalVisible = ref(false);
const addModalParentId = ref(null);
const addModalParentName = ref("");
const editingCategoryId = ref(null);
const addForm = reactive({ b_name: "" });
const addSaving = ref(false);
const deleteSaving = ref(false);
const categoryModalTitle = computed(() => (editingCategoryId.value ? "修改分类" : "新增分类"));
function buildCategoryContextMenu(node) {
if (!node) return { items: [] };
const items = [
{
key: "add-child",
label: node.isFmsRoot ? "新增根分类" : "添加子分类",
icon: h(Plus, { size: 14 }),
},
];
if (node.isFmsRoot) {
items.push({
key: "refresh",
label: "刷新分类",
icon: h(RefreshCw, { size: 14 }),
});
}
if (!node.isFmsRoot) {
items.push(
{
key: "edit",
label: "修改分类",
icon: h(Pencil, { size: 14 }),
},
{ type: "divider" },
{
key: "delete",
label: "删除",
icon: h(Trash2, { size: 14 }),
danger: true,
},
);
}
return {
items,
};
}
function onCategoryMenuClick({ key, node }) {
if (key === "add-child") {
editingCategoryId.value = null;
addModalParentId.value = node.isFmsRoot ? null : node.b_id || node.key;
addModalParentName.value = node.isFmsRoot ? "" : node.title || "";
addForm.b_name = "";
addModalVisible.value = true;
return;
}
if (key === "edit") {
openEditCategoryModal(node);
return;
}
if (key === "refresh") {
loadCategories();
return;
}
if (key === "delete") confirmDeleteCategory(node);
}
function openEditCategoryModal(node) {
if (!node || node.isFmsRoot) return;
const categoryId = String(node.b_id || node.key || "");
const category = categoryById.value.get(categoryId);
if (!category) {
message.error("未找到需要修改的分类");
return;
}
const parentId = category.b_parent_id == null ? null : String(category.b_parent_id);
const parent = parentId ? categoryById.value.get(parentId) : null;
editingCategoryId.value = categoryId;
addModalParentId.value = parentId;
addModalParentName.value = parent?.b_name || "";
addForm.b_name = category.b_name || "";
addModalVisible.value = true;
}
async function saveCategory() {
const name = (addForm.b_name || "").trim();
if (!name) {
message.warning("请填写名称");
return;
}
addSaving.value = true;
try {
if (editingCategoryId.value) {
await saveObjectApi([
{
table: "b_othercompany_bmfl",
key_field: "b_id",
updates: [{ b_id: editingCategoryId.value, b_name: name }],
},
]);
message.success("分类已修改");
addModalVisible.value = false;
await loadCategories();
return;
}
const idRes = await nextIdApi();
const newId = String(idRes.data);
let level = 1;
let path = newId;
if (addModalParentId.value) {
const parent = categoryById.value.get(String(addModalParentId.value));
if (parent) {
level = (Number(parent.b_level) || 0) + 1;
path = (parent.b_path || String(parent.b_id)) + "/" + newId;
}
}
// 计算 b_xh:同级最大值 + 10
const siblings = categories.value.filter((item) =>
addModalParentId.value
? String(item.b_parent_id) === String(addModalParentId.value)
: item.b_parent_id == null,
);
const maxXh = siblings.reduce((max, item) => Math.max(max, Number(item.b_xh) || 0), 0);
await saveObjectApi([
{
table: "b_othercompany_bmfl",
key_field: "b_id",
inserts: [
{
b_id: newId,
b_parent_id: addModalParentId.value || null,
b_name: name,
b_level: level,
b_path: path,
b_xh: maxXh + 10,
b_canuse: 1,
},
],
},
]);
message.success("分类已添加");
addModalVisible.value = false;
await loadCategories();
} catch (e) {
message.error("添加失败:" + (e.message || e));
} finally {
addSaving.value = false;
}
}
function hasChildCategory(categoryId) {
for (const category of categories.value) {
if (String(category.b_parent_id || "") === categoryId) return true;
}
return false;
}
async function hasCategoryData(categoryId) {
const response = await pageDataApi({
viewName: "b_othercompany",
orderBy: "b_id ASC",
searchColumns: ["b_id"],
searchCondition: `[b_cate_id] = N'${escapeSqlText(categoryId)}'`,
pageNo: 1,
pageSize: 1,
});
return Boolean(response.data?.rows?.length);
}
async function hasPersistedChildCategory(categoryId) {
const response = await pageDataApi({
viewName: "b_othercompany_bmfl",
orderBy: "b_id ASC",
searchColumns: ["b_id"],
searchCondition: `[b_parent_id] = ${categoryId}`,
pageNo: 1,
pageSize: 1,
});
return Boolean(response.data?.rows?.length);
}
async function confirmDeleteCategory(node) {
if (!node || node.isFmsRoot || deleteSaving.value) return;
const categoryId = String(node.b_id || node.key || "");
if (!/^\d+$/.test(categoryId)) {
message.error("分类 ID 无效,无法删除");
return;
}
if (hasChildCategory(categoryId)) {
message.warning("该分类下还有子分类,不能删除");
return;
}
deleteSaving.value = true;
try {
if (await hasCategoryData(categoryId)) {
message.warning("该分类下还有合作伙伴数据,不能删除");
return;
}
} catch (error) {
message.error("分类数据检查失败:" + (error?.message || error));
return;
} finally {
deleteSaving.value = false;
}
deleteSaving.value = true;
Modal.confirm({
title: `确定删除「${node.title}」?`,
content: "此操作不可恢复",
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onCancel: () => {
deleteSaving.value = false;
},
onOk: async () => {
try {
const [hasChild, hasData] = await Promise.all([
hasPersistedChildCategory(categoryId),
hasCategoryData(categoryId),
]);
if (hasChild) {
message.warning("该分类下还有子分类,不能删除");
return;
}
if (hasData) {
message.warning("该分类下还有合作伙伴数据,不能删除");
return;
}
await saveObjectApi([
{
table: "b_othercompany_bmfl",
key_field: "b_id",
deletes: [{ b_id: categoryId }],
},
]);
if (selectedCategoryKey.value === categoryId) {
selectedCategoryKey.value = ALL_CATEGORIES_KEY;
}
message.success("分类已删除");
await loadCategories();
} catch (error) {
message.error("删除失败:" + (error?.message || error));
throw error;
} finally {
deleteSaving.value = false;
}
},
});
}
onMounted(loadCategories);
</script>
@@ -129,26 +372,15 @@ onMounted(loadCategories);
<a-splitter class="othercompany-splitter" lazy>
<a-splitter-panel :default-size="250" :min="210" :max="380" collapsible>
<aside class="category-panel">
<header class="category-header">
<div class="category-title">合作伙伴分类</div>
<a-tooltip title="刷新分类">
<a-button
type="text"
:disabled="categoryLoading"
aria-label="刷新分类"
@click="loadCategories"
>
<RefreshCw :size="15" />
</a-button>
</a-tooltip>
</header>
<div class="category-tree-stage">
<FmsTree
v-model:expanded-keys="expandedCategoryKeys"
:tree-data="categoryTree"
:selected-keys="[selectedCategoryKey]"
show-line
:context-menu="buildCategoryContextMenu"
:context-menu-disabled="categoryLoading || addSaving || deleteSaving"
@select="onCategorySelect"
@menu-click="onCategoryMenuClick"
/>
<div v-if="categoryLoading" class="category-loading"><a-spin size="small" /></div>
</div>
@@ -165,6 +397,22 @@ onMounted(loadCategories);
</main>
</a-splitter-panel>
</a-splitter>
<a-modal
v-model:open="addModalVisible"
:title="categoryModalTitle"
:confirm-loading="addSaving"
@ok="saveCategory"
>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item v-if="addModalParentName" label="上级分类">
<a-input :value="addModalParentName" disabled />
</a-form-item>
<a-form-item label="名称">
<a-input v-model:value="addForm.b_name" :maxlength="150" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@@ -194,35 +442,12 @@ onMounted(loadCategories);
flex-direction: column;
}
.category-header {
display: flex;
min-height: 50px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 6px 4px 12px;
border-bottom: 1px solid var(--fms-border);
}
.category-title {
min-width: 0;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.category-header :deep(.ant-btn) {
width: 28px;
height: 28px;
padding: 0;
}
.category-tree-stage {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
padding: 6px 4px;
padding: 12px;
}
.category-tree-stage :deep(.ant-tree) {
@@ -1,12 +1,28 @@
import { createTempId } from "@/utils/tempId";
const BUSINESS_CODE_FIELDS = [
"b_ywno",
"b_htno",
"b_code",
"b_account",
"b_bill_no",
"b_invoice_no",
"b_business_id",
];
function defaultFieldId(fields) {
const field = (fields || []).find(
const textFields = (fields || []).filter(
(item) =>
Number(item.b_canuse) === 1 &&
item.b_field !== "b_id" &&
(item.b_type === "input" || item.b_type === "textarea"),
);
let field = null;
for (const fieldName of BUSINESS_CODE_FIELDS) {
field = textFields.find((item) => item.b_field === fieldName);
if (field) break;
}
if (!field) field = textFields[0];
return field?.b_id != null ? String(field.b_id) : null;
}
@@ -73,11 +73,11 @@ function addGroup(parentId) {
selectedGroupId.value = String(newGroup.b_id);
}
function deleteGroup() {
if (!selectedGroupId.value) return;
function deleteGroupById(id) {
if (!id) return;
let hasChildren = false;
for (const group of localGroups.value) {
if (String(group.b_parent_id) === selectedGroupId.value) {
if (String(group.b_parent_id) === String(id)) {
hasChildren = true;
break;
}
@@ -88,7 +88,7 @@ function deleteGroup() {
}
let isReferenced = false;
for (const groupId of props.referencedGroupIds) {
if (String(groupId) === selectedGroupId.value) {
if (String(groupId) === String(id)) {
isReferenced = true;
break;
}
@@ -97,8 +97,27 @@ function deleteGroup() {
message.warning("该分组仍被列表或编辑配置引用,请先清空引用");
return;
}
localGroups.value = localGroups.value.filter((g) => String(g.b_id) !== selectedGroupId.value);
selectedGroupId.value = null;
localGroups.value = localGroups.value.filter((g) => String(g.b_id) !== String(id));
if (String(selectedGroupId.value) === String(id)) {
selectedGroupId.value = null;
}
}
function buildContextMenu(node) {
if (!node) return { items: [] };
const items = [];
items.push({ key: "add-child", label: "添加子分组" });
items.push({ type: "divider" });
items.push({ key: "delete", label: "删除", danger: true });
return { items };
}
function onMenuClick({ key, node }) {
if (key === "add-child") {
addGroup(node.key);
} else if (key === "delete") {
deleteGroupById(node.key);
}
}
async function saveAllGroups() {
@@ -142,57 +161,54 @@ function onClose() {
</script>
<template>
<a-modal :open="visible" title="分组管理" width="650px" @cancel="onClose" @ok="saveAllGroups">
<a-modal
:open="visible"
title="分组管理"
width="680px"
:destroy-on-hidden="true"
@cancel="onClose"
@ok="saveAllGroups"
>
<div class="group-manager">
<div class="group-sidebar">
<div class="group-sidebar-header">
<span>分组树</span>
<a-button size="small" type="primary" @click="addGroup(null)">新增</a-button>
</div>
<FmsTree
v-if="treeData.length > 0"
class="group-tree"
:tree-data="treeData"
:selected-keys="selectedGroupId ? [selectedGroupId] : []"
:context-menu="buildContextMenu"
@select="onTreeSelect"
>
<template #title="{ key, title: nodeTitle }">
<span class="group-tree-node">
<span>{{ nodeTitle }}</span>
<span class="group-tree-node-actions">
<a-button size="small" type="text" @click.stop="addGroup(key)">+</a-button>
</span>
</span>
</template>
</FmsTree>
<a-empty v-else description="暂无分组" />
@menu-click="onMenuClick"
/>
</div>
<div v-if="selectedGroup" class="group-detail">
<a-form layout="vertical">
<a-form-item label="分组标题" required>
<a-input v-model:value="selectedGroup.b_title" />
</a-form-item>
<a-form-item label="多语言键">
<a-input
:value="selectedGroup.b_i18n"
@update:value="(v) => (selectedGroup.b_i18n = v || null)"
/>
</a-form-item>
<a-form-item label="排序号">
<a-input-number v-model:value="selectedGroup.b_xh" :min="0" />
</a-form-item>
<a-form-item label="启用">
<a-checkbox
:checked="selectedGroup.b_canuse === 1"
@update:checked="(v) => (selectedGroup.b_canuse = v ? 1 : 0)"
/>
</a-form-item>
<a-form-item>
<a-button danger size="small" @click="deleteGroup">删除此分组</a-button>
</a-form-item>
</a-form>
<div class="group-detail">
<template v-if="selectedGroup">
<a-form layout="vertical" class="group-form">
<a-form-item label="标题" required>
<a-input v-model:value="selectedGroup.b_title" />
</a-form-item>
<a-form-item label="多语言键">
<a-input
:value="selectedGroup.b_i18n"
@update:value="(v) => (selectedGroup.b_i18n = v || null)"
/>
</a-form-item>
<div class="group-form-row">
<a-form-item label="排序号" style="margin-bottom: 0">
<a-input-number v-model:value="selectedGroup.b_xh" :min="0" style="width: 100px" />
</a-form-item>
<a-form-item label="启用" style="margin-bottom: 0">
<a-checkbox
:checked="selectedGroup.b_canuse === 1"
@update:checked="(v) => (selectedGroup.b_canuse = v ? 1 : 0)"
/>
</a-form-item>
</div>
</a-form>
</template>
<div v-else class="group-detail-empty">
<a-empty description="右键树节点可添加 / 删除分组" />
</div>
</div>
<a-empty v-else description="选择左侧分组节点编辑" class="group-detail-empty" />
</div>
</a-modal>
</template>
@@ -200,52 +216,26 @@ function onClose() {
<style scoped lang="scss">
.group-manager {
display: flex;
gap: 16px;
min-height: 350px;
gap: 20px;
min-height: 320px;
}
.group-sidebar {
display: flex;
flex-direction: column;
flex: none;
width: 240px;
width: 200px;
border: 1px solid var(--fms-border);
border-radius: 4px;
border-radius: 6px;
overflow: hidden;
}
.group-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
flex: none;
padding: 8px 12px;
border-bottom: 1px solid var(--fms-border);
font-size: 13px;
font-weight: 600;
background: var(--fms-bg);
}
.group-tree {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 0;
}
.group-tree-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.group-tree-node-actions {
display: none;
flex: none;
}
.group-tree-node:hover .group-tree-node-actions {
display: flex;
padding: 6px 4px;
}
.group-detail {
@@ -253,10 +243,23 @@ function onClose() {
min-width: 0;
}
.group-form {
:deep(.ant-form-item) {
margin-bottom: 14px;
}
}
.group-form-row {
display: flex;
gap: 28px;
align-items: flex-start;
}
.group-detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 260px;
}
</style>
@@ -1,6 +1,6 @@
<script setup>
import { computed, ref, watch } from "vue";
import { createAutoCodeDraft } from "../autoCode";
import { createAutoCodeDraft } from "../common";
const DATE_FORMAT_OPTIONS = [
{ label: "不使用日期", value: "" },
@@ -1,18 +1,21 @@
<script setup>
import { computed, ref, shallowRef, watch } from "vue";
import { message } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import FieldGroupManager from "./FieldGroupManager.vue";
const props = defineProps({
moduleId: { type: String, required: true },
editConfig: { type: Array, default: () => [] },
listConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
referencedGroupIds: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update", "groupsSaved"]);
const emit = defineEmits(["update", "groupsSaved", "syncList", "syncQuery"]);
const localEdit = ref([]);
const groupManagerVisible = ref(false);
@@ -61,10 +64,54 @@ const groupOptions = computed(() => {
return options;
});
function deleteRow(record) {
function syncToList() {
if (props.loading) return;
localEdit.value = localEdit.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
const visibleMap = new Map();
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0);
}
}
if (visibleMap.size === 0) {
message.info("编辑配置中没有字段");
return;
}
let count = 0;
const updated = (props.listConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id));
if (target !== undefined) {
count++;
return { ...row, b_visible: target };
}
return row;
});
emit("syncList", updated);
message.success(`已同步 ${count} 条到列表配置`);
}
function syncToQuery() {
if (props.loading) return;
const visibleMap = new Map();
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0);
}
}
if (visibleMap.size === 0) {
message.info("编辑配置中没有字段");
return;
}
let count = 0;
const updated = (props.queryConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id));
if (target !== undefined) {
count++;
return { ...row, b_canuse: target };
}
return row;
});
emit("syncQuery", updated);
message.success(`已同步 ${count} 条到查询配置`);
}
const columns = shallowRef([
@@ -115,20 +162,6 @@ const columns = shallowRef([
},
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
actions: [
{
type: "danger",
label: "删除",
confirm: { title: "确定删除该记录?", okText: "删除" },
onClick: (row) => deleteRow(row),
},
],
},
]);
</script>
@@ -137,6 +170,12 @@ const columns = shallowRef([
<div class="panel-toolbar">
<a-space>
<a-button :disabled="loading" @click="groupManagerVisible = true">分组管理</a-button>
<a-button :disabled="loading || !listConfig.length" @click="syncToList"
>同步到列表配置</a-button
>
<a-button :disabled="loading || !queryConfig.length" @click="syncToQuery"
>同步到查询配置</a-button
>
</a-space>
</div>
<div class="panel-body">
@@ -1,18 +1,21 @@
<script setup>
import { computed, ref, shallowRef, watch } from "vue";
import { message } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import FieldGroupManager from "./FieldGroupManager.vue";
const props = defineProps({
moduleId: { type: String, required: true },
listConfig: { type: Array, default: () => [] },
editConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
referencedGroupIds: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update", "groupsSaved"]);
const emit = defineEmits(["update", "groupsSaved", "syncEdit", "syncQuery"]);
const localList = ref([]);
const groupManagerVisible = ref(false);
@@ -61,10 +64,54 @@ const groupOptions = computed(() => {
return options;
});
function deleteRow(record) {
function syncToEdit() {
if (props.loading) return;
localList.value = localList.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
const visibleMap = new Map();
for (const row of localList.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0);
}
}
if (visibleMap.size === 0) {
message.info("列表配置中没有字段");
return;
}
let count = 0;
const updated = (props.editConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id));
if (target !== undefined) {
count++;
return { ...row, b_visible: target };
}
return row;
});
emit("syncEdit", updated);
message.success(`已同步 ${count} 条到编辑显示`);
}
function syncToQuery() {
if (props.loading) return;
const visibleMap = new Map();
for (const row of localList.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0);
}
}
if (visibleMap.size === 0) {
message.info("列表配置中没有字段");
return;
}
let count = 0;
const updated = (props.queryConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id));
if (target !== undefined) {
count++;
return { ...row, b_canuse: target };
}
return row;
});
emit("syncQuery", updated);
message.success(`已同步 ${count} 条到查询配置`);
}
const columns = shallowRef([
@@ -95,20 +142,6 @@ const columns = shallowRef([
editor: { type: "checkbox" },
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
actions: [
{
type: "danger",
label: "删除",
confirm: { title: "确定删除该记录?", okText: "删除" },
onClick: (row) => deleteRow(row),
},
],
},
]);
</script>
@@ -117,6 +150,12 @@ const columns = shallowRef([
<div class="panel-toolbar">
<a-space>
<a-button :disabled="loading" @click="groupManagerVisible = true">分组管理</a-button>
<a-button :disabled="loading || !editConfig.length" @click="syncToEdit"
>同步到编辑显示</a-button
>
<a-button :disabled="loading || !queryConfig.length" @click="syncToQuery"
>同步到查询配置</a-button
>
</a-space>
</div>
<div class="panel-body">
@@ -79,12 +79,6 @@ function addRow() {
emitUpdate();
}
function deleteRow(record) {
if (props.loading) return;
localQuery.value = localQuery.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
const columns = shallowRef([
{
dataIndex: "b_field_id",
@@ -122,20 +116,6 @@ const columns = shallowRef([
editor: { type: "checkbox" },
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
actions: [
{
type: "danger",
label: "删除",
confirm: { title: "确定删除该记录?", okText: "删除" },
onClick: (row) => deleteRow(row),
},
],
},
]);
</script>
@@ -21,7 +21,6 @@ import {
syncModuleItemI18nKeys,
validateModuleCode,
} from "@/utils/i18n";
import { Plus } from "@lucide/vue";
import ModuleBasicPanel from "./components/ModuleBasicPanel.vue";
import ModuleFieldsPanel from "./components/ModuleFieldsPanel.vue";
import ModuleListConfigPanel from "./components/ModuleListConfigPanel.vue";
@@ -30,7 +29,7 @@ import ModuleQueryConfigPanel from "./components/ModuleQueryConfigPanel.vue";
import ModuleAutoCodePanel from "./components/ModuleAutoCodePanel.vue";
import ModulePowersPanel from "./components/ModulePowersPanel.vue";
import ModuleI18nPanel from "./components/ModuleI18nPanel.vue";
import { createAutoCodeDraft } from "./autoCode";
import { createAutoCodeDraft } from "./common";
// ---- 状态 ----
const appStore = useAppStore();
@@ -43,6 +42,10 @@ const activeTab = ref("basic");
function buildTreeContextMenu(node) {
const items = [];
if (!node) return { items };
if (node.isFmsRoot) {
items.push({ label: "添加目录", key: "add-directory" });
return { items };
}
if (node.b_module_type === "directory") {
items.push(
{ label: "添加目录", key: "add-directory" },
@@ -241,11 +244,131 @@ async function loadTree() {
try {
const res = await loadDataApi("s_module", "b_canuse = 1", "b_xh ASC, b_id ASC");
treeData.value = buildModuleTree(res.data || []);
unsavedModuleIds.clear();
} finally {
treeLoading.value = false;
}
}
// ---- 拖拽 ----
const dragNodeKey = ref(null);
const unsavedModuleIds = new Set();
function onTreeDragStart(info) {
dragNodeKey.value = info.node.key;
}
function isDescendantOf(ancestorKey, descendantKey) {
const ancestorNode = findNode(ancestorKey);
if (!ancestorNode) return false;
const walk = (nodes) => {
for (const n of nodes || []) {
if (String(n.key || n.b_id) === String(descendantKey)) return true;
if (walk(n.children)) return true;
}
return false;
};
return walk(ancestorNode.children);
}
function allowDrop({ dragNode, dropNode }) {
let dragKey = dragNodeKey.value;
if (dragNode && dragNode.key) {
dragKey = dragNode.key;
}
if (!dragKey || !dropNode || dragNode?.isFmsRoot) return false;
if (String(dropNode.key) === String(dragKey)) return false;
if (isDescendantOf(dragKey, dropNode.key)) return false;
return true;
}
async function onTreeDrop(info) {
const dropKey = info.node.key;
const dragKey = info.dragNode.key;
const dropPos = info.node.pos.split("-");
const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);
const data = cloneData(treeData.value);
// 从树中移除拖拽节点
let dragObj = null;
function removeFromTree(nodes, key) {
for (let i = 0; i < nodes.length; i++) {
if (String(nodes[i].key) === String(key)) {
dragObj = nodes.splice(i, 1)[0];
return true;
}
if (nodes[i].children && removeFromTree(nodes[i].children, key)) return true;
}
return false;
}
removeFromTree(data, dragKey);
if (!dragObj) {
dragNodeKey.value = null;
return;
}
// 插入到新位置
function insertIntoTree(nodes, dropNodeKey, parentNode) {
for (let i = 0; i < nodes.length; i++) {
if (String(nodes[i].key) === String(dropNodeKey)) {
if (!info.dropToGap) {
// 拖放到节点上 → 成为其子节点
nodes[i].children = nodes[i].children || [];
nodes[i].children.unshift(dragObj);
dragObj.b_parent_id = nodes[i].b_id;
} else if (dropPosition === -1) {
// 拖放到节点上方 → 同级插入
nodes.splice(i, 0, dragObj);
dragObj.b_parent_id = parentNode ? parentNode.b_id : null;
} else {
// 拖放到节点下方 → 同级插入
nodes.splice(i + 1, 0, dragObj);
dragObj.b_parent_id = parentNode ? parentNode.b_id : null;
}
return true;
}
if (nodes[i].children && insertIntoTree(nodes[i].children, dropNodeKey, nodes[i])) return true;
}
return false;
}
if (info.node.isFmsRoot) {
dragObj.b_parent_id = null;
data.unshift(dragObj);
} else {
insertIntoTree(data, dropKey, null);
}
treeData.value = data;
const isUnsaved = unsavedModuleIds.has(String(dragObj.b_id));
// 未保存的模块只更新本地数据,等用户点击"保存"时一并落库
if (isUnsaved) {
if (drafts.value.module && String(drafts.value.module.b_id) === String(dragObj.b_id)) {
drafts.value.module.b_parent_id = dragObj.b_parent_id || null;
}
dragNodeKey.value = null;
return;
}
// 已存在的模块直接持久化到后端
try {
await saveObjectApi([
{
table: "s_module",
key_field: "b_id",
updates: [{ b_id: String(dragObj.b_id), b_parent_id: dragObj.b_parent_id || null }],
},
]);
} catch (e) {
message.error("移动失败:" + (e.message || e));
await loadTree();
} finally {
dragNodeKey.value = null;
}
}
// ---- 模块配置加载 ----
async function loadModuleConfig(moduleId) {
configLoading.value = true;
@@ -321,6 +444,10 @@ async function loadModuleConfig(moduleId) {
// ---- 树节点选择 ----
function onTreeSelect(keys, info) {
if (busy.value) return;
if (info?.node?.isFmsRoot) {
selectedModuleId.value = null;
return;
}
const nodeKey = info?.node?.key ?? keys[0];
if (nodeKey == null) return;
@@ -537,22 +664,19 @@ async function onAddModule(parentId, moduleType = "directory") {
selectedModuleId.value = newId;
loadedModuleId.value = newId;
activeTab.value = "basic";
unsavedModuleIds.add(newId);
updateSavedTreeNode(newModule);
if (parentId && !expandedTreeKeys.value.includes(String(parentId))) {
expandedTreeKeys.value = [...expandedTreeKeys.value, String(parentId)];
}
}
const addModuleMenu = {
items: [{ key: "directory", label: "目录" }],
onClick: ({ key }) => onAddModule(null, key),
};
// ---- 右键菜单 ----
function onTreeMenuClick({ key, node }) {
if (busy.value) return;
if (key.startsWith("add-")) {
onAddModule(node.key, key.replace("add-", ""));
const parentId = node.isFmsRoot ? null : node.key;
onAddModule(parentId, key.replace("add-", ""));
} else if (key === "delete") {
if (hasChildNode(node.key)) {
Modal.warning({
@@ -749,6 +873,7 @@ async function saveAll() {
if (i18nPanelRef.value) {
i18nPanelRef.value.markSaved();
}
unsavedModuleIds.delete(moduleId);
updateSavedTreeNode(moduleData);
if (appStore.locale) await appStore.loadTranslations(appStore.locale);
message.success("保存成功");
@@ -984,43 +1109,43 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
<template>
<div class="mm-page">
<!-- 左侧模块树 -->
<aside class="mm-sidebar">
<div class="mm-sidebar-header">
<span class="mm-sidebar-title">模块列表</span>
<a-dropdown :menu="addModuleMenu" :trigger="['click']" :disabled="busy">
<a-button :disabled="busy"><Plus :size="14" />新增</a-button>
</a-dropdown>
</div>
<div v-if="treeLoading" class="mm-tree-empty">
<a-spin />
</div>
<div v-else-if="treeData.length === 0" class="mm-tree-empty">
<a-empty description="暂无模块" />
</div>
<FmsTree
v-else
class="mm-tree"
:disabled="configSaving"
:tree-data="treeData"
v-model:expanded-keys="expandedTreeKeys"
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
:context-menu="buildTreeContextMenu"
:context-menu-disabled="busy"
@select="onTreeSelect"
@menu-click="onTreeMenuClick"
>
<template #title="node">
<span class="mm-tree-node">
<span>{{ node.title }}</span>
</span>
</template>
</FmsTree>
</aside>
<a-splitter class="mm-splitter" lazy>
<a-splitter-panel :default-size="260" :min="210" :max="440" collapsible>
<!-- 左侧模块树 -->
<aside class="mm-sidebar">
<div class="mm-tree-stage">
<div v-if="treeLoading" class="mm-tree-empty">
<a-spin />
</div>
<FmsTree
v-else
:disabled="configSaving"
:tree-data="treeData"
v-model:expanded-keys="expandedTreeKeys"
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
:context-menu="buildTreeContextMenu"
:context-menu-disabled="busy"
draggable
:allow-drop="allowDrop"
@select="onTreeSelect"
@menu-click="onTreeMenuClick"
@drop="onTreeDrop"
@drag-start="onTreeDragStart"
>
<template #title="node">
<span class="mm-tree-node">
<span>{{ node.title }}</span>
</span>
</template>
</FmsTree>
</div>
</aside>
</a-splitter-panel>
<!-- 右侧详情 -->
<main class="mm-main">
<FmsLoadingOverlay :loading="configLoading">
<a-splitter-panel :min="720">
<!-- 右侧详情 -->
<main class="mm-main">
<FmsLoadingOverlay :loading="configLoading">
<template v-if="selectedModule">
<!-- 页面头部 -->
<div class="mm-header">
@@ -1100,23 +1225,31 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
v-else-if="tab.key === 'list'"
:module-id="String(selectedModule.b_id)"
:list-config="drafts.listConfig"
:edit-config="drafts.editConfig"
:query-config="drafts.queryConfig"
:fields="drafts.fields"
:groups="drafts.groups"
:referenced-group-ids="referencedGroupIds"
:loading="busy"
@update="(v) => onUpdate('listConfig', v)"
@groups-saved="onGroupsSaved"
@sync-edit="(v) => onUpdate('editConfig', v)"
@sync-query="(v) => onUpdate('queryConfig', v)"
/>
<ModuleEditConfigPanel
v-else-if="tab.key === 'edit'"
:module-id="String(selectedModule.b_id)"
:edit-config="drafts.editConfig"
:list-config="drafts.listConfig"
:query-config="drafts.queryConfig"
:fields="drafts.fields"
:groups="drafts.groups"
:referenced-group-ids="referencedGroupIds"
:loading="busy"
@update="(v) => onUpdate('editConfig', v)"
@groups-saved="onGroupsSaved"
@sync-list="(v) => onUpdate('listConfig', v)"
@sync-query="(v) => onUpdate('queryConfig', v)"
/>
<ModuleQueryConfigPanel
v-else-if="tab.key === 'query'"
@@ -1159,50 +1292,43 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
<div v-else class="mm-empty">
<a-result status="info" title="请选择模块" sub-title="从左侧树选择一个模块查看详情" />
</div>
</FmsLoadingOverlay>
</main>
</FmsLoadingOverlay>
</main>
</a-splitter-panel>
</a-splitter>
</div>
</template>
<style scoped lang="scss">
.mm-page {
display: flex;
.mm-page,
.mm-splitter {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.mm-page {
background: var(--fms-bg);
}
.mm-sidebar {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
flex: none;
width: 260px;
border-right: 1px solid var(--fms-border);
background: var(--fms-surface);
}
.mm-sidebar-header {
.mm-tree-stage {
display: flex;
align-items: center;
justify-content: space-between;
flex: none;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--fms-border);
}
.mm-sidebar-title {
font-size: 14px;
font-weight: 600;
color: var(--fms-text);
}
.mm-tree {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 10px;
flex-direction: column;
padding: 12px;
}
.mm-tree-empty {
@@ -1224,10 +1350,11 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
.mm-main {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
background: var(--fms-surface);
}