20260730231110

This commit is contained in:
oneao committed 2026-07-30 23:11:10 +08:00
1 parent 4929b78434
commit 318fa0d9e1
23 files changed
+4026 -506

No files matched your search

@@ -323,33 +323,15 @@ BEGIN TRANSACTION;
);
END;
IF OBJECT_ID(N'dbo.b_other', N'U') IS NULL
BEGIN
CREATE TABLE dbo.[b_other] (
[b_cateid] varchar(50) NULL,
[b_id] bigint NOT NULL,
[b_name_c] nvarchar(50) NULL,
[b_1] numeric(18,0) NULL,
[b_2] varchar(50) NULL,
[b_desc] nvarchar(255) NULL,
[b_inputuser_id] bigint NULL,
[b_inputdatetime] datetime2(0) NULL,
[b_updateuser_id] bigint NULL,
[b_updatedatetime] datetime2(0) NULL,
[b_code] varchar(50) NOT NULL,
CONSTRAINT [PK_b_other] PRIMARY KEY ([b_id]),
CONSTRAINT [UQ_b_other_b_code] UNIQUE ([b_code])
);
END;
IF OBJECT_ID(N'dbo.b_other_bmfl', N'U') IS NULL
BEGIN
CREATE TABLE dbo.[b_other_bmfl] (
b_id bigint NOT NULL,
b_parent_id bigint NULL,
b_name nvarchar(150) NOT NULL,
b_level int NOT NULL CONSTRAINT [DF_b_other_bmfl_level] DEFAULT (1),
b_path varchar(1000) NOT NULL,
b_i18n varchar(150) NULL,
b_node_type varchar(20) NOT NULL CONSTRAINT [DF_b_other_bmfl_node_type] DEFAULT ('category'),
b_module_code varchar(100) NULL,
b_xh int NOT NULL CONSTRAINT [DF_b_other_bmfl_xh] DEFAULT (0),
b_canuse int NOT NULL CONSTRAINT [DF_b_other_bmfl_canuse] DEFAULT (1),
b_inputuser_id bigint NULL,
@@ -358,10 +340,15 @@ BEGIN TRANSACTION;
b_updatedatetime datetime2(0) NULL,
CONSTRAINT [PK_b_other_bmfl] PRIMARY KEY (b_id),
CONSTRAINT [CK_b_other_bmfl_parent] CHECK (b_parent_id IS NULL OR b_parent_id <> b_id),
CONSTRAINT [CK_b_other_bmfl_node_type] CHECK (b_node_type IN ('category', 'module')),
CONSTRAINT [CK_b_other_bmfl_module_ref] CHECK (
(b_node_type = 'category' AND b_module_code IS NULL)
OR (b_node_type = 'module' AND b_module_code IS NOT NULL AND LEN(LTRIM(RTRIM(b_module_code))) > 0)
),
CONSTRAINT [FK_b_other_bmfl_parent] FOREIGN KEY (b_parent_id) REFERENCES dbo.[b_other_bmfl] (b_id)
);
CREATE INDEX [IX_b_other_bmfl_parent] ON dbo.[b_other_bmfl] (b_parent_id, b_xh, b_id);
CREATE INDEX [IX_b_other_bmfl_path] ON dbo.[b_other_bmfl] (b_path, b_xh, b_id);
CREATE INDEX [IX_b_other_bmfl_module] ON dbo.[b_other_bmfl] (b_node_type, b_module_code);
END;
IF OBJECT_ID(N'dbo.b_othercompany_accountbank', N'U') IS NULL
@@ -17,10 +17,18 @@ BEGIN TRANSACTION;
THROW 52001, N'base directory not found. Run 012 / 002 migration first.', 1;
/* =========================================================================
2. 创建 page 模块: m_base_otherdata
2. 创建 page 模块: base_otherdata
========================================================================= */
IF EXISTS (SELECT 1 FROM dbo.s_module WHERE b_code = 'm_base_otherdata')
AND NOT EXISTS (SELECT 1 FROM dbo.s_module WHERE b_code = 'base_otherdata')
BEGIN
UPDATE dbo.s_module
SET b_code = 'base_otherdata'
WHERE b_code = 'm_base_otherdata';
END;
DECLARE @PageId bigint = 920000000000001100;
IF NOT EXISTS (SELECT 1 FROM dbo.s_module WHERE b_code = 'm_base_otherdata')
IF NOT EXISTS (SELECT 1 FROM dbo.s_module WHERE b_code = 'base_otherdata')
BEGIN
IF EXISTS (SELECT 1 FROM dbo.s_module WHERE b_id = @PageId)
THROW 52002, N'The reserved otherdata page ID is already in use.', 1;
@@ -30,13 +38,13 @@ BEGIN TRANSACTION;
b_route, b_viewtable, b_savetable, b_keyfield, b_orderfield,
b_canmenu, b_canuse, b_xh
) VALUES (
@PageId, 'm_base_otherdata', @BaseId, N'其他数据',
@PageId, 'base_otherdata', @BaseId, N'其他数据',
'menu.base_otherdata', 'page', '/base/otherdata',
NULL, NULL, NULL, NULL, 1, 1, 30
);
END
ELSE
SET @PageId = (SELECT b_id FROM dbo.s_module WHERE b_code = 'm_base_otherdata');
SET @PageId = (SELECT b_id FROM dbo.s_module WHERE b_code = 'base_otherdata');
/* =========================================================================
3. 创建 data 模块辅助存储过程
@@ -1,78 +1,175 @@
/* 扩展 b_other_bmfl 表,添加 b_module_code 字段关联 s_module 数据模块。
分类节点 b_module_code 为空(仅做分组),叶子节点指向对应的 data 模块。*/
/* 其他数据分类树采用引用设计:
category 节点只维护树结构和分类名称,module 节点只引用 s_module.data 的 b_code。*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
/* =========================================================================
1. 添加 b_module_code 列
========================================================================= */
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID(N'dbo.b_other_bmfl') AND name = 'b_module_code'
)
IF OBJECT_ID(N'dbo.b_other_bmfl', N'U') IS NULL
THROW 52301, N'dbo.b_other_bmfl does not exist.', 1;
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_node_type') IS NULL
BEGIN
ALTER TABLE dbo.b_other_bmfl
ADD b_module_code nvarchar(100) NULL;
ADD b_node_type varchar(20) NOT NULL
CONSTRAINT [DF_b_other_bmfl_node_type] DEFAULT ('category') WITH VALUES;
END;
/* =========================================================================
2. 插入演示分类数据(基础信息 / 财务信息 / 物流信息 分组)
========================================================================= */
DECLARE @CatBasic bigint = 930000000000000101;
DECLARE @CatFinance bigint = 930000000000000102;
DECLARE @CatLogistics bigint = 930000000000000103;
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_module_code') IS NULL
BEGIN
ALTER TABLE dbo.b_other_bmfl
ADD b_module_code varchar(100) NULL;
END;
/* ---- 基础信息 ---- */
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @CatBasic)
INSERT INTO dbo.b_other_bmfl (b_id, b_parent_id, b_name, b_level, b_path, b_xh, b_canuse, b_module_code)
VALUES (@CatBasic, NULL, N'基础信息', 1, CAST(@CatBasic AS varchar(20)), 10, 1, NULL);
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND name = N'IX_b_other_bmfl_path'
)
DROP INDEX [IX_b_other_bmfl_path] ON dbo.b_other_bmfl;
/* ---- 财务信息 ---- */
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @CatFinance)
INSERT INTO dbo.b_other_bmfl (b_id, b_parent_id, b_name, b_level, b_path, b_xh, b_canuse, b_module_code)
VALUES (@CatFinance, NULL, N'财务信息', 1, CAST(@CatFinance AS varchar(20)), 20, 1, NULL);
DECLARE @DropSql nvarchar(max);
/* ---- 物流信息 ---- */
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @CatLogistics)
INSERT INTO dbo.b_other_bmfl (b_id, b_parent_id, b_name, b_level, b_path, b_xh, b_canuse, b_module_code)
VALUES (@CatLogistics, NULL, N'物流信息', 1, CAST(@CatLogistics AS varchar(20)), 30, 1, NULL);
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_level') IS NOT NULL
BEGIN
SET @DropSql = NULL;
SELECT @DropSql = N'ALTER TABLE dbo.b_other_bmfl DROP CONSTRAINT [' + REPLACE(dc.name, N']', N']]') + N']'
FROM sys.default_constraints dc
JOIN sys.columns c
ON c.object_id = dc.parent_object_id
AND c.column_id = dc.parent_column_id
WHERE dc.parent_object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND c.name = N'b_level';
IF @DropSql IS NOT NULL EXEC sys.sp_executesql @DropSql;
/* =========================================================================
3. 插入叶子节点(关联到已有 data 模块)
========================================================================= */
DECLARE @NodePort bigint = 930000000000000201;
DECLARE @NodeCity bigint = 930000000000000202;
DECLARE @NodeMoney bigint = 930000000000000203;
DECLARE @NodeOceanline bigint = 930000000000000204;
EXEC sys.sp_executesql N'ALTER TABLE dbo.b_other_bmfl DROP COLUMN b_level';
END;
/* ---- 基础信息 / 港口 ---- */
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @NodePort)
INSERT INTO dbo.b_other_bmfl (b_id, b_parent_id, b_name, b_level, b_path, b_xh, b_canuse, b_module_code)
VALUES (@NodePort, @CatBasic, N'港口', 2,
CAST(@CatBasic AS varchar(20)) + '/' + CAST(@NodePort AS varchar(20)),
10, 1, 'd_other_port');
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_path') IS NOT NULL
BEGIN
SET @DropSql = NULL;
SELECT @DropSql = N'ALTER TABLE dbo.b_other_bmfl DROP CONSTRAINT [' + REPLACE(dc.name, N']', N']]') + N']'
FROM sys.default_constraints dc
JOIN sys.columns c
ON c.object_id = dc.parent_object_id
AND c.column_id = dc.parent_column_id
WHERE dc.parent_object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND c.name = N'b_path';
IF @DropSql IS NOT NULL EXEC sys.sp_executesql @DropSql;
/* ---- 基础信息 / 城市 ---- */
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @NodeCity)
INSERT INTO dbo.b_other_bmfl (b_id, b_parent_id, b_name, b_level, b_path, b_xh, b_canuse, b_module_code)
VALUES (@NodeCity, @CatBasic, N'城市', 2,
CAST(@CatBasic AS varchar(20)) + '/' + CAST(@NodeCity AS varchar(20)),
20, 1, 'd_other_city');
EXEC sys.sp_executesql N'ALTER TABLE dbo.b_other_bmfl DROP COLUMN b_path';
END;
/* ---- 财务信息 / 币种 ---- */
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @NodeMoney)
INSERT INTO dbo.b_other_bmfl (b_id, b_parent_id, b_name, b_level, b_path, b_xh, b_canuse, b_module_code)
VALUES (@NodeMoney, @CatFinance, N'币种', 2,
CAST(@CatFinance AS varchar(20)) + '/' + CAST(@NodeMoney AS varchar(20)),
10, 1, 'd_other_money');
EXEC sys.sp_executesql N'
UPDATE dbo.b_other_bmfl
SET b_module_code = NULLIF(LTRIM(RTRIM(COALESCE(b_module_code, ''''))), '''');
/* ---- 物流信息 / 航线 ---- */
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @NodeOceanline)
INSERT INTO dbo.b_other_bmfl (b_id, b_parent_id, b_name, b_level, b_path, b_xh, b_canuse, b_module_code)
VALUES (@NodeOceanline, @CatLogistics, N'航线', 2,
CAST(@CatLogistics AS varchar(20)) + '/' + CAST(@NodeOceanline AS varchar(20)),
10, 1, 'd_other_oceanline');
UPDATE dbo.b_other_bmfl
SET b_node_type = CASE
WHEN b_module_code IS NULL THEN ''category''
ELSE ''module''
END';
IF NOT EXISTS (
SELECT 1 FROM sys.check_constraints
WHERE parent_object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND name = N'CK_b_other_bmfl_node_type'
)
EXEC sys.sp_executesql N'
ALTER TABLE dbo.b_other_bmfl
ADD CONSTRAINT [CK_b_other_bmfl_node_type]
CHECK (b_node_type IN (''category'', ''module''))';
IF NOT EXISTS (
SELECT 1 FROM sys.check_constraints
WHERE parent_object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND name = N'CK_b_other_bmfl_module_ref'
)
EXEC sys.sp_executesql N'
ALTER TABLE dbo.b_other_bmfl
ADD CONSTRAINT [CK_b_other_bmfl_module_ref]
CHECK (
(b_node_type = ''category'' AND b_module_code IS NULL)
OR (
b_node_type = ''module''
AND b_module_code IS NOT NULL
AND LEN(LTRIM(RTRIM(b_module_code))) > 0
)
)';
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND name = N'IX_b_other_bmfl_module'
)
EXEC sys.sp_executesql N'
CREATE INDEX [IX_b_other_bmfl_module] ON dbo.b_other_bmfl (b_node_type, b_module_code)';
EXEC sys.sp_executesql N'
DECLARE @CatBasic bigint = 930000000000000101;
DECLARE @CatFinance bigint = 930000000000000102;
DECLARE @CatLogistics bigint = 930000000000000103;
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @CatBasic)
INSERT INTO dbo.b_other_bmfl (
b_id, b_parent_id, b_name, b_node_type, b_module_code,
b_xh, b_canuse
)
VALUES (@CatBasic, NULL, N''基础信息'', ''category'', NULL, 10, 1);
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @CatFinance)
INSERT INTO dbo.b_other_bmfl (
b_id, b_parent_id, b_name, b_node_type, b_module_code,
b_xh, b_canuse
)
VALUES (@CatFinance, NULL, N''财务信息'', ''category'', NULL, 20, 1);
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @CatLogistics)
INSERT INTO dbo.b_other_bmfl (
b_id, b_parent_id, b_name, b_node_type, b_module_code,
b_xh, b_canuse
)
VALUES (@CatLogistics, NULL, N''物流信息'', ''category'', NULL, 30, 1);
DECLARE @NodePort bigint = 930000000000000201;
DECLARE @NodeCity bigint = 930000000000000202;
DECLARE @NodeMoney bigint = 930000000000000203;
DECLARE @NodeOceanline bigint = 930000000000000204;
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @NodePort)
INSERT INTO dbo.b_other_bmfl (
b_id, b_parent_id, b_name, b_node_type, b_module_code,
b_xh, b_canuse
)
VALUES (
@NodePort, @CatBasic, N''港口'', ''module'', ''d_other_port'', 10, 1
);
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @NodeCity)
INSERT INTO dbo.b_other_bmfl (
b_id, b_parent_id, b_name, b_node_type, b_module_code,
b_xh, b_canuse
)
VALUES (
@NodeCity, @CatBasic, N''城市'', ''module'', ''d_other_city'', 20, 1
);
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @NodeMoney)
INSERT INTO dbo.b_other_bmfl (
b_id, b_parent_id, b_name, b_node_type, b_module_code,
b_xh, b_canuse
)
VALUES (
@NodeMoney, @CatFinance, N''币种'', ''module'', ''d_other_money'', 10, 1
);
IF NOT EXISTS (SELECT 1 FROM dbo.b_other_bmfl WHERE b_id = @NodeOceanline)
INSERT INTO dbo.b_other_bmfl (
b_id, b_parent_id, b_name, b_node_type, b_module_code,
b_xh, b_canuse
)
VALUES (
@NodeOceanline, @CatLogistics, N''航线'', ''module'', ''d_other_oceanline'', 10, 1
);';
COMMIT TRANSACTION;
@@ -0,0 +1,13 @@
/* 删除未使用的旧版其他数据通用表。当前其他数据使用 b_other_bmfl 分类树
关联 s_module 数据节点,并由具体基础表保存数据。*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
IF OBJECT_ID(N'dbo.b_other', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.b_other;
END;
COMMIT TRANSACTION;
@@ -0,0 +1,29 @@
/* 为其他数据分类树增加多语言键。分类节点归属 base_otherdata 页面模块;
数据节点名称继续使用其关联 s_module data 节点的 b_i18n。*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
IF OBJECT_ID(N'dbo.b_other_bmfl', N'U') IS NOT NULL
BEGIN
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID(N'dbo.b_other_bmfl') AND name = 'b_i18n'
)
BEGIN
ALTER TABLE dbo.b_other_bmfl
ADD b_i18n varchar(150) NULL;
END;
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_module_code') IS NOT NULL
BEGIN
EXEC sys.sp_executesql N'
UPDATE dbo.b_other_bmfl
SET b_i18n = ''category.base_otherdata.'' + CAST(b_id AS varchar(30))
WHERE b_module_code IS NULL
AND (b_i18n IS NULL OR b_i18n = '''')';
END;
END;
COMMIT TRANSACTION;
@@ -0,0 +1,132 @@
/* 规范其他数据分类树为引用设计。
仅修正目标库元数据结构,不从模块管理同步树节点。*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
IF EXISTS (SELECT 1 FROM dbo.s_module WHERE b_code = 'm_base_otherdata')
AND NOT EXISTS (SELECT 1 FROM dbo.s_module WHERE b_code = 'base_otherdata')
BEGIN
UPDATE dbo.s_module
SET b_code = 'base_otherdata'
WHERE b_code = 'm_base_otherdata';
END;
IF OBJECT_ID(N'dbo.b_other_bmfl', N'U') IS NOT NULL
BEGIN
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_node_type') IS NULL
BEGIN
ALTER TABLE dbo.b_other_bmfl
ADD b_node_type varchar(20) NOT NULL
CONSTRAINT [DF_b_other_bmfl_node_type] DEFAULT ('category') WITH VALUES;
END;
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_module_code') IS NULL
BEGIN
ALTER TABLE dbo.b_other_bmfl
ADD b_module_code varchar(100) NULL;
END;
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_i18n') IS NULL
BEGIN
ALTER TABLE dbo.b_other_bmfl
ADD b_i18n varchar(150) NULL;
END;
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND name = N'IX_b_other_bmfl_path'
)
DROP INDEX [IX_b_other_bmfl_path] ON dbo.b_other_bmfl;
EXEC sys.sp_executesql N'
UPDATE dbo.b_other_bmfl
SET b_module_code = NULLIF(LTRIM(RTRIM(COALESCE(b_module_code, ''''))), '''');
UPDATE dbo.b_other_bmfl
SET b_node_type = CASE
WHEN b_module_code IS NULL THEN ''category''
ELSE ''module''
END;
UPDATE dbo.b_other_bmfl
SET b_i18n = ''category.base_otherdata.'' + CAST(b_id AS varchar(30))
WHERE b_node_type = ''category''
AND (b_i18n IS NULL OR b_i18n = '''');
UPDATE dbo.b_other_bmfl
SET b_i18n = REPLACE(b_i18n, ''category.m_base_otherdata.'', ''category.base_otherdata.'')
WHERE b_node_type = ''category''
AND b_i18n LIKE ''category.m_base_otherdata.%''';
IF NOT EXISTS (
SELECT 1 FROM sys.check_constraints
WHERE parent_object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND name = N'CK_b_other_bmfl_node_type'
)
EXEC sys.sp_executesql N'
ALTER TABLE dbo.b_other_bmfl
ADD CONSTRAINT [CK_b_other_bmfl_node_type]
CHECK (b_node_type IN (''category'', ''module''))';
IF NOT EXISTS (
SELECT 1 FROM sys.check_constraints
WHERE parent_object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND name = N'CK_b_other_bmfl_module_ref'
)
EXEC sys.sp_executesql N'
ALTER TABLE dbo.b_other_bmfl
ADD CONSTRAINT [CK_b_other_bmfl_module_ref]
CHECK (
(b_node_type = ''category'' AND b_module_code IS NULL)
OR (
b_node_type = ''module''
AND b_module_code IS NOT NULL
AND LEN(LTRIM(RTRIM(b_module_code))) > 0
)
)';
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND name = N'IX_b_other_bmfl_module'
)
EXEC sys.sp_executesql N'
CREATE INDEX [IX_b_other_bmfl_module] ON dbo.b_other_bmfl (b_node_type, b_module_code)';
DECLARE @DropSql nvarchar(max);
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_level') IS NOT NULL
BEGIN
SET @DropSql = NULL;
SELECT @DropSql = N'ALTER TABLE dbo.b_other_bmfl DROP CONSTRAINT [' + REPLACE(dc.name, N']', N']]') + N']'
FROM sys.default_constraints dc
JOIN sys.columns c
ON c.object_id = dc.parent_object_id
AND c.column_id = dc.parent_column_id
WHERE dc.parent_object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND c.name = N'b_level';
IF @DropSql IS NOT NULL EXEC sys.sp_executesql @DropSql;
EXEC sys.sp_executesql N'ALTER TABLE dbo.b_other_bmfl DROP COLUMN b_level';
END;
IF COL_LENGTH(N'dbo.b_other_bmfl', N'b_path') IS NOT NULL
BEGIN
SET @DropSql = NULL;
SELECT @DropSql = N'ALTER TABLE dbo.b_other_bmfl DROP CONSTRAINT [' + REPLACE(dc.name, N']', N']]') + N']'
FROM sys.default_constraints dc
JOIN sys.columns c
ON c.object_id = dc.parent_object_id
AND c.column_id = dc.parent_column_id
WHERE dc.parent_object_id = OBJECT_ID(N'dbo.b_other_bmfl')
AND c.name = N'b_path';
IF @DropSql IS NOT NULL EXEC sys.sp_executesql @DropSql;
EXEC sys.sp_executesql N'ALTER TABLE dbo.b_other_bmfl DROP COLUMN b_path';
END;
END;
COMMIT TRANSACTION;
+2
View File
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
public/file-viewer
+2
View File
@@ -11,6 +11,7 @@
"fmt:check": "oxfmt --check"
},
"dependencies": {
"@file-viewer/vue3-full": "^2.2.4",
"@lucide/vue": "^1.24.0",
"@vueuse/core": "^14.3.0",
"antdv-next": "^1.4.2",
@@ -25,6 +26,7 @@
"devDependencies": {
"@antdv-next/auto-import-resolver": "^1.1.0",
"@antdv-next/tailwind": "^1.1.1",
"@file-viewer/vite-plugin": "^2.2.4",
"@tailwindcss/vite": "4.3.2",
"@vitejs/plugin-vue": "^6.0.7",
"less": "^4.7.0",
+1965 -3
View File
File diff suppressed because it is too large. Load diff
+105 -31
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import { useLookupOptions } from "./select/useLookupOptions";
defineOptions({ inheritAttrs: false });
@@ -15,69 +15,143 @@ const props = defineProps({
orderBy: { type: String, default: "" },
pageSize: { type: Number, default: 20 },
searchDelay: { type: Number, default: 300 },
allowClear: { type: Boolean, default: true },
allowClear: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue", "update:label", "change"]);
const lookup = useLookupOptions(props);
const inputValue = ref("");
const clearedByInput = ref(false);
const options = computed(() => {
const items = lookup.rows.value.map((row) => ({
value: row[props.valueKey],
label: String(row[props.labelKey] ?? ""),
record: row,
}));
const items = lookup.rows.value.map((row) => {
const label = String(row[props.labelKey] ?? "");
const value = row[props.valueKey];
return {
value: label,
label,
key: String(value ?? label),
rawValue: value,
record: row,
};
});
if (
props.modelValue !== "" &&
props.modelValue != null &&
props.label &&
!items.some((item) => item.value === props.modelValue)
) {
items.unshift({ value: props.modelValue, label: props.label, record: null });
if (props.modelValue !== "" && props.modelValue != null && props.label) {
const exists = items.some((item) => String(item.rawValue) === String(props.modelValue));
if (!exists) {
items.unshift({
value: props.label,
label: props.label,
key: String(props.modelValue),
rawValue: props.modelValue,
record: null,
});
}
}
return items;
});
function onOpenChange(open) {
if (open) lookup.open();
else lookup.resetAfterClose();
watch(
() => [props.modelValue, props.label],
() => {
if (props.modelValue === "" || props.modelValue == null) {
if (!props.label) inputValue.value = "";
return;
}
clearedByInput.value = false;
inputValue.value = props.label || "";
},
{ immediate: true },
);
function onSearch(value) {
inputValue.value = String(value ?? "");
if (!inputValue.value) {
clearSelection();
return;
}
lookup.search(value);
}
function onSelect(value, option) {
emit("update:modelValue", value);
emit("update:label", String(option.label ?? ""));
emit("change", option.record ?? null);
function onInputChange(value) {
inputValue.value = String(value ?? "");
if (!inputValue.value) {
clearSelection();
return;
}
clearedByInput.value = false;
}
function onChange(value) {
if (value !== undefined && value !== null) return;
function clearSelection() {
inputValue.value = "";
clearedByInput.value = true;
emit("update:modelValue", "");
emit("update:label", "");
emit("change", null);
}
function onBlur() {
if (clearedByInput.value || !inputValue.value) return;
inputValue.value = props.label || "";
}
function onOpenChange(open) {
if (open) lookup.open();
else {
lookup.resetAfterClose();
if (clearedByInput.value || !inputValue.value) return;
inputValue.value = props.label || "";
}
}
function onSelect(_value, option) {
const record = option.record ?? null;
const value = record ? record[props.valueKey] : option.rawValue;
const label = record ? String(record[props.labelKey] ?? "") : String(option.label ?? "");
inputValue.value = label;
clearedByInput.value = false;
emit("update:modelValue", value ?? "");
emit("update:label", label);
emit("change", record);
}
function onInputKeydown(event) {
if (event.key !== "Backspace" && event.key !== "Delete") return;
const checkCleared = () => {
const target = event.target;
const value = target && "value" in target ? String(target.value ?? "") : inputValue.value;
if (!value) clearSelection();
};
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(checkCleared);
} else {
setTimeout(checkCleared, 0);
}
}
</script>
<template>
<a-select
<a-auto-complete
v-bind="$attrs"
class="fms-select"
:value="modelValue === '' ? undefined : modelValue"
:value="inputValue"
:options="options"
:loading="lookup.loading.value"
:filter-option="false"
:show-search="{ onSearch }"
:not-found-content="lookup.loading.value ? '加载中...' : '暂无数据'"
:allow-clear="allowClear"
show-search
@search="lookup.search"
@change="onInputChange"
@clear="clearSelection"
@blur="onBlur"
@focus="lookup.open"
@input-keydown="onInputKeydown"
@open-change="onOpenChange"
@popup-scroll="lookup.onPopupScroll"
@select="onSelect"
@change="onChange"
/>
</template>
<style scoped>
<style scoped lang="scss">
.fms-select {
width: 100%;
}
+21 -3
View File
@@ -1,9 +1,11 @@
<script setup>
import { computed, ref, useAttrs, watch } from "vue";
import { useAppStore } from "@/stores/app";
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const appStore = useAppStore();
const handledEventAttrs = new Set([
"onDrop",
"onDragStart",
@@ -36,7 +38,6 @@ const props = defineProps({
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 },
@@ -48,13 +49,30 @@ const effectiveVirtual = computed(() => {
return props.virtual;
});
function getNodeTitle(node) {
const fallback = node?.title || node?.b_name || node?.b_code || "";
return appStore.t(node?.b_i18n, fallback);
}
function localizeNodes(nodes) {
const result = [];
for (const node of nodes || []) {
result.push({
...node,
title: getNodeTitle(node),
children: localizeNodes(node.children),
});
}
return result;
}
const effectiveTreeData = computed(() => {
return [
{
key: props.rootKey,
title: props.rootTitle,
title: appStore.t("common.allCategories", "全部分类"),
isFmsRoot: true,
children: props.treeData,
children: localizeNodes(props.treeData),
},
];
});
@@ -1,4 +1,4 @@
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { onBeforeUnmount, ref, watch } from "vue";
import { useTimeoutFn } from "@vueuse/core";
import { pageDataApi } from "@/services/api";
@@ -19,10 +19,6 @@ export function useLookupOptions(props) {
const total = ref(0);
const query = ref("");
const searchColumns = computed(() => [
...new Set([props.labelKey, props.valueKey, ...props.searchColumns].filter(Boolean)),
]);
let requestId = 0;
async function load(searchText = query.value, append = false) {
@@ -40,7 +36,7 @@ export function useLookupOptions(props) {
const response = await pageDataApi({
viewName: props.viewName,
orderBy: props.orderBy || props.labelKey,
searchColumns: searchColumns.value,
searchColumns: props.searchColumns.length ? props.searchColumns : undefined,
searchCondition: combineConditions(props.searchCondition, searchFilter),
pageNo: requestedPage,
pageSize: props.pageSize,
@@ -42,8 +42,8 @@ const menuItems = computed(() => {
},
{
key: "/base/otherdata",
label: moduleLabel("m_base_otherdata", "/base/otherdata", "其他数据"),
moduleId: "m_base_otherdata",
label: moduleLabel("base_otherdata", "/base/otherdata", "其他数据"),
moduleId: "base_otherdata",
},
].filter(canAccessMenu);
const systemPages = [
+2
View File
@@ -7,6 +7,7 @@ import "./styles/tailwind.css";
import "./styles/main.scss";
import "stk-table-vue/lib/style.css";
import { registerFeature, useAreaSelection } from "stk-table-vue";
import FileViewer from '@file-viewer/vue3-full'
const app = createApp(App);
const pinia = createPinia();
@@ -16,4 +17,5 @@ registerFeature(useAreaSelection);
pinia.use(piniaPluginPersistedstate);
app.use(pinia);
app.use(router);
app.use(FileViewer);
app.mount("#app");
+1 -1
View File
@@ -44,7 +44,7 @@ const routes = [
title: "其他数据",
section: "基础数据",
menuKey: "/base/otherdata",
moduleId: "m_base_otherdata",
moduleId: "base_otherdata",
},
},
{
@@ -113,14 +113,14 @@ const editingCategoryId = ref(null);
const addForm = reactive({ b_name: "" });
const addSaving = ref(false);
const deleteSaving = ref(false);
const categoryModalTitle = computed(() => (editingCategoryId.value ? "修改分类" : "新增分类"));
const categoryModalTitle = computed(() => (editingCategoryId.value ? "修改分类" : "添加分类"));
function buildCategoryContextMenu(node) {
if (!node) return { items: [] };
const items = [
{
key: "add-child",
label: node.isFmsRoot ? "新增根分类" : "添加子分类",
label: "添加分类",
icon: h(Plus, { size: 14 }),
},
];
@@ -5,64 +5,158 @@ 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 { useAppStore } from "@/stores/app";
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 appStore = useAppStore();
const categories = ref([]);
const dataModules = ref([]);
const categoryLoading = ref(false);
const selectedCategoryKey = ref(ALL_CATEGORIES_KEY);
const moduleLoading = ref(false);
const selectedCategoryKey = ref("");
const selectedModuleCode = ref("");
const expandedCategoryKeys = ref([ALL_CATEGORIES_KEY]);
const modalVisible = ref(false);
const modalParentId = ref(null);
const modalParentName = ref("");
const editingCategoryId = ref(null);
const modalType = ref("category");
const saving = ref(false);
const deleting = ref(false);
const form = reactive({ b_name: "", b_module_code: "" });
const categoryById = computed(
() => new Map(categories.value.map((item) => [String(item.b_id), item])),
);
const tableKey = computed(() => selectedModuleCode.value);
const shouldLoadData = computed(() => Boolean(selectedModuleCode.value));
const selectedCategoryKeys = computed(() =>
selectedCategoryKey.value ? [selectedCategoryKey.value] : [],
);
const modalTitle = computed(() => {
if (editingCategoryId.value) return modalType.value === "node" ? "修改节点" : "修改分类";
return modalType.value === "node" ? "添加节点" : "添加分类";
});
const moduleOptions = computed(() => {
const usedCodes = new Set();
for (const item of categories.value) {
if (String(item.b_id) === String(editingCategoryId.value || "")) continue;
if (getNodeType(item) !== "module") continue;
if (item.b_module_code) usedCodes.add(String(item.b_module_code));
}
const options = [];
for (const item of dataModules.value) {
if (!item.b_code || usedCodes.has(String(item.b_code))) continue;
options.push({
label: `${appStore.t(item.b_i18n, item.b_name || item.b_code)}(${item.b_code})`,
value: item.b_code,
});
}
return options;
});
const isNodeModal = computed(() => modalType.value === "node");
const moduleByCode = computed(() => {
const map = new Map();
for (const item of dataModules.value) {
if (item.b_code) map.set(String(item.b_code), item);
}
return map;
});
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: [],
}),
mapNode: buildCategoryTreeNode,
});
sortTree(roots);
return roots;
});
function buildCategoryTreeNode(item) {
let title = "";
const moduleCode = item.b_module_code || "";
const nodeType = getNodeType(item);
if (nodeType === "module") {
const module = moduleByCode.value.get(String(moduleCode));
title = appStore.t(module?.b_i18n, module?.b_name || item.b_name || moduleCode);
} else {
title = appStore.t(item.b_i18n, item.b_name);
}
if (Number(item.b_canuse) !== 1) {
title = `${title}(${appStore.t("common.disabled", "停用")})`;
}
return {
key: String(item.b_id),
title,
b_id: String(item.b_id),
b_parent_id: item.b_parent_id == null ? null : String(item.b_parent_id),
b_i18n: item.b_i18n || "",
b_node_type: nodeType,
b_module_code: moduleCode,
b_xh: item.b_xh,
children: [],
};
}
function getNodeType(item) {
if (!item) return "category";
if (item.b_node_type === "module") return "module";
if (item.b_node_type === "category") return "category";
if (item.b_module_code) return "module";
return "category";
}
function buildCategoryI18nKey(id) {
return `category.base_otherdata.${id}`;
}
function updateSelectedModule(key) {
selectedCategoryKey.value = key;
if (!key) {
selectedModuleCode.value = "";
return;
}
if (key === ALL_CATEGORIES_KEY) {
selectedModuleCode.value = "";
return;
}
const category = categoryById.value.get(key);
if (!category || getNodeType(category) !== "module") {
selectedModuleCode.value = "";
return;
}
const moduleCode = category.b_module_code || "";
if (Number(category.b_canuse) !== 1 || !moduleByCode.value.has(String(moduleCode))) {
selectedModuleCode.value = "";
return;
}
selectedModuleCode.value = moduleCode;
}
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,
);
const response = await loadDataApi("b_other_bmfl", "", "b_parent_id ASC, b_xh ASC, b_id ASC");
categories.value = response.data || [];
// 如果之前选中的节点被删了,回到根
const existingKeys = new Set(categories.value.map((item) => String(item.b_id)));
if (
selectedCategoryKey.value &&
selectedCategoryKey.value !== ALL_CATEGORIES_KEY &&
!existingKeys.has(selectedCategoryKey.value)
) {
selectedCategoryKey.value = ALL_CATEGORIES_KEY;
selectedModuleCode.value = "";
updateSelectedModule("");
} else {
updateSelectedModule(selectedCategoryKey.value);
}
// 展开所有有子节点的父节点
const parentKeys = categories.value
.map((item) => (item.b_parent_id == null ? "" : String(item.b_parent_id)))
.filter(Boolean);
@@ -76,53 +170,74 @@ async function loadCategories() {
}
}
function onCategorySelect(keys) {
if (!keys.length) return;
const key = String(keys[0]);
selectedCategoryKey.value = key;
async function loadDataModules() {
moduleLoading.value = true;
try {
const pageRes = await loadDataApi(
"s_module",
"b_code = N'base_otherdata' AND b_module_type = 'page' AND b_canuse = 1",
"b_id ASC",
);
const pageId = pageRes.data?.[0]?.b_id;
if (!pageId) {
dataModules.value = [];
return;
}
// 判断节点是否关联了数据模块
const cat = categoryById.value.get(key);
if (cat && cat.b_module_code) {
selectedModuleCode.value = cat.b_module_code;
} else {
selectedModuleCode.value = "";
const moduleRes = await loadDataApi(
"s_module",
`b_parent_id = ${pageId} AND b_module_type = 'data' AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
);
dataModules.value = moduleRes.data || [];
updateSelectedModule(selectedCategoryKey.value);
} catch (error) {
message.error("数据节点加载失败:" + (error?.message || error));
} finally {
moduleLoading.value = false;
}
}
// ---- 右键菜单 & 分类维护 ----
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 onCategorySelect(keys, info) {
if (info?.node?.isFmsRoot) {
updateSelectedModule(ALL_CATEGORIES_KEY);
return;
}
if (keys.length) {
updateSelectedModule(String(keys[0]));
return;
}
updateSelectedModule("");
}
function buildCategoryContextMenu(node) {
if (!node) return { items: [] };
const items = [
{
key: "add-child",
label: node.isFmsRoot ? "新增根分类" : "添加子分类",
icon: h(Plus, { size: 14 }),
},
];
if (node.isFmsRoot) {
const items = [];
const isCategory = !node?.isFmsRoot && node?.b_node_type === "category";
if (node?.isFmsRoot || isCategory) {
items.push(
{
key: "add-category",
label: "添加分类",
icon: h(Plus, { size: 14 }),
},
{
key: "add-node",
label: "添加节点",
icon: h(Plus, { size: 14 }),
},
);
}
if (node?.isFmsRoot) {
items.push({
key: "refresh",
label: "刷新分类",
icon: h(RefreshCw, { size: 14 }),
});
}
if (!node.isFmsRoot) {
} else {
items.push(
{
key: "edit",
label: "修改分类",
label: node?.b_node_type === "module" ? "修改节点" : "修改分类",
icon: h(Pencil, { size: 14 }),
},
{ type: "divider" },
@@ -134,31 +249,42 @@ function buildCategoryContextMenu(node) {
},
);
}
return { items };
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;
if (key === "refresh") {
loadCategories();
return;
}
if (key === "add-category") {
openAddModal(node, "category");
return;
}
if (key === "add-node") {
openAddModal(node, "node");
return;
}
if (key === "edit") {
openEditCategoryModal(node);
return;
}
if (key === "refresh") {
loadCategories();
openEditModal(node);
return;
}
if (key === "delete") confirmDeleteCategory(node);
}
function openEditCategoryModal(node) {
function openAddModal(node, type) {
editingCategoryId.value = null;
modalType.value = type;
modalParentId.value = node?.isFmsRoot ? null : String(node?.b_id || node?.key || "");
modalParentName.value = node?.isFmsRoot ? "" : node?.title || "";
form.b_name = "";
form.b_module_code = "";
modalVisible.value = true;
}
function openEditModal(node) {
if (!node || node.isFmsRoot) return;
const categoryId = String(node.b_id || node.key || "");
const category = categoryById.value.get(categoryId);
@@ -168,94 +294,111 @@ function openEditCategoryModal(node) {
}
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;
modalType.value = getNodeType(category) === "module" ? "node" : "category";
modalParentId.value = parentId;
modalParentName.value = parentId ? categoryById.value.get(parentId)?.b_name || "" : "";
form.b_name = category.b_name || "";
form.b_module_code = category.b_module_code || "";
modalVisible.value = true;
}
function nextSort(parentId) {
let max = 0;
for (const item of categories.value) {
const sameParent = parentId
? String(item.b_parent_id) === String(parentId)
: item.b_parent_id == null;
if (sameParent) max = Math.max(max, Number(item.b_xh) || 0);
}
return max + 10;
}
function buildNewCategory(id, name, moduleCode, nodeType) {
const parentId = modalParentId.value;
return {
b_id: id,
b_parent_id: parentId || null,
b_name: name,
b_i18n: nodeType === "category" ? buildCategoryI18nKey(id) : null,
b_node_type: nodeType,
b_xh: nextSort(parentId),
b_canuse: 1,
b_module_code: moduleCode,
};
}
function findDataModule(code) {
for (const item of dataModules.value) {
if (item.b_code === code) return item;
}
return null;
}
function onModuleChange(code) {
form.b_module_code = code || "";
const module = findDataModule(form.b_module_code);
if (module) form.b_name = module.b_name || module.b_code || "";
}
async function saveCategory() {
const name = (addForm.b_name || "").trim();
if (!name) {
let name = String(form.b_name || "").trim();
if (!name && !isNodeModal.value) {
message.warning("请填写名称");
return;
}
const moduleCode = (addForm.b_module_code || "").trim() || null;
addSaving.value = true;
let moduleCode = String(form.b_module_code || "").trim() || null;
let nodeType = "category";
if (isNodeModal.value) {
const module = findDataModule(moduleCode);
if (!module) {
message.warning("请选择数据节点");
return;
}
name = module.b_name || module.b_code || name;
nodeType = "module";
} else {
moduleCode = null;
}
saving.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,
}],
updates: [
{
b_id: editingCategoryId.value,
b_name: name,
b_i18n:
nodeType === "category" ? buildCategoryI18nKey(editingCategoryId.value) : null,
b_node_type: nodeType,
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;
message.success(isNodeModal.value ? "节点已修改" : "分类已修改");
} else {
const idRes = await nextIdApi();
await saveObjectApi([
{
table: "b_other_bmfl",
key_field: "b_id",
inserts: [buildNewCategory(String(idRes.data), name, moduleCode, nodeType)],
},
]);
message.success(isNodeModal.value ? "节点已添加" : "分类已添加");
}
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;
modalVisible.value = false;
await loadCategories();
} catch (e) {
message.error("保存失败:" + (e.message || e));
} catch (error) {
message.error("保存失败:" + (error?.message || error));
} finally {
addSaving.value = false;
saving.value = false;
}
}
@@ -270,7 +413,6 @@ 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,
@@ -278,19 +420,21 @@ async function hasPersistedChildCategory(categoryId) {
return Boolean(response.data?.rows?.length);
}
async function confirmDeleteCategory(node) {
if (!node || node.isFmsRoot || deleteSaving.value) return;
function confirmDeleteCategory(node) {
if (!node || node.isFmsRoot || deleting.value) return;
const categoryId = String(node.b_id || node.key || "");
if (!/^\d+$/.test(categoryId)) {
message.error("分类 ID 无效,无法删除");
return;
}
const category = categoryById.value.get(categoryId);
const isNode = getNodeType(category) === "module";
if (hasChildCategory(categoryId)) {
message.warning("该分类下还有子分类,不能删除");
return;
}
deleteSaving.value = true;
deleting.value = true;
Modal.confirm({
title: `确定删除「${node.title}」?`,
content: "此操作不可恢复",
@@ -298,16 +442,14 @@ async function confirmDeleteCategory(node) {
cancelText: "取消",
okButtonProps: { danger: true },
onCancel: () => {
deleteSaving.value = false;
deleting.value = false;
},
onOk: async () => {
try {
const hasChild = await hasPersistedChildCategory(categoryId);
if (hasChild) {
if (await hasPersistedChildCategory(categoryId)) {
message.warning("该分类下还有子分类,不能删除");
return;
}
await saveObjectApi([
{
table: "b_other_bmfl",
@@ -315,23 +457,23 @@ async function confirmDeleteCategory(node) {
deletes: [{ b_id: categoryId }],
},
]);
if (selectedCategoryKey.value === categoryId) {
selectedCategoryKey.value = ALL_CATEGORIES_KEY;
selectedModuleCode.value = "";
}
message.success("分类已删除");
if (selectedCategoryKey.value === categoryId) updateSelectedModule("");
message.success(isNode ? "节点已删除" : "分类已删除");
await loadCategories();
} catch (error) {
message.error("删除失败:" + (error?.message || error));
throw error;
} finally {
deleteSaving.value = false;
deleting.value = false;
}
},
});
}
onMounted(loadCategories);
onMounted(() => {
loadCategories();
loadDataModules();
});
</script>
<template>
@@ -343,10 +485,9 @@ onMounted(loadCategories);
<FmsTree
v-model:expanded-keys="expandedCategoryKeys"
:tree-data="categoryTree"
:selected-keys="[selectedCategoryKey]"
root-title="其他数据"
:selected-keys="selectedCategoryKeys"
:context-menu="buildCategoryContextMenu"
:context-menu-disabled="categoryLoading || addSaving || deleteSaving"
:context-menu-disabled="categoryLoading || saving || deleting"
@select="onCategorySelect"
@menu-click="onCategoryMenuClick"
/>
@@ -360,36 +501,38 @@ onMounted(loadCategories);
<a-splitter-panel :min="520">
<main class="data-panel">
<FmsModuleListPage
v-if="selectedModuleCode"
:key="selectedModuleCode"
v-if="shouldLoadData"
:key="tableKey"
:data-code="selectedModuleCode"
/>
<a-empty
v-else
description="请选择具体数据项(叶子节点)"
/>
<div v-else class="data-empty">
<a-empty description="暂无数据" />
</div>
</main>
</a-splitter-panel>
</a-splitter>
<a-modal
v-model:open="addModalVisible"
:title="categoryModalTitle"
:confirm-loading="addSaving"
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="saving"
@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 v-if="modalParentName" label="上级分类">
<a-input :value="modalParentName" disabled />
</a-form-item>
<a-form-item label="名称">
<a-input v-model:value="addForm.b_name" :maxlength="150" />
<a-form-item v-if="!isNodeModal" label="名称">
<a-input v-model:value="form.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 v-if="isNodeModal" label="数据节点">
<a-select
:value="form.b_module_code || undefined"
:options="moduleOptions"
:loading="moduleLoading"
show-search
option-filter-prop="label"
@update:value="onModuleChange"
/>
</a-form-item>
</a-form>
@@ -453,4 +596,12 @@ onMounted(loadCategories);
border: 0;
border-radius: 0;
}
.data-empty {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
}
</style>
+158 -2
View File
@@ -1,8 +1,10 @@
<script setup>
import { h, shallowRef } from "vue";
import { h, ref, shallowRef } from "vue";
import { message } from "antdv-next";
import { Input, InputNumber, Select } from "antdv-next";
import FmsSelect from "@/components/FmsSelect.vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { FileViewer, withFullViewerOptions } from "@file-viewer/vue3-full";
const COLUMN_COUNT = 20;
const ROW_COUNT = 4000;
@@ -47,6 +49,9 @@ function buildData() {
}
const dataSource = shallowRef(buildData());
const selectedLocale = ref("");
const selectedLocaleLabel = ref("");
const selectedLocaleRecord = shallowRef(null);
// 当前编辑行 key;进入编辑时留存快照,取消可还原
const editingKey = shallowRef(null);
@@ -170,6 +175,35 @@ function regenerate() {
function rowClassName(row) {
return row.id === editingKey.value ? "fms-row-editing" : "";
}
function onLocaleChange(record) {
selectedLocaleRecord.value = record;
}
// ── 文件预览 Demo(@file-viewer/vue3-full) ──
const fvOptions = withFullViewerOptions();
const viewerVisible = ref(false);
const viewerFile = ref(null);
const viewerUrl = ref("");
const viewerUrlInput = ref("");
const viewerKey = ref(0);
function openViewer() {
viewerVisible.value = true;
}
function onPickFile(file) {
viewerFile.value = file;
viewerUrl.value = "";
viewerKey.value += 1;
return false; // 阻止 antdv 实际上传,仅取本地 File
}
function applyUrl() {
const u = viewerUrlInput.value.trim();
if (!u) return;
viewerUrl.value = u;
viewerFile.value = null;
viewerKey.value += 1;
}
</script>
<template>
@@ -187,9 +221,31 @@ function rowClassName(row) {
<a-button :disabled="editingKey == null" type="primary" @click="saveEdit">保存</a-button>
<a-button :disabled="editingKey == null" @click="cancelEdit">取消</a-button>
<a-button @click="regenerate">重新生成</a-button>
<a-button @click="openViewer">文件预览</a-button>
</div>
</header>
<section class="select-demo">
<div class="select-demo__field">
<label class="select-demo__label">FmsSelect 远程选择</label>
<FmsSelect
v-model="selectedLocale"
v-model:label="selectedLocaleLabel"
view-name="b_i18n_type"
label-key="b_name"
value-key="b_code"
order-by="b_code ASC"
:page-size="10"
@change="onLocaleChange"
/>
</div>
<div class="select-demo__value">
<span>当前值:{{ selectedLocale || "-" }}</span>
<span>显示文本:{{ selectedLocaleLabel || "-" }}</span>
<span>记录编码:{{ selectedLocaleRecord?.b_code || "-" }}</span>
</div>
</section>
<section class="table-demo__body">
<FmsTable
v-model:columns="columns"
@@ -200,10 +256,45 @@ function rowClassName(row) {
@row-click="onRowClick"
/>
</section>
<a-modal
v-model:open="viewerVisible"
title="文件预览(@file-viewer/vue3-full)"
:width="960"
:footer="null"
>
<div class="fv-demo">
<div class="fv-bar">
<a-upload :show-upload-list="false" :before-upload="onPickFile" accept="*">
<a-button type="primary">选择本地文件</a-button>
</a-upload>
<a-input
v-model:value="viewerUrlInput"
placeholder="或粘贴文件 URL,如 https://example.com/demo.pdf"
style="width: 380px"
@press-enter="applyUrl"
/>
<a-button @click="applyUrl">预览 URL</a-button>
</div>
<div class="fv-body">
<FileViewer
v-if="viewerFile || viewerUrl"
:key="viewerKey"
:file="viewerFile || undefined"
:url="viewerUrl || undefined"
:options="fvOptions"
/>
<div v-else class="fv-empty">请选择本地文件或输入文件 URL 进行预览</div>
</div>
<p class="fv-tip">
支持图片 / PDF / Office / Markdown / 文本 / 音视频等;PDF、Office 等格式由浏览器端 WASM 渲染,无需服务端转换。
</p>
</div>
</a-modal>
</main>
</template>
<style scoped>
<style scoped lang="scss">
.table-demo {
display: flex;
flex-direction: column;
@@ -254,7 +345,72 @@ function rowClassName(row) {
padding: 12px 16px 16px;
}
.select-demo {
display: flex;
flex: none;
align-items: flex-end;
gap: 16px;
padding: 12px 16px 0;
}
.select-demo__field {
width: 320px;
}
.select-demo__label {
display: block;
margin-bottom: 6px;
color: var(--fms-text-secondary);
font-size: 12px;
}
.select-demo__value {
display: flex;
min-width: 0;
gap: 16px;
color: var(--fms-text-secondary);
font-size: 12px;
line-height: 32px;
}
:deep(.fms-row-editing) {
background: rgba(22, 119, 255, 0.08) !important;
}
// ── 文件预览 Demo ──
.fv-demo {
display: flex;
flex-direction: column;
gap: 12px;
}
.fv-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.fv-body {
height: 560px;
border: 1px solid var(--fms-border);
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.fv-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--fms-text-secondary);
font-size: 13px;
}
.fv-tip {
margin: 0;
color: var(--fms-text-secondary);
font-size: 12px;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { message, Modal } from "antdv-next";
import FmsLoadingOverlay from "@/components/FmsLoadingOverlay.vue";
import FmsTree from "@/components/FmsTree.vue";
@@ -40,6 +40,7 @@ const selectedModuleId = ref(null);
const skipLoad = ref(false);
const expandTimer = ref(null);
const activeTab = ref("basic");
let expandDelayResolve = null;
function buildTreeContextMenu(node) {
const items = [];
if (!node) return { items };
@@ -329,7 +330,8 @@ async function onTreeDrop(info) {
}
return true;
}
if (nodes[i].children && insertIntoTree(nodes[i].children, dropNodeKey, nodes[i])) return true;
if (nodes[i].children && insertIntoTree(nodes[i].children, dropNodeKey, nodes[i]))
return true;
}
return false;
}
@@ -371,10 +373,23 @@ async function onTreeDrop(info) {
}
// ---- 模块配置加载 ----
async function loadModuleConfig(moduleId) {
function waitForNextPaint() {
if (typeof requestAnimationFrame !== "function") return Promise.resolve();
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
}
async function loadModuleConfig(moduleId, options = {}) {
const id = String(moduleId);
const applyReady = options.applyReady;
if (options.waitForPaint) await waitForNextPaint();
if (String(selectedModuleId.value) !== id) return;
configLoading.value = true;
try {
const id = String(moduleId);
const moduleType = findNode(id)?.b_module_type;
let moduleResponse;
let fieldsResponse;
@@ -413,6 +428,8 @@ async function loadModuleConfig(moduleId) {
moduleResponse = await loadDataApi("s_module", `b_id = ${id}`);
}
if (String(selectedModuleId.value) !== id) return;
if (applyReady && !(await applyReady)) return;
if (String(selectedModuleId.value) !== id) return;
const original = {
@@ -448,6 +465,23 @@ function clearExpandTimer() {
clearTimeout(expandTimer.value);
expandTimer.value = null;
}
if (expandDelayResolve) {
expandDelayResolve(false);
expandDelayResolve = null;
}
}
function waitForExpandAnimation() {
return new Promise((resolve) => {
expandDelayResolve = (ready) => {
resolve(ready);
};
expandTimer.value = setTimeout(() => {
expandTimer.value = null;
expandDelayResolve = null;
resolve(true);
}, 300);
});
}
function onTreeSelect(keys, info) {
@@ -481,15 +515,15 @@ function onTreeSelect(keys, info) {
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);
selectedModuleId.value = key;
loadedModuleId.value = null;
void loadModuleConfig(key, {
waitForPaint: true,
applyReady: waitForExpandAnimation(),
});
}
}
@@ -1056,9 +1090,7 @@ async function syncFields() {
}
// 多语言面板中移除旧字段对应的受管翻译行(_managedId 格式为 field:<b_id>)
if (i18nPanelRef.value) {
const managedIds = new Set(
Array.from(removedFieldIds).map((id) => `field:${id}`),
);
const managedIds = new Set(Array.from(removedFieldIds).map((id) => `field:${id}`));
i18nPanelRef.value.removeByManagedIds(managedIds);
}
}
@@ -1151,192 +1183,196 @@ async function syncFields() {
// ---- 初始化 ----
void loadTree().catch((e) => message.error("模块树加载失败:" + (e.message || e)));
onBeforeUnmount(clearExpandTimer);
</script>
<template>
<div class="mm-page">
<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"
@select="onTreeSelect"
@menu-click="onTreeMenuClick"
>
<template #title="node">
<span class="mm-tree-node">
<span>{{ node.title }}</span>
</span>
</template>
</FmsTree>
</div>
</aside>
</a-splitter-panel>
<a-splitter-panel :min="720">
<!-- 右侧详情 -->
<main class="mm-main">
<FmsLoadingOverlay :loading="configLoading">
<template v-if="selectedModule">
<!-- 页面头部 -->
<div class="mm-header">
<a-space>
<a-button
type="primary"
:loading="configSaving"
:disabled="configLoading || configSyncing"
@click="saveAll"
>保存</a-button
>
<a-button
v-if="selectedModule.b_module_type === 'data'"
:loading="configSyncing"
:disabled="syncDisabled"
@click="syncFields"
>
同步字段
</a-button>
</a-space>
<div class="mm-header-center">
<a-space size="middle">
<span>启用</span>
<a-checkbox
:checked="drafts.module?.b_canuse === 1"
:disabled="busy"
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
/>
<a-divider type="vertical" />
<span>显示菜单</span>
<a-checkbox
:checked="drafts.module?.b_canmenu === 1"
:disabled="busy || drafts.module?.b_module_type === 'data'"
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
/>
<template v-if="drafts.module?.b_module_type === 'data'">
<a-divider type="vertical" />
<span>自动编码</span>
<a-checkbox
:checked="autoCodeEnabled"
:disabled="busy"
@update:checked="setAutoCodeEnabled"
/>
</template>
</a-space>
<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"
@select="onTreeSelect"
@menu-click="onTreeMenuClick"
>
<template #title="node">
<span class="mm-tree-node">
<span>{{
appStore.t(node.b_i18n, node.b_name || node.b_code || node.title)
}}</span>
</span>
</template>
</FmsTree>
</div>
</aside>
</a-splitter-panel>
<!-- Tab 面板 -->
<div class="mm-body">
<a-tabs v-model:active-key="activeTab" class="mm-tabs">
<a-tab-pane
v-for="tab in visibleTabs"
:key="tab.key"
:tab="tab.tab"
:force-render="tab.key === 'i18n'"
>
<ModuleBasicPanel
v-if="tab.key === 'basic'"
:module-id="String(selectedModule.b_id)"
:data="drafts.module"
:parent-name="parentModuleName"
:loading="busy"
@update="(v) => onUpdate('module', v)"
@i18n-saved="onI18nSaved"
/>
<ModuleFieldsPanel
v-else-if="tab.key === 'fields'"
:module-id="String(selectedModule.b_id)"
:module-code="selectedModule?.b_code || ''"
:fields="drafts.fields"
:loading="busy"
@update="onFieldsUpdate"
@i18n-saved="onI18nSaved"
/>
<ModuleListConfigPanel
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'"
:module-id="String(selectedModule.b_id)"
:query-config="drafts.queryConfig"
:fields="drafts.fields"
:loading="busy"
@update="(v) => onUpdate('queryConfig', v)"
/>
<ModuleAutoCodePanel
v-else-if="tab.key === 'auto-code'"
:module-id="String(selectedModule.b_id)"
:auto-code="drafts.autoCode"
:fields="drafts.fields"
:loading="busy"
@update="onAutoCodeUpdate"
/>
<ModulePowersPanel
v-else-if="tab.key === 'powers'"
:module-id="String(selectedModule.b_id)"
:module-code="selectedModule?.b_code || ''"
:powers="drafts.powers"
:loading="busy"
@update="(v) => onUpdate('powers', v)"
@i18n-saved="onI18nSaved"
/>
<ModuleI18nPanel
v-else-if="tab.key === 'i18n'"
:ref="setI18nPanelRef"
:module-id="String(selectedModule.b_id)"
:i18n-items="i18nItems"
:loading="busy"
/>
</a-tab-pane>
</a-tabs>
</div>
</template>
<a-splitter-panel :min="720">
<!-- 右侧详情 -->
<main class="mm-main">
<FmsLoadingOverlay :loading="configLoading">
<template v-if="selectedModule">
<!-- 页面头部 -->
<div class="mm-header">
<a-space>
<a-button
type="primary"
:loading="configSaving"
:disabled="configLoading || configSyncing"
@click="saveAll"
>保存</a-button
>
<a-button
v-if="selectedModule.b_module_type === 'data'"
:loading="configSyncing"
:disabled="syncDisabled"
@click="syncFields"
>
同步字段
</a-button>
</a-space>
<div class="mm-header-center">
<a-space size="middle">
<span>启用</span>
<a-checkbox
:checked="drafts.module?.b_canuse === 1"
:disabled="busy"
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
/>
<a-divider type="vertical" />
<span>显示菜单</span>
<a-checkbox
:checked="drafts.module?.b_canmenu === 1"
:disabled="busy || drafts.module?.b_module_type === 'data'"
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
/>
<template v-if="drafts.module?.b_module_type === 'data'">
<a-divider type="vertical" />
<span>自动编码</span>
<a-checkbox
:checked="autoCodeEnabled"
:disabled="busy"
@update:checked="setAutoCodeEnabled"
/>
</template>
</a-space>
</div>
</div>
<!-- 未选中模块 -->
<div v-else class="mm-empty">
<a-result status="info" title="请选择模块" sub-title="从左侧树选择一个模块查看详情" />
</div>
</FmsLoadingOverlay>
</main>
</a-splitter-panel>
<!-- Tab 面板 -->
<div class="mm-body">
<a-tabs v-model:active-key="activeTab" class="mm-tabs">
<a-tab-pane
v-for="tab in visibleTabs"
:key="tab.key"
:tab="tab.tab"
:force-render="tab.key === 'i18n'"
>
<ModuleBasicPanel
v-if="tab.key === 'basic'"
:module-id="String(selectedModule.b_id)"
:data="drafts.module"
:parent-name="parentModuleName"
:loading="busy"
@update="(v) => onUpdate('module', v)"
@i18n-saved="onI18nSaved"
/>
<ModuleFieldsPanel
v-else-if="tab.key === 'fields'"
:module-id="String(selectedModule.b_id)"
:module-code="selectedModule?.b_code || ''"
:fields="drafts.fields"
:loading="busy"
@update="onFieldsUpdate"
@i18n-saved="onI18nSaved"
/>
<ModuleListConfigPanel
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'"
:module-id="String(selectedModule.b_id)"
:query-config="drafts.queryConfig"
:fields="drafts.fields"
:loading="busy"
@update="(v) => onUpdate('queryConfig', v)"
/>
<ModuleAutoCodePanel
v-else-if="tab.key === 'auto-code'"
:module-id="String(selectedModule.b_id)"
:auto-code="drafts.autoCode"
:fields="drafts.fields"
:loading="busy"
@update="onAutoCodeUpdate"
/>
<ModulePowersPanel
v-else-if="tab.key === 'powers'"
:module-id="String(selectedModule.b_id)"
:module-code="selectedModule?.b_code || ''"
:powers="drafts.powers"
:loading="busy"
@update="(v) => onUpdate('powers', v)"
@i18n-saved="onI18nSaved"
/>
<ModuleI18nPanel
v-else-if="tab.key === 'i18n'"
:ref="setI18nPanelRef"
:module-id="String(selectedModule.b_id)"
:i18n-items="i18nItems"
:loading="busy"
/>
</a-tab-pane>
</a-tabs>
</div>
</template>
<!-- 未选中模块 -->
<div v-else class="mm-empty">
<a-result status="info" title="请选择模块" sub-title="从左侧树选择一个模块查看详情" />
</div>
</FmsLoadingOverlay>
</main>
</a-splitter-panel>
</a-splitter>
</div>
</template>
@@ -1,10 +1,429 @@
<script setup>
import { ref, reactive, computed } from "vue";
import { message } from "antdv-next";
import { Plus, ChevronRight, ChevronDown } from "@lucide/vue";
import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vue";
const listRef = ref(null);
// ── Mock data ──
const mockUser = {
b_id: "1001",
b_account: "zhangsan",
b_name: "张三",
b_phone: "13800138000",
b_department: "技术部",
b_canuse: "1",
b_salesman: "0",
b_docman: "0",
b_dealman: "1",
b_othermachinelogin: "0",
b_visible_customer: "1",
b_ismanager: "0",
b_purview: "1",
b_spview: "0",
b_subcompany: "",
b_inputuser: "admin",
b_inputdatetime: "2025-01-15 10:30:00",
};
// 模块+权限合并的树数据:每个模块挂载自己的 power 列表
const mockModuleTree = [
{
b_id: "g1",
b_name: "基础资料",
children: [
{
b_id: "m11", b_code: "m_customer", b_name: "客户管理",
powers: ["查看", "新增", "修改", "删除", "导出"],
},
{
b_id: "m12", b_code: "m_supplier", b_name: "供应商管理",
powers: ["查看", "新增", "修改", "删除"],
},
{
b_id: "m13", b_code: "m_product", b_name: "产品管理",
powers: ["查看", "新增", "修改"],
},
],
},
{
b_id: "g2",
b_name: "业务操作",
children: [
{
b_id: "m21", b_code: "m_order", b_name: "订单管理",
powers: ["查看", "新增", "修改", "删除", "导出", "审核"],
},
{
b_id: "m22", b_code: "m_ship", b_name: "发货管理",
powers: ["查看", "新增", "发货", "确认"],
},
],
},
{
b_id: "g3",
b_name: "报表与分析",
children: [
{
b_id: "m31", b_code: "m_sales_report", b_name: "销售报表",
powers: ["查看", "导出"],
},
{
b_id: "m32", b_code: "m_stock_report", b_name: "库存报表",
powers: ["查看"],
},
],
},
];
// 该用户当前的全部权限状态:模块授权 + 每个模块的操作权限
// key = moduleId, value = Set of power names(含 "module" 表示模块已授权)
const mockUserGrants = {
m11: new Set(["module", "查看", "新增", "修改"]),
m13: new Set(["module", "查看"]),
m21: new Set(["module", "查看", "新增", "修改", "审核"]),
};
// ── Modal state ──
const modalVisible = ref(false);
const modalTitle = ref("新增用户");
const saving = ref(false);
const activeTab = ref("basic");
// Form data
const formData = reactive({
b_account: "",
b_name: "",
b_phone: "",
b_department: "",
b_canuse: true,
b_salesman: false,
b_docman: false,
b_dealman: false,
b_othermachinelogin: false,
b_visible_customer: false,
b_ismanager: false,
b_purview: true,
b_spview: false,
});
const password = ref("");
const passwordConfirm = ref("");
// 合并后的权限状态:{ [moduleId]: Set<powerName> },"module" 表示已授权该模块
const moduleGrants = ref({});
// 左侧菜单树:当前选中模块及分组展开状态
const selectedModId = ref(null);
const collapsedGroups = ref(new Set(["g3"])); // 默认折叠"报表与分析"
const selectedModule = computed(() => {
if (!selectedModId.value) return null;
for (const group of mockModuleTree) {
const found = group.children.find((m) => m.b_id === selectedModId.value);
if (found) return found;
}
return null;
});
function selectModule(modId) {
selectedModId.value = modId;
}
function toggleGroupExpand(gid) {
const next = new Set(collapsedGroups.value);
if (next.has(gid)) next.delete(gid);
else next.add(gid);
collapsedGroups.value = next;
}
function isModuleGranted(modId) {
return moduleGrants.value[modId]?.has("module") ?? false;
}
function isPowerGranted(modId, powerName) {
return moduleGrants.value[modId]?.has(powerName) ?? false;
}
function toggleModuleGrant(modId) {
const next = { ...moduleGrants.value };
if (isModuleGranted(modId)) {
// 取消模块授权 → 清除该模块所有权限
delete next[modId];
} else {
next[modId] = new Set(["module"]);
}
moduleGrants.value = next;
}
function togglePower(modId, powerName) {
const next = { ...moduleGrants.value };
const current = next[modId];
if (!current) return;
const nextSet = new Set(current);
if (nextSet.has(powerName)) nextSet.delete(powerName);
else nextSet.add(powerName);
next[modId] = nextSet;
moduleGrants.value = next;
}
function toggleAllPowers(mod) {
const modId = mod.b_id;
const current = moduleGrants.value[modId];
if (!current) return;
const next = { ...moduleGrants.value };
const nextSet = new Set(current);
const allGranted = mod.powers.every((p) => nextSet.has(p));
if (allGranted) {
for (const p of mod.powers) nextSet.delete(p);
} else {
for (const p of mod.powers) nextSet.add(p);
}
next[modId] = nextSet;
moduleGrants.value = next;
}
function resetForm() {
formData.b_account = "";
formData.b_name = "";
formData.b_phone = "";
formData.b_department = "";
formData.b_canuse = true;
formData.b_salesman = false;
formData.b_docman = false;
formData.b_dealman = false;
formData.b_othermachinelogin = false;
formData.b_visible_customer = false;
formData.b_ismanager = false;
formData.b_purview = true;
formData.b_spview = false;
password.value = "";
passwordConfirm.value = "";
moduleGrants.value = {};
selectedModId.value = null;
collapsedGroups.value = new Set(["g3"]);
}
function fillEditForm(user) {
formData.b_account = user.b_account || "";
formData.b_name = user.b_name || "";
formData.b_phone = user.b_phone || "";
formData.b_department = user.b_department || "";
formData.b_canuse = user.b_canuse === "1" || user.b_canuse === 1;
formData.b_salesman = user.b_salesman === "1" || user.b_salesman === 1;
formData.b_docman = user.b_docman === "1" || user.b_docman === 1;
formData.b_dealman = user.b_dealman === "1" || user.b_dealman === 1;
formData.b_othermachinelogin = user.b_othermachinelogin === "1" || user.b_othermachinelogin === 1;
formData.b_visible_customer = user.b_visible_customer === "1" || user.b_visible_customer === 1;
formData.b_ismanager = user.b_ismanager === "1" || user.b_ismanager === 1;
formData.b_purview = user.b_purview === "1" || user.b_purview === 1;
formData.b_spview = user.b_spview === "1" || user.b_spview === 1;
password.value = "";
passwordConfirm.value = "";
}
// ── Handlers ──
function handleAdd() {
modalTitle.value = "新增用户";
activeTab.value = "basic";
resetForm();
modalVisible.value = true;
}
function handleEdit(row) {
modalTitle.value = `编辑用户 - ${row.b_name || row.b_account || ""}`;
activeTab.value = "basic";
fillEditForm(mockUser);
// 深拷贝 mock user grants
const grants = {};
for (const [modId, powers] of Object.entries(mockUserGrants)) {
grants[modId] = new Set(powers);
}
moduleGrants.value = grants;
selectedModId.value = mockModuleTree[0].children[0].b_id;
collapsedGroups.value = new Set();
modalVisible.value = true;
}
const rowActions = [
{ key: "edit", type: "action", label: "编辑", onClick: handleEdit },
];
// Save (demo only)
async function handleSave() {
saving.value = true;
await new Promise((r) => setTimeout(r, 600));
saving.value = false;
modalVisible.value = false;
message.success("(Demo)保存成功 — 未真实调用接口");
}
</script>
<template>
<div class="user-page">
<FmsModuleListPage data-code="b_user" />
<FmsModuleListPage
ref="listRef"
data-code="b_user"
:row-actions="rowActions"
@row-action="({ record }) => { if (record?.action?.key === 'edit') handleEdit(record.record) }"
>
<template #actions>
<a-button type="primary" @click="handleAdd">
<Plus :size="15" />
新增用户
</a-button>
</template>
</FmsModuleListPage>
<!-- Demo Edit Modal -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:width="1000"
:confirm-loading="saving"
:body-style="{ padding: '0 24px 16px', maxHeight: '70vh', overflow: 'auto' }"
@ok="handleSave"
@cancel="modalVisible = false"
>
<a-tabs v-model:active-key="activeTab">
<!-- Tab 1: 基本信息(含密码) -->
<a-tab-pane key="basic" tab="基本信息">
<div class="demo-basic-info">
<a-row :gutter="[24, 16]">
<a-col :span="12">
<label class="demo-field-label">登录账号 <span class="demo-required">*</span></label>
<a-input v-model:value="formData.b_account" placeholder="请输入登录账号" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">用户姓名 <span class="demo-required">*</span></label>
<a-input v-model:value="formData.b_name" placeholder="请输入用户姓名" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">手机号</label>
<a-input v-model:value="formData.b_phone" placeholder="请输入手机号" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">部门</label>
<a-input v-model:value="formData.b_department" placeholder="请输入部门" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">新密码</label>
<a-input-password v-model:value="password" placeholder="留空则不修改" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">确认密码</label>
<a-input-password v-model:value="passwordConfirm" placeholder="请再次输入" />
</a-col>
</a-row>
<!-- 标志位 -->
<div class="demo-flags-card">
<div class="demo-card-title">标志位</div>
<a-row :gutter="[16, 12]">
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_canuse">启用</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_salesman">销售员</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_docman">单据主管</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_dealman">可处理事务</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_othermachinelogin">允许异地登录</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_visible_customer">可见客户</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_ismanager">管理员</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_purview">操作权限</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_spview">审批权限</a-checkbox>
</a-col>
</a-row>
</div>
</div>
</a-tab-pane>
<!-- Tab 2: 菜单权限(左树右操作列表) -->
<a-tab-pane key="module-power" tab="菜单权限">
<div class="perm-layout">
<!-- 左侧:菜单树 -->
<div class="perm-tree">
<div v-for="group in mockModuleTree" :key="group.b_id" class="tree-group">
<div class="tree-group-hd" @click="toggleGroupExpand(group.b_id)">
<span class="tree-group-ic">
<ChevronRight v-if="collapsedGroups.has(group.b_id)" :size="14" />
<ChevronDown v-else :size="14" />
</span>
<span class="tree-group-label">{{ group.b_name }}</span>
<span class="tree-group-count">{{ group.children.length }}</span>
</div>
<div v-show="!collapsedGroups.has(group.b_id)" class="tree-group-body">
<div
v-for="mod in group.children"
:key="mod.b_id"
class="tree-item"
:class="{ 'is-active': selectedModId === mod.b_id }"
@click="selectModule(mod.b_id)"
>
<a-checkbox
:checked="isModuleGranted(mod.b_id)"
@click.stop
@change="toggleModuleGrant(mod.b_id)"
/>
<span class="tree-item-name">{{ mod.b_name }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧:操作权限列表 -->
<div class="perm-detail">
<template v-if="selectedModule">
<div class="perm-detail-hd">
<span class="perm-detail-title">{{ selectedModule.b_name }}</span>
<span class="perm-detail-sub">{{ selectedModule.b_code }}</span>
</div>
<div class="perm-detail-body">
<template v-if="isModuleGranted(selectedModule.b_id)">
<label class="perm-detail-all">
<a-checkbox
:checked="selectedModule.powers.every((p) => isPowerGranted(selectedModule.b_id, p))"
:indeterminate="selectedModule.powers.some((p) => isPowerGranted(selectedModule.b_id, p)) && !selectedModule.powers.every((p) => isPowerGranted(selectedModule.b_id, p))"
@change="toggleAllPowers(selectedModule)"
/>
<span>全选操作权限</span>
</label>
<div class="perm-pow-list">
<label
v-for="pow in selectedModule.powers"
:key="pow"
class="perm-pow-item"
>
<a-checkbox
:checked="isPowerGranted(selectedModule.b_id, pow)"
@change="togglePower(selectedModule.b_id, pow)"
/>
<span>{{ pow }}</span>
</label>
</div>
</template>
<div v-else class="perm-detail-tip">
勾选左侧「{{ selectedModule.b_name }}」以授权访问,再配置具体操作权限
</div>
</div>
</template>
<div v-else class="perm-detail-empty">
请选择左侧菜单
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
</a-modal>
</div>
</template>
@@ -22,4 +441,208 @@ import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vu
border: 0;
border-radius: 0;
}
// ── Modal internal styles ──
.demo-basic-info {
padding-top: 4px;
}
.demo-field-label {
display: block;
margin-bottom: 5px;
font-size: 13px;
color: #333;
}
.demo-required {
color: #ff4d4f;
}
.demo-flags-card {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.demo-card-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: #666;
}
// ── Menu permission: left tree + right list ──
.perm-layout {
display: flex;
height: 440px;
border: 1px solid #f0f0f0;
border-radius: 6px;
overflow: hidden;
}
// 左侧:菜单树
.perm-tree {
width: 240px;
min-width: 240px;
padding: 6px 0;
border-right: 1px solid #f0f0f0;
overflow-y: auto;
background: #fafbfc;
}
.tree-group-hd {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
transition: background 0.1s;
&:hover {
background: #f0f1f3;
}
}
.tree-group-ic {
display: flex;
align-items: center;
color: #999;
}
.tree-group-label {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #1a1a1a;
}
.tree-group-count {
font-size: 11px;
color: #bbb;
}
.tree-group-body {
display: flex;
flex-direction: column;
}
.tree-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 8px 30px;
cursor: pointer;
transition: background 0.1s;
&:hover {
background: #ebedf0;
}
&.is-active {
background: #e6f0ff;
&:hover {
background: #dcebff;
}
}
}
.tree-item-name {
font-size: 13px;
color: #333;
user-select: none;
}
// 右侧:操作权限列表
.perm-detail {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: #fff;
}
.perm-detail-hd {
display: flex;
align-items: baseline;
gap: 8px;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
}
.perm-detail-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.perm-detail-sub {
font-size: 12px;
color: #bbb;
}
.perm-detail-body {
flex: 1;
padding: 12px 20px;
overflow-y: auto;
}
.perm-detail-all {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 6px;
border-radius: 6px;
background: #f6f7f9;
font-size: 13px;
color: #333;
cursor: pointer;
user-select: none;
}
.perm-pow-list {
display: flex;
flex-direction: column;
}
.perm-pow-item {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 12px;
border-bottom: 1px solid #f5f5f5;
font-size: 13px;
color: #333;
cursor: pointer;
user-select: none;
&:last-child {
border-bottom: 0;
}
&:hover {
background: #fafbfc;
}
}
.perm-detail-tip {
margin-top: 8px;
padding: 36px 16px;
text-align: center;
font-size: 13px;
color: #999;
background: #fafbfc;
border-radius: 6px;
}
.perm-detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #bbb;
}
</style>
+11
View File
@@ -4,6 +4,7 @@ import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
import { AntdvNextResolver } from "@antdv-next/auto-import-resolver";
import Components from "unplugin-vue-components/vite";
import { fileViewerRenderers } from "@file-viewer/vite-plugin";
// https://vite.dev/config/
export default defineConfig({
@@ -16,6 +17,15 @@ export default defineConfig({
host: "127.0.0.1",
port: 5180,
strictPort: true,
// 排除静态资源目录的监听:full 包复制出的 WASM/字体体积大,
// 在 Windows 下易被锁定导致 Vite 监听报 EBUSY 崩溃;public 下资源按需直接服务,无需监听
watch: {
ignored: [
"**/node_modules/**",
"**/.git/**",
"**/public/file-viewer/**",
],
},
proxy: {
"/api": {
target: "http://127.0.0.1:8088",
@@ -29,5 +39,6 @@ export default defineConfig({
Components({
resolvers: [AntdvNextResolver()],
}),
fileViewerRenderers({ copyAssets: true }),
],
});
+4 -1
View File
@@ -29,11 +29,14 @@
- [NEVER] 修改通用接口的 URL、参数、返回值结构或处理逻辑。
- [MUST] 如通用接口确实无法满足需求:先说明原因 + 拟修改内容 + 影响范围,取得用户确认后再实施。
- [MUST] `bigint` 类型(如 `b_id`)序列化到前端时必须转为**字符串**。
- [MUST] 通用查询默认**不传 `search_columns` / `searchColumns`**;除非明确需要限制返回字段且已确认字段存在,否则采用接口默认返回。
- [MUST] `config/dbconfigs` 中的旧库仅用于参考表结构、历史数据和差异排查;除非用户明确指定,不修改旧库结构或数据。
## 4. 前端实现
- [MUST] 表格使用项目封装的 **`fms-table`** 组件,不从零实现表格。
- [MUST] 表单输入控件**不添加 `placeholder`**,用 `label` 或说明文字表达意图。
- [MUST] 表单输入控件**不添加 `placeholder`**,`label` 只写字段名称,不追加“留空则...”这类操作提示信息。
- [MUST] 使用 **`FmsTree`** 时不设置 `root-title`,根节点标题由组件内部统一维护。
- [MUST] 优先复用项目现有组件和 Antdv Next 组件能力,不重复造轮子。
- [SHOULD] 页面中出现 ≥2 处相同/相近功能时,提取为可复用组件、composable 或 service 方法。
- [SHOULD] 抽取复用以确有收益为前提,不为简单逻辑增加无意义的封装。
+218
View File
@@ -0,0 +1,218 @@
# FMS 文件管理组件设计方案(三栏布局)
> 状态:待执行(计划稿)
> 目标:参考旧系统 `g3file`,为新 FMS 系统设计可复用的文件管理组件。
> 布局:左 = 分类树,中 = 文件列表 + 操作区,右 = 预览区。
---
## 1. 背景与新旧差异
旧系统 `g3file` 是分两层的组件:
- 参数化通用库 `file-preview/g3-file`(基于 Element Plus + 阿里云 OSS),支持 `modelValue`、`module-id`、`father`、`cate-id`、`readonly`、`search-condition`、`upload-config.aliyun`、`preview-url`、`global-permission`、`iconfont` 及多种回调。
- 业务包装层 `g3hd/frontend/src/components/g3-file/index.vue`,业务页只用一行调用:`<G3File v-model="showFile" :father="id" module-id="oa_notices" />`。
新 FMS 的差异与决策:
| 维度 | 旧系统 | 新系统决策 |
|------|--------|-----------|
| 框架 | Vue3 + Element Plus | Vue3 + **antdv-next**(自动导入,勿手导) |
| 上传存储 | 阿里云 OSS | 后端**本地磁盘**,文件名 `{subid}.{ext}`;预留 `OssStorageAdapter` 抽象便于将来切换 |
| moduleid | 存字符串业务 code(如 `oa_notices`) | `bf_files.mx_moduleid` 为 **bigint 外键 → `s_module.b_id`** |
| 预览 | 自研/第三方 | 复用已安装的 `@file-viewer/vue3-full`(`FileViewer`,dashboard 已验证 `withFullViewerOptions()`) |
| 调用方式 | `module-id` 字符串 | **`module-code`**(如 `oa_notices`);组件内解析为 `b_id` 写入 `mx_moduleid` |
### 关于 moduleid 的关键决策
- **保留 `mx_moduleid` 字段**,但它是**派生字段**,由组件根据 `module-code` 查 `s_module.b_id` 后写入。
- 业务页**不单独传 moduleid**,只传 `module-code` 与 `father`。组件内部完成 code → b_id 解析并随上传/保存一并提交。
- 说明:旧系统把字符串 code 直接当 moduleid 存,新系统 `mx_moduleid` 是指向 `s_module.b_id` 的 bigint 外键,二者语义不同;用 `module-code` 解析可兼容旧习惯又满足新外键约束。
---
## 2. 数据库现状(已存在,无需新建表)
表 `bf_files` / `bf_files_cateid` / `bf_files_type` 已在迁移 `017_create_contract_file_tables.sql` 定义,字段如下(节选相关项):
`bf_files`:
- `subid` bigint(主键,Snowflake ID)
- `father` bigint(业务单据 ID,如合同/通知 b_id)
- `mx_xh` int(显示序号)
- `mx_filename` nvarchar(500)(原始文件名)
- `mx_filesize` varchar(50)
- `mx_fileext` varchar(50)
- `mx_mapfilename` nvarchar(500)(**新系统存服务端存储文件名,如 `1234567890.pdf`,不再存阿里云 URL**)
- `mx_moduleid` bigint(FK → `s_module.b_id`,派生字段)
- `mx_cutfilename`、`mx_cutfilesize`(缩略图,可选)
- `mx_cate_id` bigint(→ `bf_files_cateid.b_id`,分类)
- `mx_bz` nvarchar(500)
- `mx_orderid`、`mx_sh`、`mx_shdatetime`、`mx_shuser_id`
- `mx_inputuser_id`、`mx_inputdatetime`、`mx_updateuser_id`、`mx_updatedatetime`
索引:`IX_bf_files_father`、`IX_bf_files_mx_cate_id`、`IX_bf_files_mx_shuser_id`。
`bf_files_cateid`:`b_id`、`b_name`、`b_xh`、`b_bz`、`b_canuse`、`b_files_type`、`b_code`(唯一)等。
`bf_files_type`:`b_id`、`b_name`、`b_module_id`、`b_module_name`、`b_xh` 等。
> 注意:bigint ID(subid / father / mx_moduleid / mx_cate_id)经后端返回前端时**必须序列化为字符串**。
---
## 3. 后端新增文件端点(按开发规范例外处理)
通用端点(`/api/data/...`)无法处理二进制流与 multipart 上传,因此按规范例外新增**与业务表无关的 3 个通用文件端点**。需在新增前于回复中说明原因(已在本文 §1 论证)。
### 3.1 `POST /api/file/upload`
- 请求:`multipart/form-data`
- `file`(必填,二进制)
- `father`(必填,bigint 字符串,业务单据 ID)
- `moduleCode`(必填,如 `oa_notices`)
- `cateId`(可选,bigint 字符串 → `mx_cate_id`)
- `bz`(可选)
- 处理流程:
1. 校验 JWT(过 `JwtAuthFilter`),取 `OrgContext.orgId` 选库。
2. 用 `IdGenerator` 生成 `subid`(Snowflake)。
3. 解析 `moduleCode` → `s_module.b_id`(查 `s_module` 表;查不到则报错 400)。
4. 计算扩展名 `ext`、存储名 `{subid}.{ext}`,写字节到 `fms.file.storage-dir`。
5. **只写磁盘文件,不写 `bf_files` 行**(行由前端用 `saveObjectApi` 批量保存,便于统一事务/权限)。
- 响应(JSON):
```json
{
"subid": "1234567890",
"storedName": "1234567890.pdf",
"mx_filename": "合同.pdf",
"mx_filesize": "102400",
"mx_fileext": "pdf",
"mx_mapfilename": "1234567890.pdf"
}
```
### 3.2 `GET /api/file/{subid}`
- 查 `bf_files` 取 `mx_mapfilename`,从 `storage-dir` 流式返回字节。
- 响应头:`Content-Type`(按扩展名映射)、`Content-Disposition: inline; filename*=UTF-8''<mx_filename>`(inline 便于浏览器/pdf 预览)。
- 需过 `JwtAuthFilter`(带 Token 才能取)。
### 3.3 `DELETE /api/file/{subid}`
- 删磁盘文件 + 删 `bf_files` 行(业务页也可改为走 `saveObjectApi` 的 `deletes[]`,二选一;推荐端点方式以便同时清理磁盘)。
- 返回 `{ success: true }`。
### 3.4 配置
`fms-api/src/main/resources/application.yaml` 新增:
```yaml
fms:
file:
storage-dir: ./data/files # 绝对或相对(相对应用工作目录),需 git-ignore
```
`storage-dir` 目录加入 `.gitignore`。
---
## 4. 前端组件设计(三栏布局)
目录:`fms-vue/src/components/file/`
```
FileManager.vue 三栏容器(左树 / 中列表操作 / 右预览)
FileCategoryTree.vue 左栏:分类树(虚拟根“全部附件” + bf_files_cateid 节点)
FileList.vue 中栏:文件列表 + 工具栏(上传/下载/删除/刷新)
FileUpload.vue a-upload 包装(调用 fileService.upload)
```
复用:`@file-viewer/vue3-full` 的 `FileViewer` 用于右栏预览;`antdv-next` 的 `a-upload` / `a-table` / `a-tree` / `a-button` 等(自动导入)。
服务层:`fms-vue/src/services/fileService.js`。
### 4.1 FileManager.vue(容器)
- 三栏 flex 布局:左 `FileCategoryTree`(宽 ~220px,受 `showTree` 控制),中 `FileList`(flex:1),右预览区(宽 ~40% 或固定 ~480px,仅当选中文件时显示)。
- 选中文件(来自 `FileList` 的 `select` 事件)→ 右栏渲染 `FileViewer` 并传入预览 URL(`fileService.getContentUrl(subid)`)。
- 负责 `moduleCode` → `moduleId`(b_id) 解析:挂载时调 `loadModuleConfiguration` / 通用 `loaddata` 查 `s_module` 得到 `b_id`,下传 `FileList` 备用(上传时带回)。
- 工具栏按钮(上传/下载/删除)按 `moduleCode` 查 `b_user_power` 控制显隐/禁用(前端权限,沿用现有 `permissions` store 模式)。
**Props**
| 名称 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `modelValue` | Boolean | 是 | — | 控制显隐(v-model) |
| `moduleCode` | String | 是 | — | 模块 code,如 `oa_notices` |
| `father` | [String, Number] | 是 | — | 业务单据 ID(bigint 字符串) |
| `moduleId` | [String, Number] | 否 | — | 已解析的 `s_module.b_id`;不传则组件内解析 |
| `cateId` | [String, Number] | 否 | — | 初始分类过滤 |
| `categories` | Array | 否 | — | 预置分类列表,避免请求树 |
| `readonly` | Boolean | 否 | false | 只读(隐藏上传/删除) |
| `showTree` | Boolean | 否 | true | 是否显示左栏分类树 |
**Events**
| 名称 | 载荷 | 说明 |
|------|------|------|
| `update:modelValue` | Boolean | v-model |
| `uploaded` | fileRow | 上传完成(含回写 bf_files 后的行) |
| `deleted` | subid | 删除完成 |
| `selected` | fileRow \| null | 选中文件变化(驱动右栏预览) |
| `error` | Error | 异常 |
### 4.2 FileCategoryTree.vue(左栏)
- 虚拟根节点“全部附件”(值为 `null` 或 `'__all__'`),下挂 `bf_files_cateid` 节点(来自 `loaddata` 查 `bf_files_cateid`,按 `b_xh` 排序)。
- 选中分类 emit `select(cateId)`,供 `FileList` 过滤。
- 若 `categories` prop 已提供则直接用,否则自行加载。
### 4.3 FileList.vue(中栏)
- `a-table` 展示当前 `father` +(可选)`cateId` 下的 `bf_files` 行(走 `pageDataApi` / `loadDataApi`,`view_name='bf_files'`)。
- 工具栏:`FileUpload`(上传)、下载、删除、刷新。
- 行选中 → emit `select(row)` → 容器驱动右栏。
- 上传完成后调用 `saveObjectApi` 写 `bf_files` 行(inserts: `[{table:'bf_files', key_field:'subid', ...}]`,带 `father`/`mx_moduleid`/`mx_filename`/`mx_filesize`/`mx_fileext`/`mx_mapfilename`/`mx_cate_id`)。
- 删除:调 `fileService.remove(subid)`(或 `saveObjectApi` deletes + 端点删磁盘)。
### 4.4 FileUpload.vue
- 包装 `a-upload`(`:before-upload` 拦截,手动调 `fileService.upload`)。
- 入参:`father`、`moduleCode`、`moduleId`、`cateId`;上传成功后把响应行 emit 给 `FileList` 继续写元数据。
### 4.5 fileService.js
封装以下方法(基于 `src/services/http.js`):
- `upload({file, father, moduleCode, cateId, bz})` → `POST /api/file/upload`(multipart)
- `getContentUrl(subid)` → 返回 `GET /api/file/{subid}` 的可访问 URL(需带 Token;若 axios 拦截器不自动带,则改用带 Token 的 `fetch`/blob 下载后 `URL.createObjectURL` 喂给 `FileViewer`)
- `remove(subid)` → `DELETE /api/file/{subid}`
- `listFiles({father, cateId})` → `pageDataApi`(`bf_files`)
- `saveMeta(rows)` → `saveObjectApi`
> 预览 URL 处理:因 `FileViewer` 需要可直接访问的资源地址,而 `/api/file/{subid}` 需鉴权,推荐在 `fileService` 内用带 `Authorization` 的 `fetch` 取 blob 再 `createObjectURL`,避免 Token 泄漏到 URL。
---
## 5. 业务页调用示例
```vue
<!-- 业务页(如通知管理) -->
<FileManager
v-model="showFile"
module-code="oa_notices"
:father="rowId"
/>
```
- 业务页**只需传 `module-code` 与 `father`**,不再传 moduleid。
- 只读场景:`readonly` + `show-tree` 可组合使用。
---
## 6. 落地步骤清单(执行顺序)
1. **后端**
- [ ] `application.yaml` 增加 `fms.file.storage-dir`,并把该目录加入 `.gitignore`。
- [ ] 新建 `FileController`(或并入现有 controller 包),实现 `upload` / `GET {subid}` / `DELETE {subid}` 三个端点,复用 `OrgContext` + `DbUtils` + `IdGenerator`。
- [ ] 实现本地磁盘存储(`StorageService`,预留 `OssStorageAdapter` 接口)。
- [ ] `moduleCode → b_id` 解析逻辑(查 `s_module`)。
2. **前端服务**
- [ ] `src/services/fileService.js` 五个方法。
3. **前端组件**
- [ ] `FileCategoryTree.vue`(左树)
- [ ] `FileList.vue`(中栏列表+操作,含写元数据/删除)
- [ ] `FileUpload.vue`(a-upload 包装)
- [ ] `FileManager.vue`(三栏容器 + 权限 + moduleCode 解析 + 右栏预览)
4. **集成验证**
- [ ] 选一个业务页(如通知 `oa_notices`)挂载 `<FileManager>`,验证上传/列表/分类过滤/预览/删除全链路。
- [ ] 确认 bigint ID 前端为字符串、预览 URL 鉴权正常。
---
## 7. 注意事项(沿用开发规范)
- 不新增业务专属数据端点(文件端点属通用基础设施例外,已在 §1 论证)。
- 不手动 `import` antdv-next 组件(vite resolver 已自动导入)。
- 不引入新 vue-i18n;文案沿用 DB 驱动 i18n(必要时走 `b_i18n`)。
- 改动保持聚焦,不触碰无关文件;保留用户未提交的工作。
- 交付时说明是否运行了测试、是否执行了 build(默认不主动 build/起服务,除非用户要求)。