20260730173012

This commit is contained in:
oneao committed 2026-07-30 17:30:13 +08:00
1 parent 84673b3d3d
commit 4929b78434
25 files changed
+1796 -68

No files matched your search

@@ -1,5 +1,15 @@
<script setup>
import { computed, h, nextTick, reactive, ref, shallowRef, useSlots, watch } from "vue";
import {
computed,
h,
nextTick,
onActivated,
reactive,
ref,
shallowRef,
useSlots,
watch,
} from "vue";
import { useRoute } from "vue-router";
import { message } from "antdv-next";
import { Columns3, RefreshCw, Save } from "@lucide/vue";
@@ -33,6 +43,9 @@ const slots = useSlots();
const route = useRoute();
const appStore = useAppStore();
const tableRef = ref(null);
// keep-alive 中的缓存实例必须保留各自的路由上下文,不能跟随全局 route 变化。
const instanceRoutePath = route.path;
const instanceRouteDataCode = route.meta.dataCode;
// ── 内部状态 ──
const dataModule = ref(null);
@@ -91,12 +104,11 @@ function resolveKeyField(module, fields) {
}
function getDataCode() {
return String(props.dataCode || route.meta.dataCode || "").trim();
return String(props.dataCode || instanceRouteDataCode || "").trim();
}
async function loadModuleConfig() {
const code = getDataCode();
const currentPath = route.path;
configLoading.value = true;
configError.value = "";
@@ -122,7 +134,7 @@ async function loadModuleConfig() {
throw new Error(`数据编码 ${runtimeData.b_code} 没有对应的启用页面`);
}
} else {
const routePath = escapeSqlText(currentPath);
const routePath = escapeSqlText(instanceRoutePath);
const pageRes = await loadDataApi(
"s_module",
`b_route = N'${routePath}' AND b_canuse = 1`,
@@ -160,7 +172,7 @@ async function loadModuleConfig() {
"b_xh ASC, b_id ASC",
),
]);
if (getDataCode() !== code || route.path !== currentPath) return false;
if (getDataCode() !== code) return false;
const fields = [];
const fieldById = new Map();
@@ -205,12 +217,12 @@ async function loadModuleConfig() {
keyField.value = resolveKeyField(runtimeData, fields);
return true;
} catch (error) {
if (getDataCode() === code && route.path === currentPath) {
if (getDataCode() === code) {
configError.value = error?.message || String(error);
}
return false;
} finally {
if (getDataCode() === code && route.path === currentPath) {
if (getDataCode() === code) {
configLoading.value = false;
}
}
@@ -239,7 +251,6 @@ async function fetchData() {
const currentModule = dataModule.value;
const viewName = currentModule.b_viewtable;
const currentCode = getDataCode();
const currentPath = route.path;
const requestVersion = ++dataRequestVersion;
const conditions = [];
const fixedCondition = String(props.fixedSearchCondition || "").trim();
@@ -259,15 +270,14 @@ async function fetchData() {
});
if (requestVersion !== dataRequestVersion || dataModule.value !== currentModule) return;
if (getDataCode() !== currentCode || route.path !== currentPath) return;
if (getDataCode() !== currentCode) return;
dataSource.value = res.data?.rows || [];
pagination.total = Number(res.data?.total ?? res.data?.row_count) || 0;
} catch (error) {
if (
requestVersion === dataRequestVersion &&
dataModule.value === currentModule &&
getDataCode() === currentCode &&
route.path === currentPath
getDataCode() === currentCode
) {
dataSource.value = [];
pagination.total = 0;
@@ -277,8 +287,7 @@ async function fetchData() {
if (
requestVersion === dataRequestVersion &&
dataModule.value === currentModule &&
getDataCode() === currentCode &&
route.path === currentPath
getDataCode() === currentCode
) {
dataLoading.value = false;
}
@@ -412,7 +421,19 @@ async function handleMoreAction({ key }) {
}
// ── 生命周期 ──
watch(() => [getDataCode(), route.path], initialize, { immediate: true });
let hasActivated = false;
void initialize();
onActivated(async () => {
if (!hasActivated) {
hasActivated = true;
return;
}
await nextTick();
tableRef.value?.refreshLayout?.();
});
watch(() => props.dataCode, initialize);
watch(
() => props.fixedSearchCondition,
@@ -41,7 +41,7 @@ function formatCellValue(value, field, optionInfo) {
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, disabled: true });
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));
@@ -199,6 +199,10 @@ function resetColumns() {
tableRef.value?.initVirtualScroll?.();
}
function refreshLayout() {
tableRef.value?.refreshLayout?.();
}
function getColumnSettings() {
const configByFieldId = new Map();
for (const config of props.listConfig || []) {
@@ -282,6 +286,7 @@ watch(
defineExpose({
resetColumns,
refreshLayout,
getColumnSettings,
clearSelectedArea: () => tableRef.value?.clearSelectedArea?.(),
});
@@ -85,6 +85,28 @@ function clearActiveEditing() {
if (state) state.value = { editing: false, focusCol: null, initialValues: null };
}
// 父级可能在 change 后回灌同 rowKey 的新对象,编辑状态需要随行对象迁移。
function rebindActiveEditingRow(rows) {
if (!activeEditingRow || rows.includes(activeEditingRow)) return;
const previousRow = activeEditingRow;
const previousState = rowEditingStates.get(previousRow)?.value;
const previousKey = getRowKey(previousRow);
const replacement =
previousKey == null ? null : rows.find((row) => String(getRowKey(row)) === String(previousKey));
clearActiveEditing();
if (!replacement || !previousState?.editing) return;
const state = getRowEditingState(replacement);
activeEditingRow = replacement;
state.value = {
editing: true,
focusCol: previousState.focusCol,
initialValues: captureEditableValues(replacement),
};
}
function activateEditing(row, focusCol) {
if (!props.editable || !row) return;
const state = getRowEditingState(row);
@@ -402,7 +424,7 @@ function handleAreaSelectionChange(ranges) {
watch(
[() => props.activeRowKey, () => props.dataSource],
async ([rowKey]) => {
if (activeEditingRow && !props.dataSource.includes(activeEditingRow)) clearActiveEditing();
rebindActiveEditingRow(props.dataSource);
await nextTick();
if (rowKey == null) {
tableRef.value?.setCurrentRow?.(undefined, { silent: true });
@@ -1,7 +1,42 @@
import { h } from "vue";
import { h, toRaw } from "vue";
import { Input, Select } from "antdv-next";
const stop = (event) => event.stopPropagation();
const inputDrafts = new WeakMap();
function getInputDraft(row, dataIndex) {
const drafts = inputDrafts.get(row);
return drafts?.has(dataIndex) ? drafts.get(dataIndex) : row[dataIndex];
}
function setInputDraft(row, dataIndex, value) {
let drafts = inputDrafts.get(row);
if (!drafts) {
drafts = new Map();
inputDrafts.set(row, drafts);
}
drafts.set(dataIndex, value);
}
function clearInputDraft(row, dataIndex) {
const drafts = inputDrafts.get(row);
if (!drafts) return;
drafts.delete(dataIndex);
if (!drafts.size) inputDrafts.delete(row);
}
function commitInputDraft(row, dataIndex, value) {
clearInputDraft(row, dataIndex);
row[dataIndex] = value;
}
function preserveInputDraft(row, dataIndex) {
const drafts = inputDrafts.get(row);
if (!drafts?.has(dataIndex)) return;
// 卸载兜底不触发整表更新;正常路径会在 blur 中响应式提交并通知父级。
toRaw(row)[dataIndex] = drafts.get(dataIndex);
clearInputDraft(row, dataIndex);
}
function resolveOptions(editor) {
return typeof editor.options === "function" ? editor.options() : (editor.options ?? []);
@@ -52,15 +87,16 @@ function createInput(row, col, editor, ctx) {
const focus = createFocusHandler(ctx, row, col, true);
return h(Input, {
type: editor.inputType || "text",
value: row[col.dataIndex] ?? "",
value: getInputDraft(row, col.dataIndex) ?? "",
size: "small",
class: "fms-cell-editor fms-cell-editor-root",
"aria-label": col.title || col.dataIndex,
"onUpdate:value": (value) => {
row[col.dataIndex] = value;
setInputDraft(row, col.dataIndex, value);
},
onBlur: (event) => {
const value = event.target.value;
commitInputDraft(row, col.dataIndex, value);
notifyChange(ctx, editor, row, col, value);
},
onKeydown: (event) => {
@@ -75,6 +111,7 @@ function createInput(row, col, editor, ctx) {
onClick: stop,
onVnodeMounted: focus,
onVnodeUpdated: focus,
onVnodeBeforeUnmount: () => preserveInputDraft(row, col.dataIndex),
});
}
@@ -35,6 +35,16 @@ const menuItems = computed(() => {
label: moduleLabel("base_othercompany", "/base/othercompany", "合作伙伴"),
moduleId: "base_othercompany",
},
{
key: "/base/contact",
label: moduleLabel("v_b_contact", "/base/contact", "联系人"),
moduleId: "v_b_contact",
},
{
key: "/base/otherdata",
label: moduleLabel("m_base_otherdata", "/base/otherdata", "其他数据"),
moduleId: "m_base_otherdata",
},
].filter(canAccessMenu);
const systemPages = [
{
@@ -52,6 +62,11 @@ const menuItems = computed(() => {
label: moduleLabel("d_login_log", "/system/login-log", "登录日志"),
moduleId: "d_login_log",
},
{
key: "/system/user",
label: moduleLabel("b_user", "/system/user", "用户管理"),
moduleId: "b_user",
},
].filter(canAccessMenu);
const items = [
{
@@ -152,6 +167,19 @@ onMounted(() => permissionStore.load().catch(() => {}));
margin: 0;
border-radius: 0;
line-height: 36px;
transition: padding-inline-start 0.2s ease;
}
:deep(.ant-menu-inline-collapsed) {
.ant-menu-title-content {
display: none !important;
}
.ant-menu-item,
.ant-menu-submenu-title {
padding-inline-start: 20px !important;
}
.ant-menu-item-icon {
vertical-align: middle;
}
}
}
.brand {
+34 -1
View File
@@ -22,7 +22,29 @@ const routes = [
title: "合作伙伴",
section: "基础数据",
menuKey: "/base/othercompany",
moduleId: "b_othercompany",
moduleId: "v_b_othercompany",
},
},
{
path: "base/contact",
name: "base-contact",
component: () => import("@/views/base/contact/index.vue"),
meta: {
title: "联系人",
section: "基础数据",
menuKey: "/base/contact",
moduleId: "v_b_contact",
},
},
{
path: "base/otherdata",
name: "base-otherdata",
component: () => import("@/views/base/otherdata/index.vue"),
meta: {
title: "其他数据",
section: "基础数据",
menuKey: "/base/otherdata",
moduleId: "m_base_otherdata",
},
},
{
@@ -58,6 +80,17 @@ const routes = [
moduleId: "s_login_log",
},
},
{
path: "system/user",
name: "system-user",
component: () => import("@/views/system/user/index.vue"),
meta: {
title: "用户管理",
section: "系统管理",
menuKey: "/system/user",
moduleId: "b_user",
},
},
],
},
{
@@ -0,0 +1,25 @@
<script setup>
import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vue";
</script>
<template>
<div class="contact-page">
<FmsModuleListPage data-code="v_b_contact" />
</div>
</template>
<style scoped lang="scss">
.contact-page {
width: 100%;
height: 100%;
overflow: hidden;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
}
.contact-page :deep(.fms-module-list-page) {
border: 0;
border-radius: 0;
}
</style>
@@ -390,7 +390,7 @@ onMounted(loadCategories);
<a-splitter-panel :min="520">
<main class="partner-panel">
<FmsModuleListPage
data-code="b_othercompany"
data-code="v_b_othercompany"
:fixed-search-condition="categoryFilterCondition"
:column-extensions="columnExtensions"
/>
@@ -0,0 +1,456 @@
<script setup>
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 { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from "@/services/api";
import { buildTree, sortTree } from "@/utils/tree";
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", "b_module_code",
];
const categories = ref([]);
const categoryLoading = ref(false);
const selectedCategoryKey = ref(ALL_CATEGORIES_KEY);
const selectedModuleCode = ref("");
const expandedCategoryKeys = ref([ALL_CATEGORIES_KEY]);
const categoryById = computed(
() => new Map(categories.value.map((item) => [String(item.b_id), item])),
);
const categoryTree = computed(() => {
const roots = buildTree(categories.value, {
mapNode: (item) => ({
key: String(item.b_id),
title: Number(item.b_canuse) === 1
? item.b_name
: `${item.b_name}(停用)`,
b_id: String(item.b_id),
b_parent_id: item.b_parent_id == null ? null : String(item.b_parent_id),
b_module_code: item.b_module_code || "",
b_xh: item.b_xh,
children: [],
}),
});
sortTree(roots);
return roots;
});
async function loadCategories() {
categoryLoading.value = true;
try {
const response = await loadDataApi(
"b_other_bmfl",
"",
"b_path ASC, b_xh ASC, b_id ASC",
CATEGORY_COLUMNS,
);
categories.value = response.data || [];
// 如果之前选中的节点被删了,回到根
const existingKeys = new Set(categories.value.map((item) => String(item.b_id)));
if (
selectedCategoryKey.value !== ALL_CATEGORIES_KEY &&
!existingKeys.has(selectedCategoryKey.value)
) {
selectedCategoryKey.value = ALL_CATEGORIES_KEY;
selectedModuleCode.value = "";
}
// 展开所有有子节点的父节点
const parentKeys = categories.value
.map((item) => (item.b_parent_id == null ? "" : String(item.b_parent_id)))
.filter(Boolean);
expandedCategoryKeys.value = [
...new Set([...expandedCategoryKeys.value, ALL_CATEGORIES_KEY, ...parentKeys]),
];
} catch (error) {
message.error("分类加载失败:" + (error?.message || error));
} finally {
categoryLoading.value = false;
}
}
function onCategorySelect(keys) {
if (!keys.length) return;
const key = String(keys[0]);
selectedCategoryKey.value = key;
// 判断节点是否关联了数据模块
const cat = categoryById.value.get(key);
if (cat && cat.b_module_code) {
selectedModuleCode.value = cat.b_module_code;
} else {
selectedModuleCode.value = "";
}
}
// ---- 右键菜单 & 分类维护 ----
const addModalVisible = ref(false);
const addModalParentId = ref(null);
const addModalParentName = ref("");
const editingCategoryId = ref(null);
const addForm = reactive({ b_name: "", b_module_code: "" });
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 = "";
addForm.b_module_code = "";
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 || "";
addForm.b_module_code = category.b_module_code || "";
addModalVisible.value = true;
}
async function saveCategory() {
const name = (addForm.b_name || "").trim();
if (!name) {
message.warning("请填写名称");
return;
}
const moduleCode = (addForm.b_module_code || "").trim() || null;
addSaving.value = true;
try {
if (editingCategoryId.value) {
await saveObjectApi([
{
table: "b_other_bmfl",
key_field: "b_id",
updates: [{
b_id: editingCategoryId.value,
b_name: name,
b_module_code: moduleCode,
}],
},
]);
message.success("分类已修改");
addModalVisible.value = false;
await loadCategories();
// 如果当前选中节点被修改了模块代码,更新选中
if (selectedCategoryKey.value === editingCategoryId.value) {
const cat = categoryById.value.get(editingCategoryId.value);
selectedModuleCode.value = cat?.b_module_code || "";
}
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;
}
}
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_other_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,
b_module_code: moduleCode,
},
],
},
]);
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 hasPersistedChildCategory(categoryId) {
const response = await pageDataApi({
viewName: "b_other_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;
Modal.confirm({
title: `确定删除「${node.title}」?`,
content: "此操作不可恢复",
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onCancel: () => {
deleteSaving.value = false;
},
onOk: async () => {
try {
const hasChild = await hasPersistedChildCategory(categoryId);
if (hasChild) {
message.warning("该分类下还有子分类,不能删除");
return;
}
await saveObjectApi([
{
table: "b_other_bmfl",
key_field: "b_id",
deletes: [{ b_id: categoryId }],
},
]);
if (selectedCategoryKey.value === categoryId) {
selectedCategoryKey.value = ALL_CATEGORIES_KEY;
selectedModuleCode.value = "";
}
message.success("分类已删除");
await loadCategories();
} catch (error) {
message.error("删除失败:" + (error?.message || error));
throw error;
} finally {
deleteSaving.value = false;
}
},
});
}
onMounted(loadCategories);
</script>
<template>
<div class="otherdata-page">
<a-splitter class="otherdata-splitter" lazy>
<a-splitter-panel :default-size="250" :min="210" :max="380" collapsible>
<aside class="category-panel">
<div class="category-tree-stage">
<FmsTree
v-model:expanded-keys="expandedCategoryKeys"
:tree-data="categoryTree"
:selected-keys="[selectedCategoryKey]"
root-title="其他数据"
: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>
</aside>
</a-splitter-panel>
<a-splitter-panel :min="520">
<main class="data-panel">
<FmsModuleListPage
v-if="selectedModuleCode"
:key="selectedModuleCode"
:data-code="selectedModuleCode"
/>
<a-empty
v-else
description="请选择具体数据项(叶子节点)"
/>
</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-item label="数据模块代码">
<a-input
v-model:value="addForm.b_module_code"
:maxlength="100"
placeholder="留空则为分类分组节点;填入则为数据模块叶子节点(如 d_other_port)"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped lang="scss">
.otherdata-page,
.otherdata-splitter {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.otherdata-page {
overflow: hidden;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
}
.category-panel,
.data-panel {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.category-tree-stage {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
}
.category-tree-stage :deep(.ant-tree) {
min-width: 100%;
}
.category-tree-stage :deep(.ant-tree-node-content-wrapper) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgb(255 255 255 / 70%);
}
.data-panel :deep(.fms-module-list-page) {
border: 0;
border-radius: 0;
}
</style>
@@ -47,12 +47,12 @@ function onRowOrderChange(rows) {
emitUpdate();
}
const availableFields = computed(() => {
const options = [];
const fieldNameMap = computed(() => {
const map = new Map();
for (const field of props.fields || []) {
options.push({ label: field.b_name || field.b_field, value: String(field.b_id) });
map.set(String(field.b_id), field.b_name || field.b_field);
}
return options;
return map;
});
const groupOptions = computed(() => {
@@ -119,9 +119,10 @@ const columns = shallowRef([
dataIndex: "b_field_id",
title: "字段",
width: 160,
editor: {
type: "select",
options: () => availableFields.value,
customCell: ({ row }) => {
const id = row.b_field_id;
if (id == null) return "";
return fieldNameMap.value.get(String(id)) || id;
},
},
{
@@ -302,6 +302,15 @@ function flatten(rows) {
return flat;
}
// 由父级「同步字段」调用:移除匹配指定 _managedId 的透视行(字段定义已删除时清理残留翻译)。
function removeByManagedIds(ids) {
if (!ids || ids.size === 0) return;
const idSet = new Set(Array.from(ids).map(String));
pivotRows.value = pivotRows.value.filter(
(row) => !(row._managedId && idSet.has(String(row._managedId))),
);
}
// 由父级「同步字段」调用:把默认翻译条目({ key, locale, value, managedId })并入草稿行。
// 已存在同 b_key 的行只补全空值(不覆盖用户配置),避免新增重复键;逻辑见 utils/i18n.mergeI18nDefaults。
function mergeDefaults(entries) {
@@ -374,7 +383,7 @@ function markSaved() {
originalsFlat.value = cloneData(flatten(pivotRows.value));
}
defineExpose({ buildSaveRequest, markSaved, mergeDefaults, refreshKey, reconcileManagedRows });
defineExpose({ buildSaveRequest, markSaved, mergeDefaults, refreshKey, reconcileManagedRows, removeByManagedIds });
const displayedData = computed(() => {
const kw = searchKey.value.toLowerCase();
@@ -47,12 +47,12 @@ function onRowOrderChange(rows) {
emitUpdate();
}
const availableFields = computed(() => {
const options = [];
const fieldNameMap = computed(() => {
const map = new Map();
for (const field of props.fields || []) {
options.push({ label: field.b_name || field.b_field, value: String(field.b_id) });
map.set(String(field.b_id), field.b_name || field.b_field);
}
return options;
return map;
});
const groupOptions = computed(() => {
@@ -119,12 +119,18 @@ const columns = shallowRef([
dataIndex: "b_field_id",
title: "字段",
width: 160,
editor: {
type: "select",
options: () => availableFields.value,
customCell: ({ row }) => {
const id = row.b_field_id;
if (id == null) return "";
return fieldNameMap.value.get(String(id)) || id;
},
},
{ dataIndex: "b_width", title: "列宽", width: 80 },
{
dataIndex: "b_width",
title: "列宽",
width: 80,
editor: { type: "input" },
},
{
dataIndex: "b_group_id",
title: "分组",
@@ -56,12 +56,12 @@ function onRowOrderChange(rows) {
emitUpdate();
}
const availableFields = computed(() => {
const options = [];
const fieldNameMap = computed(() => {
const map = new Map();
for (const field of props.fields || []) {
options.push({ label: field.b_name || field.b_field, value: String(field.b_id) });
map.set(String(field.b_id), field.b_name || field.b_field);
}
return options;
return map;
});
function addRow() {
@@ -84,9 +84,10 @@ const columns = shallowRef([
dataIndex: "b_field_id",
title: "字段",
width: 160,
editor: {
type: "select",
options: () => availableFields.value,
customCell: ({ row }) => {
const id = row.b_field_id;
if (id == null) return "";
return fieldNameMap.value.get(String(id)) || id;
},
},
{
@@ -38,6 +38,7 @@ const treeLoading = ref(false);
const expandedTreeKeys = ref([]);
const selectedModuleId = ref(null);
const skipLoad = ref(false);
const expandTimer = ref(null);
const activeTab = ref("basic");
function buildTreeContextMenu(node) {
const items = [];
@@ -442,24 +443,54 @@ async function loadModuleConfig(moduleId) {
}
// ---- 树节点选择 ----
function clearExpandTimer() {
if (expandTimer.value != null) {
clearTimeout(expandTimer.value);
expandTimer.value = null;
}
}
function onTreeSelect(keys, info) {
if (busy.value) return;
if (info?.node?.isFmsRoot) {
selectedModuleId.value = null;
clearExpandTimer();
return;
}
const nodeKey = info?.node?.key ?? keys[0];
if (nodeKey == null) return;
const key = String(nodeKey);
selectedModuleId.value = key;
const node = findNode(key);
if (!node?.children?.length) return;
const hasChildren = node?.children?.length;
clearExpandTimer();
if (!hasChildren) {
// 叶子节点:立即加载数据
selectedModuleId.value = key;
return;
}
// 父节点:处理展开/收起
const expandedKeys = expandedTreeKeys.value.map(String);
expandedTreeKeys.value = expandedKeys.includes(key)
? expandedKeys.filter((item) => item !== key)
: [...expandedKeys, key];
const isCurrentlyExpanded = expandedKeys.includes(key);
if (isCurrentlyExpanded) {
// 收起:立即生效,同时加载数据
expandedTreeKeys.value = expandedKeys.filter((item) => item !== key);
selectedModuleId.value = key;
} else {
// 展开:立即高亮选中节点,动画完毕后再加载数据
expandedTreeKeys.value = [...expandedKeys, key];
skipLoad.value = true;
selectedModuleId.value = key; // 立即高亮,watch 中 skipLoad=true 跳过加载
expandTimer.value = setTimeout(() => {
expandTimer.value = null;
loadedModuleId.value = null;
void loadModuleConfig(key);
}, 300);
}
}
watch(selectedModuleId, (id) => {
@@ -1009,11 +1040,26 @@ async function syncFields() {
}
}
// 视图中不存在的已有字段标记为不可用
// 视图中不存在的已有字段:从字段定义中移除,并清理其在列表/编辑/查询配置中的残留行。
const removedFieldIds = new Set();
for (const existing of existingFields) {
const fieldName = existing.b_field ? existing.b_field.toLowerCase() : "";
if (!viewColumnSet.has(fieldName)) {
mergedFields.push({ ...existing, b_canuse: 0 });
removedFieldIds.add(String(existing.b_id));
}
}
if (removedFieldIds.size > 0) {
for (const section of ["listConfig", "editConfig", "queryConfig"]) {
drafts.value[section] = (drafts.value[section] || []).filter(
(row) => !removedFieldIds.has(String(row.b_field_id)),
);
}
// 多语言面板中移除旧字段对应的受管翻译行(_managedId 格式为 field:<b_id>)
if (i18nPanelRef.value) {
const managedIds = new Set(
Array.from(removedFieldIds).map((id) => `field:${id}`),
);
i18nPanelRef.value.removeByManagedIds(managedIds);
}
}
@@ -1125,12 +1171,8 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
: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">
@@ -39,7 +39,7 @@ function batchDelete() {
const deletes = keys.map((b_id) => ({ b_id }));
await saveObjectApi([
{
table: "s_log_login",
table: "s_login_log",
key_field: "b_id",
inserts: [],
updates: [],
@@ -0,0 +1,25 @@
<script setup>
import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vue";
</script>
<template>
<div class="user-page">
<FmsModuleListPage data-code="b_user" />
</div>
</template>
<style scoped lang="scss">
.user-page {
width: 100%;
height: 100%;
overflow: hidden;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
}
.user-page :deep(.fms-module-list-page) {
border: 0;
border-radius: 0;
}
</style>