20260721223033

This commit is contained in:
oneao committed 2026-07-21 22:30:33 +08:00
1 parent bab8e2972f
commit e9f099f044
26 files changed
+1687 -349

No files matched your search

+3 -3
View File
@@ -14,7 +14,7 @@ Supporting docs in the repo root (Chinese):
- `新数据库结构.md` — the module-management table design (authoritative schema).
- `旧数据库结构.md` — legacy schema, kept for reference only.
- `模块管理实施计划.md` — phased plan for the module-management feature.
- `fms-api/config/migrations/001_module_management.sql` — the only migration script.
- `fms-api/config/migrations/` — SQL Server schema migration scripts.
## Commands
@@ -87,7 +87,7 @@ Vue 3 `<script setup>` SFCs, Pinia for state, **antdv-next** components (auto-im
Feature code should call these, or wrap them in a dedicated service like `src/services/moduleManagement.js` (e.g. `loadModuleConfiguration`, `nextIds`, `saveChanges`). Do not introduce new backend URLs from the frontend — reuse the generic endpoints.
### Routing & permissions
- `src/router/index.js` registers view components via **static explicit `import()`**, not a dynamic whitelist. Each route carries `meta.moduleCode` (e.g. `M_MODULE`), plus `menuKey`, `title`, `requiresAuth`.
- `src/router/index.js` registers view components via **static explicit `import()`**, not a dynamic whitelist. Each route carries `meta.moduleCode` (e.g. `m_module`), plus `menuKey`, `title`, `requiresAuth`.
- A `beforeEach` guard checks auth, then `permissionStore.canModule(to.meta.moduleCode)`. `src/stores/permissions.js` loads `b_user_module` / `b_user_power` / `b_module_power` and gates page entry and button enable/disable. Currently permission enforcement is **frontend-only**; backend interception is not yet implemented.
- `src/layouts/components/AppSidebar.vue` still uses a hardcoded `fallbackMenuItems` array. It should fall back to this static menu when the generic-interface module load fails.
@@ -96,7 +96,7 @@ Feature code should call these, or wrap them in a dedicated service like `src/se
- i18n: `app` store holds `locale` (default `zh-CN`); translation text comes from the DB `b_i18n` table (loaded via `loadModuleConfiguration`), not vue-i18n.
### Module management feature
`src/views/module-management/` holds `ModuleManagementView.vue` plus `components/` panels (`ModuleTreePanel`, `ModuleBasicInfoPanel`, `ModuleFieldDefinitionPanel`, `ModulePowerPanel`, `ModuleTranslationsPanel`). Demo data lives in `src/data/moduleDemo.js` — the real page must not depend on it. The table design (six tables: `b_module`, `b_module_field`, `b_module_power`, `b_user_module`, `b_user_power`, `b_i18n`) is in `新数据库结构.md`; follow that schema exactly. `b_module.b_id` is the module code (e.g. `M_BIZ`), immutable after creation; other `b_id`s are snowflake bigints returned as strings.
`src/views/module-management/` holds `ModuleManagementView.vue` plus `components/` panels (`ModuleTreePanel`, `ModuleBasicInfoPanel`, `ModuleFieldDefinitionPanel`, `ModulePowerPanel`, `ModuleTranslationsPanel`). Demo data lives in `src/data/moduleDemo.js` — the real page must not depend on it. The table design (six tables: `b_module`, `b_module_field`, `b_module_power`, `b_user_module`, `b_user_power`, `b_i18n`) is in `新数据库结构.md`; follow that schema exactly. `b_module.b_id` is an immutable snowflake ID; `b_module.b_code` is a mutable, unique lowercase code (e.g. `m_biz`). Bigint IDs are returned to the frontend as strings.
## Cross-cutting conventions
- Reuse existing components and antdv-next; extract shared logic into components/composables/services only when it genuinely reduces duplication. Keep new CSS scoped to the component.
@@ -20,7 +20,7 @@
### 核心原则:各自负责各自
1. **权限负责「有什么操作」**,只声明编码目录,不持有任何界面文案。
2. **多语言负责「所有界面显示名」**,是唯一文案数据源;但**只覆盖菜单 / 模块 / 字段三类显示名,不覆盖权限项**(权限名是给管理员看的配置标签,不是给终端用户的按钮文案)。
2. **多语言负责「所有界面文案」**,是菜单、模块、权限、字段、提示、按钮和校验信息的统一文案数据源。
3. **字段定义驱动列表与表单**,自身不关心权限、不关心翻译落点。
4. **菜单是功能树的可投影子集**,不单独存一份菜单数据。
@@ -34,7 +34,7 @@
| 项 | 说明 |
| --- | --- |
| 模块编码 `b_id` | 唯一主键,如 `M_BIZ`、`M_USER` |
| 模块编码 `b_code` | 可修改的唯一小写编码,如 `m_biz`、`m_user` |
| 模块名称 | 在「多语言」中以 `module.<编码>` 维护(见 §5) |
| 查询对象 `view_name` | 只读裸对象名(视图),如 `v_bs_business` |
| 保存对象 `save_name` | 可写裸表名,如 `b_business`;只读模块允许为空 |
@@ -44,7 +44,7 @@
### 2.2 操作目录(权限项来源,见 §6)
模块声明它拥有哪些操作:`actions: [{ code, name }, ...]`。例如 `M_BIZ` 声明 `1=新增 … 7=导出`。
模块声明它拥有哪些操作:`actions: [{ code, name }, ...]`。例如 `m_biz` 声明 `1=新增 … 7=导出`。
### 2.3 与其它能力的关联
@@ -112,7 +112,7 @@ Module
### 4.3 关键场景:同模块、不同页面、不同权限
多个 `page` 节点可绑定**同一个模块**(如 `M_BIZ` 被 `海运业务单 / 业务单查询 / 业务单审核` 三个页面引用)。它们共享字段与操作目录,但:
多个 `page` 节点可绑定**同一个模块**(如 `m_biz` 被 `海运业务单 / 业务单查询 / 业务单审核` 三个页面引用)。它们共享字段与操作目录,但:
- 可见性各自独立(`业务单审核` 设 `showInMenu=false`,不进菜单);
- 权限各自独立(操作员在「海运业务单」可提交,在「业务单审核」仅可审核)——授权由角色赋权页另配(不在本页面)。
@@ -124,22 +124,25 @@ Module
所有**界面显示名**唯一数据源,以稳定 `i18nKey` 为中心,多种语言并列。
### 5.1 覆盖范围(仅三类显示名)
### 5.1 覆盖范围
| 命名空间 | i18nKey 规则 | 示例 |
| --- | --- | --- |
| 菜单/功能树 | `menu.<节点id>` | `menu.page_biz` |
| 模块 | `module.<模块编码>` | `module.M_BIZ` |
| 字段 | `field.<模块>.<字段code>` | `field.M_BIZ.b_ywno` |
> **权限项名称不在此列**(见 §6 说明)。
| 模块 | `module.<模块编码>` | `module.m_biz` |
| 字段 | `field.<模块>.<字段code>` | `field.m_biz.b_ywno` |
| 权限 | `power.<模块>.<权限code>` | `power.m_biz.audit` |
| 按钮 | `button.<模块>.<动作>` | `button.m_biz.approve` |
| 占位提示 | `placeholder.<模块>.<字段>` | `placeholder.m_biz.customer` |
| 校验提示 | `validation.<模块>.<规则>` | `validation.m_biz.customer_required` |
| 通用文案 | `common.<名称>` | `common.save` |
### 5.2 数据结构
```js
i18nData = {
"menu.page_biz": { "zh-CN": "海运业务单", "en-US": "Sea Business Orders", "zh-TW": "海運業務單" },
"field.M_BIZ.b_ywno": { "zh-CN": "业务单号", "en-US": "Order No.", "zh-TW": "業務單號" }
"field.m_biz.b_ywno": { "zh-CN": "业务单号", "en-US": "Order No.", "zh-TW": "業務單號" }
// ...
}
```
@@ -150,7 +153,8 @@ i18nData = {
### 5.3 管理界面要求
- 按命名空间类型筛选(菜单 / 模块 / 字段)。
- 「多语言管理」是主配置中心,模块下的多语言面板是按当前模块过滤后的快捷编辑入口;两处写入同一张多语言表。
- 支持按命名空间、模块、语言和缺失翻译状态筛选。
- 以「Key + 各语言列」并列编辑;某语言留空即回退简体。
- **缺失翻译检测**:非默认语言留空时标红,并汇总「N 条缺失」,提示补录。
@@ -165,20 +169,21 @@ i18nData = {
```js
// 模块声明
actions: [
{ code: 1, name: "新增" },
{ code: 5, name: "审核" },
{ code: 7, name: "导出" }
{ code: 1, name: "新增", i18nKey: "power.m_biz.create" },
{ code: 5, name: "审核", i18nKey: "power.m_biz.audit" },
{ code: 7, name: "导出", i18nKey: "power.m_biz.export" }
]
```
- `code`:稳定操作编码,前端与后端统一引用(如 `5` 永远代表「审核」)。**授权与校验只认 `code`,不认名字。**
- `name`:管理员可读的默认标签,仅一份(简体)。它是「这个编码是干嘛的」的配置注释,**不是终端用户的按钮文案**。
- `name`:管理员可读的默认中文回退标签。
- `i18nKey`:权限显示名绑定的多语言键;终端按钮如果需要不同文案,使用独立的 `button.*` Key。
### 6.2 为什么不进多语言
### 6.2 权限与按钮文案的边界
- 授权逻辑只认 `code`,名字不参与校验,因此没有翻译必要。
- 界面上的按钮文案常与权限名不同(权限叫「审核」,按钮可能写「通过」),把权限名塞进 i18n 反而把「权限定义」与「按钮文案」错误耦合。
- 结论:**权限定义自洽于 `{code, name}`,与多语言彻底解耦**;终端用户看到的按钮文案由各自页面/组件自行在多语言里维护。
- 授权与校验始终只认 `code`,不依赖任何语言文本。
- 权限名称和按钮文案都进入统一多语言资源,但使用不同 Key,避免把「审核」与「通过」错误耦合。
- 权限定义保持 `{code, name, i18nKey}` 自洽;按钮、提示和校验信息按各自命名空间独立维护。
### 6.3 与授权层的关系(边界)
@@ -203,10 +208,9 @@ actions: [
├─ 分组 ─────────────► 组织 Form
└─ 操作目录(code) ────► 权限内核(角色赋权页另行消费)
▲
│ 各自只声明,不持有文案
│ 各自只声明稳定编码并绑定 i18nKey
│
多语言 (i18nKey → 多语言值) ──► 菜单名 / 模块名 / 字段标题 的唯一文案源
(权限名不在内)
多语言 (i18nKey → 多语言值) ──► 菜单 / 模块 / 权限 / 字段 / 按钮 / 提示 / 校验的唯一文案源
菜单 = 功能树中 showInMenu=1 且角色有查看权限的节点投影
```
@@ -218,7 +222,7 @@ actions: [
| 设计项 | 原型位置 | 备注 |
| --- | --- | --- |
| 功能树四类节点 | `functionTree` | 已是响应式可编辑 |
| 模块基本信息/字段/分组 | `modules` | `M_MODULE/M_USER/M_BIZ/M_FEE` |
| 模块基本信息/字段/分组 | `modules` | `m_module/m_user/m_biz/m_fee` |
| 字段驱动列表/表单 | `字段配置` Tab | 列表/表单/必填开关 |
| 菜单投影 | `菜单预览` Tab | 按角色 + 语言渲染 |
| 多语言 | `多语言` Tab | Key + 三语,含缺失标红 |
@@ -228,8 +232,8 @@ actions: [
原型已按本文设计落地:
- **多语言仅覆盖菜单 / 模块 / 字段三类显示名**;`action.*` 条目已从 `i18nData` 彻底移除,权限项名不再进多语言(符合 §6)。
- **权限项仅含 `{code, name}`**,`name` 为管理员可读标签,不在多语言维护;新增权限项不再生成 i18nKey。
- **多语言覆盖所有界面文案**;权限名称使用 `power.*`,终端按钮使用独立的 `button.*`,两者不耦合。
- **权限项包含 `{code, name, i18nKey}`**;授权仍只认 `code`,`name` 作为默认中文回退。
- **字段多语言可在字段配置处点击设置**:标题单元格带多语言按钮,点击打开该字段的简体/English/繁體编辑框(i18nKey 形如 `field.<模块>.<字段>`)。
- **字段 `i18nKey` 统一为 `field.<模块>.<字段>`**,与模块/菜单 `<id>` 前缀、整体「命名空间.容器.标识」规则一致。
- 授权 `roleNodePerm` 仍为写死 mock,仅用于菜单预览示意,符合 §6.3 边界(角色赋权在另一页面,不在模块管理内)。
@@ -244,7 +248,7 @@ actions: [
| `s_module_field` | 字段配置(编码、标题键、类型、列表/表单/必填、分组) |
| `s_module_group` | 表单分组 |
| `s_function_node` | 功能树(目录/页面/分组/模块定位) |
| `s_i18n` | 多语言键值(key → 各语言值),覆盖菜单/模块/字段 |
| `s_i18n` | 统一多语言键值(key → 各语言值),覆盖所有界面文案 |
| 权限相关表 | 独立系统表,不属于 Table/Form 渲染配置 |
> 角色与权限内核使用独立系统表,不写入模块渲染配置;后端按 `module_id` + 操作 `code` 校验权限,不能只依赖前端隐藏。
@@ -7,8 +7,9 @@ BEGIN TRY
IF OBJECT_ID(N'dbo.b_module', N'U') IS NULL
BEGIN
CREATE TABLE dbo.b_module (
b_id varchar(50) NOT NULL,
b_parent_id varchar(50) NULL,
b_id bigint NOT NULL,
b_code varchar(50) NOT NULL,
b_parent_id bigint NULL,
b_name nvarchar(100) NOT NULL,
b_i18n varchar(150) NULL,
b_type varchar(20) NOT NULL,
@@ -16,20 +17,19 @@ BEGIN TRY
b_viewtable varchar(128) NULL,
b_savetable varchar(128) NULL,
b_keyfield varchar(50) NULL,
b_orderfield varchar(50) NULL,
b_ordertype varchar(4) NULL,
b_orderfield varchar(500) NULL,
b_canmenu int NOT NULL CONSTRAINT DF_b_module_canmenu DEFAULT (0),
b_canuse int NOT NULL CONSTRAINT DF_b_module_canuse DEFAULT (1),
b_xh int NOT NULL CONSTRAINT DF_b_module_xh DEFAULT (0),
CONSTRAINT PK_b_module PRIMARY KEY (b_id),
CONSTRAINT FK_b_module_parent FOREIGN KEY (b_parent_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT CK_b_module_id CHECK (
LEN(b_id) BETWEEN 2 AND 50
AND b_id LIKE '[A-Z]%'
AND b_id NOT LIKE '%[^A-Z0-9_]%'
CONSTRAINT UQ_b_module_code UNIQUE (b_code),
CONSTRAINT CK_b_module_code CHECK (
LEN(b_code) BETWEEN 2 AND 50
AND b_code COLLATE Latin1_General_100_BIN2 LIKE '[a-z]%'
AND b_code COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^a-z0-9_]%'
),
CONSTRAINT CK_b_module_type CHECK (b_type IN ('directory', 'page', 'data')),
CONSTRAINT CK_b_module_ordertype CHECK (b_ordertype IS NULL OR b_ordertype IN ('ASC', 'DESC')),
CONSTRAINT CK_b_module_canmenu CHECK (b_canmenu IN (0, 1)),
CONSTRAINT CK_b_module_canuse CHECK (b_canuse IN (0, 1)),
CONSTRAINT CK_b_module_directory CHECK (
@@ -49,7 +49,7 @@ BEGIN TRY
BEGIN
CREATE TABLE dbo.b_module_field (
b_id bigint NOT NULL,
b_module_id varchar(50) NOT NULL,
b_module_id bigint NOT NULL,
b_field varchar(50) NOT NULL,
b_name nvarchar(100) NOT NULL,
b_i18n varchar(150) NULL,
@@ -85,7 +85,7 @@ BEGIN TRY
BEGIN
CREATE TABLE dbo.b_module_power (
b_id bigint NOT NULL,
b_module_id varchar(50) NOT NULL,
b_module_id bigint NOT NULL,
b_code varchar(50) NOT NULL,
b_name nvarchar(100) NOT NULL,
b_i18n varchar(150) NULL,
@@ -106,7 +106,7 @@ BEGIN TRY
CREATE TABLE dbo.b_user_module (
b_id bigint NOT NULL,
b_user_id bigint NOT NULL,
b_module_id varchar(50) NOT NULL,
b_module_id bigint NOT NULL,
b_inputuser_id bigint NOT NULL,
b_inputdatetime datetime NOT NULL CONSTRAINT DF_b_user_module_inputdatetime DEFAULT (GETDATE()),
CONSTRAINT PK_b_user_module PRIMARY KEY (b_id),
@@ -160,13 +160,16 @@ BEGIN TRY
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.b_i18n') AND name = N'IX_b_i18n_key')
CREATE INDEX IX_b_i18n_key ON dbo.b_i18n (b_key, b_locale);
IF NOT EXISTS (SELECT 1 FROM dbo.b_module WHERE b_id = 'M_SYSTEM')
INSERT INTO dbo.b_module (b_id, b_parent_id, b_name, b_i18n, b_type, b_canmenu, b_canuse, b_xh)
VALUES ('M_SYSTEM', NULL, N'系统管理', 'module.M_SYSTEM', 'directory', 1, 1, 100);
DECLARE @SystemModuleId bigint = 205000000000000011;
DECLARE @ModuleManagementId bigint = 205000000000000012;
IF NOT EXISTS (SELECT 1 FROM dbo.b_module WHERE b_id = 'M_MODULE')
INSERT INTO dbo.b_module (b_id, b_parent_id, b_name, b_i18n, b_type, b_route, b_canmenu, b_canuse, b_xh)
VALUES ('M_MODULE', 'M_SYSTEM', N'模块管理', 'module.M_MODULE', 'page', '/system/modules', 1, 1, 10);
IF NOT EXISTS (SELECT 1 FROM dbo.b_module WHERE b_code = 'm_system')
INSERT INTO dbo.b_module (b_id, b_code, b_parent_id, b_name, b_i18n, b_type, b_canmenu, b_canuse, b_xh)
VALUES (@SystemModuleId, 'm_system', NULL, N'系统管理', 'module.m_system', 'directory', 1, 1, 100);
IF NOT EXISTS (SELECT 1 FROM dbo.b_module WHERE b_code = 'm_module')
INSERT INTO dbo.b_module (b_id, b_code, b_parent_id, b_name, b_i18n, b_type, b_route, b_canmenu, b_canuse, b_xh)
VALUES (@ModuleManagementId, 'm_module', @SystemModuleId, N'模块管理', 'module.m_module', 'page', '/system/modules', 1, 1, 10);
DECLARE @Powers TABLE (b_id bigint, b_code varchar(50), b_name nvarchar(100), b_xh int);
INSERT INTO @Powers VALUES
@@ -176,11 +179,11 @@ BEGIN TRY
(205000000000000004, 'grant', N'用户授权', 40);
INSERT INTO dbo.b_module_power (b_id, b_module_id, b_code, b_name, b_i18n, b_xh, b_canuse)
SELECT p.b_id, 'M_MODULE', p.b_code, p.b_name, 'power.M_MODULE.' + p.b_code, p.b_xh, 1
SELECT p.b_id, @ModuleManagementId, p.b_code, p.b_name, 'power.m_module.' + p.b_code, p.b_xh, 1
FROM @Powers p
WHERE NOT EXISTS (
SELECT 1 FROM dbo.b_module_power target
WHERE target.b_module_id = 'M_MODULE' AND target.b_code = p.b_code
WHERE target.b_module_id = @ModuleManagementId AND target.b_code = p.b_code
);
EXEC sys.sp_set_session_context @key = N'allow_g3soft', @value = 1;
@@ -188,14 +191,14 @@ BEGIN TRY
IF @AdminId IS NULL
THROW 51000, '未找到已启用的管理员账号 g3soft,模块管理初始化已取消。', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.b_user_module WHERE b_user_id = @AdminId AND b_module_id = 'M_MODULE')
IF NOT EXISTS (SELECT 1 FROM dbo.b_user_module WHERE b_user_id = @AdminId AND b_module_id = @ModuleManagementId)
INSERT INTO dbo.b_user_module (b_id, b_user_id, b_module_id, b_inputuser_id, b_inputdatetime)
VALUES (205000000000000101, @AdminId, 'M_MODULE', @AdminId, GETDATE());
VALUES (205000000000000101, @AdminId, @ModuleManagementId, @AdminId, GETDATE());
INSERT INTO dbo.b_user_power (b_id, b_user_id, b_power_id, b_inputuser_id, b_inputdatetime)
SELECT 205000000000000200 + ROW_NUMBER() OVER (ORDER BY p.b_id), @AdminId, p.b_id, @AdminId, GETDATE()
FROM dbo.b_module_power p
WHERE p.b_module_id = 'M_MODULE'
WHERE p.b_module_id = @ModuleManagementId
AND p.b_code IN ('create', 'update', 'delete', 'grant')
AND NOT EXISTS (
SELECT 1 FROM dbo.b_user_power target
@@ -0,0 +1,44 @@
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
IF OBJECT_ID(N'dbo.b_module', N'U') IS NOT NULL
AND COL_LENGTH(N'dbo.b_module', N'b_orderfield') IS NOT NULL
BEGIN
EXEC sys.sp_executesql N'
ALTER TABLE dbo.b_module ALTER COLUMN b_orderfield varchar(500) NULL;
';
END;
IF OBJECT_ID(N'dbo.b_module', N'U') IS NOT NULL
AND COL_LENGTH(N'dbo.b_module', N'b_ordertype') IS NOT NULL
BEGIN
EXEC sys.sp_executesql N'
UPDATE dbo.b_module
SET b_orderfield =
CASE
WHEN NULLIF(LTRIM(RTRIM(b_orderfield)), '''') IS NULL THEN NULL
WHEN UPPER(RIGHT(LTRIM(RTRIM(b_orderfield)), 4)) = '' ASC''
OR UPPER(RIGHT(LTRIM(RTRIM(b_orderfield)), 5)) = '' DESC''
THEN LTRIM(RTRIM(b_orderfield))
WHEN UPPER(LTRIM(RTRIM(b_ordertype))) IN (''ASC'', ''DESC'')
THEN LTRIM(RTRIM(b_orderfield)) + '' '' + UPPER(LTRIM(RTRIM(b_ordertype)))
ELSE LTRIM(RTRIM(b_orderfield))
END;
';
IF OBJECT_ID(N'dbo.CK_b_module_ordertype', N'C') IS NOT NULL
ALTER TABLE dbo.b_module DROP CONSTRAINT CK_b_module_ordertype;
ALTER TABLE dbo.b_module DROP COLUMN b_ordertype;
END;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
THROW;
END CATCH;
@@ -0,0 +1,130 @@
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
IF EXISTS (
SELECT 1
FROM sys.columns c
JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE c.object_id = OBJECT_ID(N'dbo.b_module')
AND c.name = N'b_id'
AND t.name <> N'bigint'
)
BEGIN
CREATE TABLE #ModuleIdMap (
old_id varchar(50) NOT NULL PRIMARY KEY,
new_id bigint NOT NULL UNIQUE
);
INSERT INTO #ModuleIdMap (old_id, new_id)
SELECT
b_id,
CASE b_id
WHEN 'M_SYSTEM' THEN 205000000000000011
WHEN 'M_MODULE' THEN 205000000000000012
ELSE 206000000000000000 + ROW_NUMBER() OVER (ORDER BY b_id)
END
FROM dbo.b_module;
ALTER TABLE dbo.b_module ADD
b_new_id bigint NULL,
b_code varchar(50) NULL,
b_new_parent_id bigint NULL;
ALTER TABLE dbo.b_module_field ADD b_new_module_id bigint NULL;
ALTER TABLE dbo.b_module_power ADD b_new_module_id bigint NULL;
ALTER TABLE dbo.b_user_module ADD b_new_module_id bigint NULL;
EXEC sys.sp_executesql N'
UPDATE target
SET
b_new_id = id_map.new_id,
b_code = LOWER(target.b_id),
b_new_parent_id = parent_map.new_id
FROM dbo.b_module target
JOIN #ModuleIdMap id_map ON id_map.old_id = target.b_id
LEFT JOIN #ModuleIdMap parent_map ON parent_map.old_id = target.b_parent_id;
UPDATE target
SET b_new_module_id = id_map.new_id
FROM dbo.b_module_field target
JOIN #ModuleIdMap id_map ON id_map.old_id = target.b_module_id;
UPDATE target
SET b_new_module_id = id_map.new_id
FROM dbo.b_module_power target
JOIN #ModuleIdMap id_map ON id_map.old_id = target.b_module_id;
UPDATE target
SET b_new_module_id = id_map.new_id
FROM dbo.b_user_module target
JOIN #ModuleIdMap id_map ON id_map.old_id = target.b_module_id;
ALTER TABLE dbo.b_module ALTER COLUMN b_new_id bigint NOT NULL;
ALTER TABLE dbo.b_module ALTER COLUMN b_code varchar(50) NOT NULL;
ALTER TABLE dbo.b_module_field ALTER COLUMN b_new_module_id bigint NOT NULL;
ALTER TABLE dbo.b_module_power ALTER COLUMN b_new_module_id bigint NOT NULL;
ALTER TABLE dbo.b_user_module ALTER COLUMN b_new_module_id bigint NOT NULL;
';
ALTER TABLE dbo.b_module DROP CONSTRAINT FK_b_module_parent;
ALTER TABLE dbo.b_module_field DROP CONSTRAINT FK_b_module_field_module;
ALTER TABLE dbo.b_module_power DROP CONSTRAINT FK_b_module_power_module;
ALTER TABLE dbo.b_user_module DROP CONSTRAINT FK_b_user_module_module;
ALTER TABLE dbo.b_module_field DROP CONSTRAINT UQ_b_module_field;
ALTER TABLE dbo.b_module_power DROP CONSTRAINT UQ_b_module_power;
ALTER TABLE dbo.b_user_module DROP CONSTRAINT UQ_b_user_module;
ALTER TABLE dbo.b_module DROP CONSTRAINT CK_b_module_id;
ALTER TABLE dbo.b_module DROP CONSTRAINT PK_b_module;
DROP INDEX IX_b_module_parent ON dbo.b_module;
DROP INDEX IX_b_module_field_module ON dbo.b_module_field;
DROP INDEX IX_b_module_power_module ON dbo.b_module_power;
DROP INDEX IX_b_user_module_module ON dbo.b_user_module;
ALTER TABLE dbo.b_module_field DROP COLUMN b_module_id;
ALTER TABLE dbo.b_module_power DROP COLUMN b_module_id;
ALTER TABLE dbo.b_user_module DROP COLUMN b_module_id;
ALTER TABLE dbo.b_module DROP COLUMN b_parent_id, b_id;
EXEC sys.sp_rename N'dbo.b_module.b_new_id', N'b_id', N'COLUMN';
EXEC sys.sp_rename N'dbo.b_module.b_new_parent_id', N'b_parent_id', N'COLUMN';
EXEC sys.sp_rename N'dbo.b_module_field.b_new_module_id', N'b_module_id', N'COLUMN';
EXEC sys.sp_rename N'dbo.b_module_power.b_new_module_id', N'b_module_id', N'COLUMN';
EXEC sys.sp_rename N'dbo.b_user_module.b_new_module_id', N'b_module_id', N'COLUMN';
EXEC sys.sp_executesql N'
ALTER TABLE dbo.b_module ADD
CONSTRAINT PK_b_module PRIMARY KEY (b_id),
CONSTRAINT FK_b_module_parent FOREIGN KEY (b_parent_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT UQ_b_module_code UNIQUE (b_code),
CONSTRAINT CK_b_module_code CHECK (
LEN(b_code) BETWEEN 2 AND 50
AND b_code COLLATE Latin1_General_100_BIN2 LIKE ''[a-z]%''
AND b_code COLLATE Latin1_General_100_BIN2 NOT LIKE ''%[^a-z0-9_]%''
);
ALTER TABLE dbo.b_module_field ADD
CONSTRAINT FK_b_module_field_module FOREIGN KEY (b_module_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT UQ_b_module_field UNIQUE (b_module_id, b_field);
ALTER TABLE dbo.b_module_power ADD
CONSTRAINT FK_b_module_power_module FOREIGN KEY (b_module_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT UQ_b_module_power UNIQUE (b_module_id, b_code);
ALTER TABLE dbo.b_user_module ADD
CONSTRAINT FK_b_user_module_module FOREIGN KEY (b_module_id) REFERENCES dbo.b_module (b_id),
CONSTRAINT UQ_b_user_module UNIQUE (b_user_id, b_module_id);
CREATE INDEX IX_b_module_parent ON dbo.b_module (b_parent_id, b_xh, b_id);
CREATE INDEX IX_b_module_field_module ON dbo.b_module_field (b_module_id, b_xh, b_id);
CREATE INDEX IX_b_module_power_module ON dbo.b_module_power (b_module_id, b_xh, b_id);
CREATE INDEX IX_b_user_module_module ON dbo.b_user_module (b_module_id, b_user_id);
';
END;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
THROW;
END CATCH;
@@ -0,0 +1,137 @@
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @SystemModuleId bigint = (SELECT TOP (1) b_id FROM dbo.b_module WHERE b_code = 'm_system');
DECLARE @I18nModuleId bigint = 205000000000000013;
IF @SystemModuleId IS NULL
THROW 51000, '未找到系统管理模块 m_system,多语言管理初始化已取消。', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.b_module WHERE b_code = 'm_i18n')
INSERT INTO dbo.b_module (
b_id, b_code, b_parent_id, b_name, b_i18n, b_type, b_route,
b_canmenu, b_canuse, b_xh
)
VALUES (
@I18nModuleId, 'm_i18n', @SystemModuleId, N'多语言管理', 'module.m_i18n',
'page', '/system/i18n', 1, 1, 20
);
ELSE
SET @I18nModuleId = (SELECT TOP (1) b_id FROM dbo.b_module WHERE b_code = 'm_i18n');
DECLARE @Powers TABLE (
b_id bigint,
b_code varchar(50),
b_name nvarchar(100),
b_i18n varchar(150),
b_xh int
);
INSERT INTO @Powers VALUES
(205000000000000005, 'create', N'新增', 'power.m_i18n.create', 10),
(205000000000000006, 'update', N'修改', 'power.m_i18n.update', 20),
(205000000000000007, 'delete', N'删除', 'power.m_i18n.delete', 30);
INSERT INTO dbo.b_module_power (b_id, b_module_id, b_code, b_name, b_i18n, b_xh, b_canuse)
SELECT source.b_id, @I18nModuleId, source.b_code, source.b_name, source.b_i18n, source.b_xh, 1
FROM @Powers source
WHERE NOT EXISTS (
SELECT 1
FROM dbo.b_module_power target
WHERE target.b_module_id = @I18nModuleId
AND target.b_code = source.b_code
);
EXEC sys.sp_set_session_context @key = N'allow_g3soft', @value = 1;
DECLARE @AdminId bigint = (
SELECT TOP (1) b_id
FROM dbo.b_user
WHERE b_account = N'g3soft'
AND b_canuse = 1
);
IF @AdminId IS NULL
THROW 51000, '未找到已启用的管理员账号 g3soft,多语言管理初始化已取消。', 1;
IF NOT EXISTS (
SELECT 1
FROM dbo.b_user_module
WHERE b_user_id = @AdminId
AND b_module_id = @I18nModuleId
)
INSERT INTO dbo.b_user_module (
b_id, b_user_id, b_module_id, b_inputuser_id, b_inputdatetime
)
VALUES (
205000000000000102, @AdminId, @I18nModuleId, @AdminId, GETDATE()
);
INSERT INTO dbo.b_user_power (b_id, b_user_id, b_power_id, b_inputuser_id, b_inputdatetime)
SELECT
CASE power.b_code
WHEN 'create' THEN 205000000000000211
WHEN 'update' THEN 205000000000000212
WHEN 'delete' THEN 205000000000000213
END,
@AdminId,
power.b_id,
@AdminId,
GETDATE()
FROM dbo.b_module_power power
WHERE power.b_module_id = @I18nModuleId
AND power.b_code IN ('create', 'update', 'delete')
AND NOT EXISTS (
SELECT 1
FROM dbo.b_user_power target
WHERE target.b_user_id = @AdminId
AND target.b_power_id = power.b_id
);
DECLARE @Translations TABLE (
b_id bigint,
b_key varchar(150),
b_locale varchar(20),
b_value nvarchar(500)
);
INSERT INTO @Translations VALUES
(205000000000000301, 'module.m_i18n', 'zh-CN', N'多语言管理'),
(205000000000000302, 'module.m_i18n', 'en-US', N'Language Resources'),
(205000000000000303, 'module.m_i18n', 'zh-TW', N'多語言管理'),
(205000000000000304, 'power.m_i18n.create', 'zh-CN', N'新增'),
(205000000000000305, 'power.m_i18n.create', 'en-US', N'Create'),
(205000000000000306, 'power.m_i18n.create', 'zh-TW', N'新增'),
(205000000000000307, 'power.m_i18n.update', 'zh-CN', N'修改'),
(205000000000000308, 'power.m_i18n.update', 'en-US', N'Update'),
(205000000000000309, 'power.m_i18n.update', 'zh-TW', N'修改'),
(205000000000000310, 'power.m_i18n.delete', 'zh-CN', N'删除'),
(205000000000000311, 'power.m_i18n.delete', 'en-US', N'Delete'),
(205000000000000312, 'power.m_i18n.delete', 'zh-TW', N'刪除');
INSERT INTO dbo.b_i18n (
b_id, b_key, b_locale, b_value, b_inputuser_id, b_inputdatetime,
b_updateuser_id, b_updatedatetime
)
SELECT
source.b_id, source.b_key, source.b_locale, source.b_value,
@AdminId, GETDATE(), NULL, NULL
FROM @Translations source
WHERE NOT EXISTS (
SELECT 1
FROM dbo.b_i18n target
WHERE target.b_key = source.b_key
AND target.b_locale = source.b_locale
);
EXEC sys.sp_set_session_context @key = N'allow_g3soft', @value = NULL;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
BEGIN TRY
EXEC sys.sp_set_session_context @key = N'allow_g3soft', @value = NULL;
END TRY
BEGIN CATCH
END CATCH;
THROW;
END CATCH;
+187 -2
View File
@@ -1,21 +1,200 @@
<script setup>
import { computed, nextTick, ref, useSlots } from "vue";
import { computed, defineComponent, h, nextTick, ref, useSlots } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { Checkbox, Input, InputNumber, Select } from "antdv-next";
import { StkTable } from "stk-table-vue";
defineOptions({ inheritAttrs: false });
defineProps({
const props = defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
colResizable: { type: Boolean, default: true },
bordered: { type: [Boolean, String], default: true },
noDataFull: { type: Boolean, default: true },
columns: { type: Array, default: () => [] },
dataSource: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: "id" },
rowSelection: { type: Object, default: null },
});
const emit = defineEmits(["update:columns"]);
function getRowKey(row) {
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}
const selectedRowKeySet = computed(
() => new Set(props.rowSelection?.selectedRowKeys || []),
);
function changeSelection(row, checked) {
const key = getRowKey(row);
const nextKeys = new Set(selectedRowKeySet.value);
if (checked) nextKeys.add(key);
else nextKeys.delete(key);
const keys = Array.from(nextKeys);
const rows = props.dataSource.filter((item) => nextKeys.has(getRowKey(item)));
props.rowSelection?.onChange?.(keys, rows);
}
function changeAllSelection(checked) {
const visibleKeys = props.dataSource.map(getRowKey);
const nextKeys = new Set(selectedRowKeySet.value);
visibleKeys.forEach((key) => {
if (checked) nextKeys.add(key);
else nextKeys.delete(key);
});
const keys = Array.from(nextKeys);
const rows = props.dataSource.filter((item) => nextKeys.has(getRowKey(item)));
props.rowSelection?.onChange?.(keys, rows);
}
const SelectionCell = defineComponent({
props: ["row"],
setup(cellProps) {
return () =>
h(Checkbox, {
checked: selectedRowKeySet.value.has(getRowKey(cellProps.row)),
"onUpdate:checked": (checked) => changeSelection(cellProps.row, checked),
onClick: (event) => event.stopPropagation(),
});
},
});
const SelectionHeaderCell = defineComponent({
setup() {
return () => {
const visibleKeys = props.dataSource.map(getRowKey);
const selectedCount = visibleKeys.filter((key) => selectedRowKeySet.value.has(key)).length;
return h(Checkbox, {
checked: visibleKeys.length > 0 && selectedCount === visibleKeys.length,
indeterminate: selectedCount > 0 && selectedCount < visibleKeys.length,
"onUpdate:checked": changeAllSelection,
onClick: (event) => event.stopPropagation(),
});
};
},
});
const EditableCell = defineComponent({
props: ["row", "col", "cellValue"],
setup(cellProps) {
function editorOptions() {
return typeof cellProps.col.editor === "string"
? { type: cellProps.col.editor }
: cellProps.col.editor || {};
}
function updateValue(value) {
cellProps.row[cellProps.col.dataIndex] = value;
editorOptions().onChange?.(value, cellProps.row, cellProps.col);
}
return () => {
const editor = editorOptions();
const disabled =
typeof editor.disabled === "function" ? editor.disabled(cellProps.row) : editor.disabled;
const commonProps = {
size: "small",
disabled,
style: { width: "100%" },
};
if (editor.type === "number") {
return h(InputNumber, {
...commonProps,
value: cellProps.row[cellProps.col.dataIndex],
min: editor.min,
max: editor.max,
precision: editor.precision,
"onUpdate:value": updateValue,
});
}
if (editor.type === "select") {
return h(Select, {
...commonProps,
value: cellProps.row[cellProps.col.dataIndex],
options: editor.options || [],
"onUpdate:value": updateValue,
});
}
if (editor.type === "checkbox") {
const checkedValue = editor.checkedValue ?? 1;
const uncheckedValue = editor.uncheckedValue ?? 0;
return h(Checkbox, {
disabled,
checked: cellProps.row[cellProps.col.dataIndex] === checkedValue,
"onUpdate:checked": (checked) => updateValue(checked ? checkedValue : uncheckedValue),
onClick: (event) => event.stopPropagation(),
});
}
return h(Input, {
...commonProps,
value: cellProps.row[cellProps.col.dataIndex],
allowClear: editor.allowClear ?? true,
maxlength: editor.maxlength,
placeholder:
typeof editor.placeholder === "function"
? editor.placeholder(cellProps.row)
: editor.placeholder,
status: typeof editor.status === "function" ? editor.status(cellProps.row) : editor.status,
"onUpdate:value": updateValue,
});
};
},
});
// StkTable 的编辑单元格和多选列统一在封装层生成,业务页面只声明 editor / rowSelection。
const tableColumnsModel = computed({
get: () => {
const columns = props.columns.map((column) =>
column.editor && !column.customCell
? { ...column, customCell: EditableCell, __fmsEditor: true }
: column,
);
if (!props.rowSelection) return columns;
return [
{
dataIndex: "__fmsSelection",
key: "__fmsSelection",
title: "",
width: 44,
fixed: "left",
align: "center",
headerAlign: "center",
customCell: SelectionCell,
customHeaderCell: SelectionHeaderCell,
__fmsSelection: true,
},
...columns,
];
},
set: (value) =>
emit(
"update:columns",
value
.filter((column) => !column.__fmsSelection)
.map((column) => {
if (!column.__fmsEditor) return column;
const rest = { ...column };
delete rest.customCell;
delete rest.__fmsEditor;
return rest;
}),
),
});
const slots = useSlots();
const tableRef = ref(null);
// 表格样式
const customVars = ref({
"--header-row-height": "40px",
"--row-height": "34px",
"--footer-row-height": "36px",
});
const { resume: refreshLayout } = useRafFn(
async () => {
await nextTick();
@@ -49,10 +228,14 @@ const forwardSlots = computed(() => {
ref="tableRef"
class="fms-table"
v-bind="$attrs"
:style="customVars"
:auto-resize="false"
:virtual="virtual"
:virtual-x="virtualX"
:col-resizable="colResizable"
v-model:columns="tableColumnsModel"
:data-source="dataSource"
:row-key="rowKey"
:bordered="bordered"
:no-data-full="noDataFull"
>
@@ -68,6 +251,8 @@ const forwardSlots = computed(() => {
</StkTable>
</template>
<style scoped>
.fms-table {
width: 100%;
@@ -66,7 +66,7 @@ export function useLookupOptions(props) {
}
const { start: startSearchTimer, stop: stopSearchTimer } = useTimeoutFn(
() => load(query.value),
(searchText) => load(searchText),
() => props.searchDelay,
{ immediate: false },
);
@@ -76,8 +76,11 @@ export function useLookupOptions(props) {
function search(value) {
stopResetTimer();
query.value = String(value ?? "");
startSearchTimer();
const searchText = String(value ?? "");
query.value = searchText;
requestId += 1;
loading.value = false;
startSearchTimer(searchText);
}
function open() {
@@ -142,6 +142,7 @@ const fallbackMenuItems = [
{ key: "/system/mappingConfig", label: "映射配置" },
{ key: "/system/autoId", label: "自动编码" },
{ key: "/system/modules", label: "模块管理" },
{ key: "/system/i18n", label: "多语言管理" },
],
},
{
+1 -41
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
import NProgress from "nprogress";
import { message } from "antdv-next";
import "nprogress/nprogress.css";
import routes from "./routes";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
@@ -13,47 +14,6 @@ function getRedirectPath(value) {
: "/dashboard";
}
const routes = [
{
path: "/",
component: () => import("@/layouts/DefaultLayout.vue"),
meta: { requiresAuth: true },
children: [
{
path: "",
redirect: "/dashboard",
},
{
path: "dashboard",
name: "dashboard",
component: () => import("@/views/DashboardView.vue"),
meta: { title: "工作台", menuKey: "/dashboard" },
},
{
path: "system/modules",
name: "system-modules",
component: () => import("@/views/module-management/ModuleManagementView.vue"),
meta: {
title: "模块管理",
section: "系统管理",
menuKey: "/system/modules",
moduleCode: "M_MODULE",
},
},
],
},
{
path: "/login",
name: "login",
component: () => import("@/views/LoginView.vue"),
meta: { title: "登录" },
},
{
path: "/:pathMatch(.*)*",
redirect: "/dashboard",
},
];
const router = createRouter({
history: createWebHistory(),
routes,
+53
View File
@@ -0,0 +1,53 @@
const routes = [
{
path: "/",
component: () => import("@/layouts/DefaultLayout.vue"),
meta: { requiresAuth: true },
children: [
{
path: "",
redirect: "/dashboard",
},
{
path: "dashboard",
name: "dashboard",
component: () => import("@/views/dashboard/index.vue"),
meta: { title: "工作台", menuKey: "/dashboard" },
},
{
path: "system/modules",
name: "system-modules",
component: () => import("@/views/module-management/index.vue"),
meta: {
title: "模块管理",
section: "系统管理",
menuKey: "/system/modules",
moduleCode: "m_module",
},
},
{
path: "system/i18n",
name: "system-i18n",
component: () => import("@/views/i18n-management/index.vue"),
meta: {
title: "多语言管理",
section: "系统管理",
menuKey: "/system/i18n",
moduleCode: "m_i18n",
},
},
],
},
{
path: "/login",
name: "login",
component: () => import("@/views/login/index.vue"),
meta: { title: "登录" },
},
{
path: "/:pathMatch(.*)*",
redirect: "/dashboard",
},
];
export default routes;
+29 -6
View File
@@ -11,26 +11,46 @@ export const usePermissionStore = defineStore("permissions", () => {
const moduleSet = computed(() => new Set(moduleCodes.value));
const powerSet = computed(() => new Set(powerCodes.value));
const isG3soft = computed(() => {
const authStore = useAuthStore();
return String(authStore.user?.account || "").trim().toLowerCase() === "g3soft";
});
async function load() {
const authStore = useAuthStore();
const userId = String(authStore.user?.id || "");
if (isG3soft.value) {
loadedUserId.value = `g3soft:${userId}`;
moduleCodes.value = [];
powerCodes.value = [];
return;
}
if (!/^\d+$/.test(userId)) throw new Error("登录用户 ID 无效,请重新登录");
if (loadedUserId.value === userId) return;
if (loadingPromise.value) return loadingPromise.value;
loadingPromise.value = Promise.all([
loadDataApi("b_module", "b_canuse = 1", "b_id ASC"),
loadDataApi("b_user_module", `b_user_id = ${userId}`, "b_id ASC"),
loadDataApi("b_user_power", `b_user_id = ${userId}`, "b_id ASC"),
loadDataApi("b_module_power", "b_canuse = 1", "b_id ASC"),
])
.then(([userModules, userPowers, powers]) => {
.then(([modules, userModules, userPowers, powers]) => {
const moduleCodeById = new Map(
modules.data.map((module) => [String(module.b_id), module.b_code]),
);
const powerById = new Map(powers.data.map((power) => [String(power.b_id), power]));
moduleCodes.value = userModules.data.map((item) => item.b_module_id);
moduleCodes.value = userModules.data
.map((item) => moduleCodeById.get(String(item.b_module_id)))
.filter(Boolean);
powerCodes.value = userPowers.data
.map((item) => powerById.get(String(item.b_power_id)))
.filter(Boolean)
.map((power) => `${power.b_module_id}.${power.b_code}`);
.map((power) => {
const moduleCode = moduleCodeById.get(String(power.b_module_id));
return moduleCode ? `${moduleCode}.${power.b_code}` : "";
})
.filter(Boolean);
loadedUserId.value = userId;
})
.finally(() => {
@@ -40,11 +60,14 @@ export const usePermissionStore = defineStore("permissions", () => {
}
function canModule(moduleCode) {
return !moduleCode || moduleSet.value.has(moduleCode);
return isG3soft.value || !moduleCode || moduleSet.value.has(moduleCode);
}
function canPower(moduleCode, powerCode) {
return canModule(moduleCode) && powerSet.value.has(`${moduleCode}.${powerCode}`);
return (
isG3soft.value ||
(canModule(moduleCode) && powerSet.value.has(`${moduleCode}.${powerCode}`))
);
}
function clear() {
@@ -53,5 +76,5 @@ export const usePermissionStore = defineStore("permissions", () => {
powerCodes.value = [];
}
return { load, canModule, canPower, clear, moduleCodes, powerCodes };
return { load, canModule, canPower, clear, moduleCodes, powerCodes, isG3soft };
});
+10 -12
View File
@@ -1,3 +1,6 @@
/* ============================================================
* 基础重置 / Base Reset
* ============================================================ */
:root {
color: #1f2329;
background: #f5f7fa;
@@ -39,6 +42,9 @@ a {
text-decoration: none;
}
/* ============================================================
* 图标 / Icons
* ============================================================ */
.lucide {
width: 16px;
height: 16px;
@@ -47,6 +53,9 @@ a {
vertical-align: -0.125em;
}
/* ============================================================
* 主题变量 / Theme Tokens
* ============================================================ */
.theme-root {
--fms-bg: #f5f7fa;
--fms-surface: #ffffff;
@@ -59,15 +68,4 @@ a {
color: var(--fms-text);
background: var(--fms-bg);
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
export const I18N_LOCALES = [
{ value: "zh-CN", label: "简体中文", shortLabel: "简体" },
{ value: "en-US", label: "English", shortLabel: "English" },
{ value: "zh-TW", label: "繁體中文", shortLabel: "繁體" },
];
export const I18N_LOCALE_CODES = I18N_LOCALES.map((item) => item.value);
export function getI18nNamespace(key) {
return String(key || "").split(".", 1)[0] || "common";
}
export function isValidI18nKey(key) {
return /^[A-Za-z][A-Za-z0-9_.:-]{1,149}$/.test(String(key || ""));
}
export function groupI18nRows(rows) {
const groups = {};
rows.forEach((item) => {
const key = item.b_key;
groups[key] ||= { key, values: {}, records: {} };
groups[key].values[item.b_locale] = item.b_value;
groups[key].records[item.b_locale] = item;
});
return groups;
}
@@ -0,0 +1,533 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { App } from "antdv-next";
import { Languages, Plus, RefreshCw, RotateCcw, Save, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
import {
I18N_LOCALES,
I18N_LOCALE_CODES,
getI18nNamespace,
groupI18nRows,
isValidI18nKey,
} from "@/utils/i18n";
const { message } = App.useApp();
const route = useRoute();
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
const loading = ref(false);
const saving = ref(false);
const original = ref("");
const groups = ref({});
const selectedRowKeys = ref([]);
const keyModuleCodes = ref(new Map());
const data = reactive({ modules: [], fields: [], powers: [], translations: [] });
const filters = reactive({
keyword: "",
namespace: undefined,
moduleCode: undefined,
locale: undefined,
status: "all",
});
const createDialog = reactive({ open: false, key: "", values: {} });
const deleteDialogOpen = ref(false);
const canCreate = computed(() => permissionStore.canPower("m_i18n", "create"));
const canUpdate = computed(() => permissionStore.canPower("m_i18n", "update"));
const canDelete = computed(() => permissionStore.canPower("m_i18n", "delete"));
const dirty = computed(() => serializeGroups(groups.value) !== original.value);
const moduleOptions = computed(() =>
data.modules.map((item) => ({
value: item.b_code,
label: item.b_name + " (" + item.b_code + ")",
})),
);
const allRows = computed(() =>
Object.values(groups.value)
.map((group) => {
const missingLocales = I18N_LOCALE_CODES.filter((locale) => !group.values[locale]?.trim());
return {
...group,
...Object.fromEntries(
I18N_LOCALE_CODES.map((locale) => [locale, group.values[locale] || ""]),
),
namespace: getI18nNamespace(group.key),
moduleCodes: Array.from(keyModuleCodes.value.get(group.key) || inferModuleCodes(group.key)),
moduleCodesText:
Array.from(keyModuleCodes.value.get(group.key) || inferModuleCodes(group.key)).join(
"、",
) || "通用 / 未绑定",
missingLocales,
statusText: missingLocales.length ? `缺 ${missingLocales.length}` : "完整",
};
})
.sort((left, right) => left.key.localeCompare(right.key)),
);
const namespaceOptions = computed(() =>
Array.from(new Set(allRows.value.map((item) => item.namespace)))
.sort()
.map((value) => ({ value, label: value })),
);
const filteredRows = computed(() => {
const keyword = filters.keyword.trim().toLowerCase();
return allRows.value.filter((row) => {
if (keyword) {
const searchText = [row.key, ...Object.values(row.values)].join(" ").toLowerCase();
if (!searchText.includes(keyword)) return false;
}
if (filters.namespace && row.namespace !== filters.namespace) return false;
if (filters.moduleCode && !row.moduleCodes.includes(filters.moduleCode)) return false;
const focusedLocales = filters.locale ? [filters.locale] : I18N_LOCALE_CODES;
const missingCount = focusedLocales.filter((locale) => !row.values[locale]?.trim()).length;
if (filters.status === "missing" && missingCount === 0) return false;
if (filters.status === "complete" && missingCount > 0) return false;
return true;
});
});
const visibleLocales = computed(() =>
filters.locale ? I18N_LOCALES.filter((item) => item.value === filters.locale) : I18N_LOCALES,
);
const columns = computed(() => [
{ title: "多语言键", dataIndex: "key", key: "key", width: 280, fixed: "left" },
{ title: "命名空间", dataIndex: "namespace", key: "namespace", width: 110 },
{ title: "关联模块", dataIndex: "moduleCodesText", key: "moduleCodes", width: 190 },
...visibleLocales.value.map((locale) => ({
title: locale.label,
dataIndex: locale.value,
key: locale.value,
locale: locale.value,
width: 250,
editor: {
maxlength: 500,
disabled: (row) => !canEditRow(row),
placeholder: `填写 ${locale.value} 文案`,
status: (row) => (row[locale.value]?.trim() ? undefined : "warning"),
onChange: (value, row) => {
row.values[locale.value] = value;
},
},
})),
{ title: "状态", dataIndex: "statusText", key: "status", width: 100, fixed: "right" },
]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
function serializeGroups(value) {
return JSON.stringify(
Object.values(value)
.map((item) => ({
key: item.key,
values: Object.fromEntries(
I18N_LOCALE_CODES.map((locale) => [locale, item.values[locale] || ""]),
),
}))
.sort((left, right) => left.key.localeCompare(right.key)),
);
}
function inferModuleCodes(key) {
const parts = String(key).split(".");
return new Set(data.modules.map((item) => item.b_code).filter((code) => parts.includes(code)));
}
function rebuildModuleReferences() {
const moduleById = new Map(data.modules.map((item) => [String(item.b_id), item.b_code]));
const result = new Map();
const bind = (key, moduleCode) => {
if (!key || !moduleCode) return;
if (!result.has(key)) result.set(key, new Set());
result.get(key).add(moduleCode);
};
data.modules.forEach((item) => bind(item.b_i18n, item.b_code));
data.fields.forEach((item) => bind(item.b_i18n, moduleById.get(String(item.b_module_id))));
data.powers.forEach((item) => bind(item.b_i18n, moduleById.get(String(item.b_module_id))));
keyModuleCodes.value = result;
}
function resetDraft() {
const nextGroups = groupI18nRows(data.translations);
[...data.modules, ...data.fields, ...data.powers].forEach((item) => {
if (item.b_i18n) {
nextGroups[item.b_i18n] ||= { key: item.b_i18n, values: {}, records: {} };
}
});
groups.value = nextGroups;
original.value = serializeGroups(groups.value);
selectedRowKeys.value = [];
}
async function loadAll() {
loading.value = true;
try {
const [modules, fields, powers, translations] = await Promise.all([
loadDataApi("b_module", null, "b_xh ASC"),
loadDataApi("b_module_field", null, "b_xh ASC"),
loadDataApi("b_module_power", null, "b_xh ASC"),
loadDataApi("b_i18n", null, "b_key ASC"),
]);
Object.assign(data, {
modules: modules.data,
fields: fields.data,
powers: powers.data,
translations: translations.data,
});
rebuildModuleReferences();
resetDraft();
if (typeof route.query.module === "string") filters.moduleCode = route.query.module;
} catch (error) {
message.error(error.message || "多语言资源加载失败");
} finally {
loading.value = false;
}
}
function openCreateDialog() {
const modulePrefix = filters.moduleCode ? "." + filters.moduleCode + "." : ".";
createDialog.key = (filters.namespace || "common") + modulePrefix;
createDialog.values = Object.fromEntries(I18N_LOCALE_CODES.map((locale) => [locale, ""]));
createDialog.open = true;
}
function addKey() {
const key = createDialog.key.trim();
if (!isValidI18nKey(key)) {
message.warning("多语言键需以字母开头,只能包含字母、数字、点、冒号、短横线和下划线");
return;
}
if (groups.value[key]) {
message.warning("多语言键已经存在");
return;
}
if (!I18N_LOCALE_CODES.some((locale) => createDialog.values[locale]?.trim())) {
message.warning("请至少填写一种语言的文案");
return;
}
groups.value = {
...groups.value,
[key]: { key, values: { ...createDialog.values }, records: {} },
};
createDialog.open = false;
}
function requestDeleteSelected() {
if (!selectedRowKeys.value.length) return;
deleteDialogOpen.value = true;
}
function deleteSelected() {
const selected = new Set(selectedRowKeys.value);
groups.value = Object.fromEntries(
Object.entries(groups.value).filter(([key]) => !selected.has(key)),
);
selectedRowKeys.value = [];
deleteDialogOpen.value = false;
}
function canEditRow(record) {
return Object.keys(record.records || {}).length ? canUpdate.value : canCreate.value;
}
async function saveTranslations() {
if (!dirty.value) return;
saving.value = true;
try {
const oldMap = new Map(
data.translations.map((item) => [item.b_key + "\u0000" + item.b_locale, item]),
);
const desiredMap = new Map();
Object.values(groups.value).forEach((group) => {
I18N_LOCALE_CODES.forEach((locale) => {
const value = group.values[locale]?.trim();
if (value) {
desiredMap.set(group.key + "\u0000" + locale, { key: group.key, locale, value });
}
});
});
const inserts = [];
const updates = [];
const deletes = [];
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
for (const [compoundKey, item] of desiredMap) {
const old = oldMap.get(compoundKey);
if (!old) {
const id = String((await nextIdApi()).data);
inserts.push({
b_id: id,
b_key: item.key,
b_locale: item.locale,
b_value: item.value,
b_inputuser_id: String(authStore.user.id),
b_inputdatetime: now,
b_updateuser_id: null,
b_updatedatetime: null,
});
} else if (old.b_value !== item.value) {
updates.push({
...old,
b_value: item.value,
b_updateuser_id: String(authStore.user.id),
b_updatedatetime: now,
});
}
}
for (const [compoundKey, old] of oldMap) {
const keyWasDeleted = !groups.value[old.b_key];
const managedLocale = I18N_LOCALE_CODES.includes(old.b_locale);
if (!desiredMap.has(compoundKey) && (keyWasDeleted || managedLocale)) {
deletes.push({ b_id: old.b_id });
}
}
if (!inserts.length && !updates.length && !deletes.length) {
resetDraft();
message.info("绑定中的空 Key 会继续保留,以便补充缺失翻译");
return;
}
await saveObjectApi([{ table: "b_i18n", key_field: "b_id", inserts, updates, deletes }]);
message.success("多语言资源已保存");
await loadAll();
} catch (error) {
message.error(error.message || "多语言资源保存失败");
} finally {
saving.value = false;
}
}
function resetFilters() {
Object.assign(filters, {
keyword: "",
namespace: undefined,
moduleCode: undefined,
locale: undefined,
status: "all",
});
}
watch(
() => route.query.module,
(value) => {
if (typeof value === "string") filters.moduleCode = value;
},
);
onMounted(async () => {
await permissionStore.load();
await loadAll();
});
</script>
<template>
<section class="i18n-management">
<header class="page-header">
<div>
<div class="page-title"><Languages />多语言资源中心</div>
<p>统一维护菜单、模块、权限、字段、提示、按钮与校验文案。</p>
</div>
<div class="header-actions">
<a-button :disabled="!dirty" @click="resetDraft"><RotateCcw />撤销修改</a-button>
<a-button :loading="loading" @click="loadAll"><RefreshCw />刷新</a-button>
<a-button type="primary" :loading="saving" :disabled="!dirty" @click="saveTranslations">
<Save />保存
</a-button>
</div>
</header>
<div class="filter-bar">
<a-input-search v-model:value="filters.keyword" allow-clear placeholder="搜索 key 或文案" />
<a-select
v-model:value="filters.namespace"
allow-clear
placeholder="命名空间"
:options="namespaceOptions"
/>
<a-select
v-model:value="filters.moduleCode"
show-search
allow-clear
placeholder="关联模块"
:options="moduleOptions"
/>
<a-select
v-model:value="filters.locale"
allow-clear
placeholder="聚焦语言"
:options="I18N_LOCALES"
/>
<a-select
v-model:value="filters.status"
:options="[
{ value: 'all', label: '全部状态' },
{ value: 'missing', label: '存在缺失' },
{ value: 'complete', label: '翻译完整' },
]"
/>
<a-button @click="resetFilters">重置筛选</a-button>
</div>
<div class="table-toolbar">
<div class="toolbar-actions">
<a-button type="primary" :disabled="!canCreate" @click="openCreateDialog">
<Plus />新增 Key
</a-button>
<a-button
danger
:disabled="!canDelete || !selectedRowKeys.length"
@click="requestDeleteSelected"
>
<Trash2 />批量删除
</a-button>
</div>
<span>共 {{ filteredRows.length }} 个 Key,已选择 {{ selectedRowKeys.length }} 个</span>
</div>
<div class="table-wrap">
<a-spin :spinning="loading">
<FmsTable
row-key="key"
:col-resizable="false"
:columns="columns"
:data-source="filteredRows"
:row-selection="rowSelection"
/>
</a-spin>
</div>
<a-modal
v-model:open="createDialog.open"
title="新增多语言 Key"
ok-text="添加"
cancel-text="取消"
@ok="addKey"
>
<a-form layout="vertical">
<a-form-item
label="多语言键"
required
extra="建议使用 namespace.module_code.name,例如 field.m_biz.customer.placeholder"
>
<a-input v-model:value="createDialog.key" :maxlength="150" />
</a-form-item>
<a-form-item v-for="locale in I18N_LOCALES" :key="locale.value" :label="locale.label">
<a-input v-model:value="createDialog.values[locale.value]" :maxlength="500" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="deleteDialogOpen"
title="删除多语言 Key"
ok-text="删除"
cancel-text="取消"
ok-type="danger"
@ok="deleteSelected"
>
<p>
确定删除选中的 {{ selectedRowKeys.length }} 个 Key 及其全部语言文本吗?已绑定这些 Key
的业务对象将显示回退文案。
</p>
</a-modal>
</section>
</template>
<style scoped>
.i18n-management {
display: flex;
min-height: 100%;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
}
.page-header,
.filter-bar,
.table-toolbar,
.header-actions,
.toolbar-actions,
.page-title {
display: flex;
align-items: center;
}
.page-header,
.table-toolbar {
justify-content: space-between;
gap: 12px;
}
.page-title {
gap: 8px;
color: var(--fms-text);
font-size: 20px;
font-weight: 650;
}
.page-header p,
.table-toolbar,
.muted {
color: var(--fms-text-secondary);
}
.page-header p {
margin-top: 4px;
font-size: 13px;
}
.header-actions,
.toolbar-actions,
.filter-bar {
gap: 8px;
}
.filter-bar {
flex-wrap: wrap;
padding: 10px;
border-radius: 6px;
background: var(--fms-bg);
}
.filter-bar :deep(.ant-input-search) {
width: 260px;
}
.filter-bar :deep(.ant-select) {
width: 170px;
}
.table-wrap {
min-width: 0;
min-height: 420px;
flex: 1 1 auto;
}
.table-wrap :deep(.ant-spin-nested-loading),
.table-wrap :deep(.ant-spin-container),
.table-wrap :deep(.ant-spin) {
width: 100%;
height: 100%;
}
.table-wrap :deep(.stk-table .ant-input) {
min-width: 0;
}
</style>
@@ -1,11 +1,13 @@
<script setup>
import ModuleTranslationEditor from "./ModuleTranslationEditor.vue";
const props = defineProps({
directoryParentOptions: { type: Array, default: () => [] },
isNew: Boolean,
parentOptions: { type: Array, default: () => [] },
});
const emit = defineEmits(["update-module-id"]);
const emit = defineEmits(["update-module-code"]);
const module = defineModel("module", { type: Object, default: null });
const translations = defineModel("translations", { type: Object, default: () => ({}) });
function updateRoute(value) {
module.value.b_route = value || null;
@@ -18,7 +20,7 @@ function updateRoute(value) {
<a-form v-if="module.b_type === 'directory'" layout="vertical" class="directory-form">
<div class="directory-grid">
<a-form-item label="模块编码" required>
<a-input :value="module.b_id" disabled />
<a-input :value="module.b_code" @update:value="emit('update-module-code', $event)" />
</a-form-item>
<a-form-item label="模块名称" required>
<a-input v-model:value="module.b_name" />
@@ -33,7 +35,14 @@ function updateRoute(value) {
/>
</a-form-item>
<a-form-item label="多语言键">
<a-input v-model:value="module.b_i18n" />
<div class="i18n-editor-row">
<a-input v-model:value="module.b_i18n" />
<ModuleTranslationEditor
v-model:translations="translations"
:i18n-key="module.b_i18n"
:disabled="!module.b_i18n?.trim()"
/>
</div>
</a-form-item>
<a-form-item label="显示顺序">
<a-input-number v-model:value="module.b_xh" :min="0" />
@@ -44,11 +53,7 @@ function updateRoute(value) {
<a-form v-else layout="vertical" class="basic-form">
<div class="form-grid">
<a-form-item label="模块编码" required>
<a-input
:value="module.b_id"
:disabled="!isNew"
@update:value="emit('update-module-id', $event)"
/>
<a-input :value="module.b_code" @update:value="emit('update-module-code', $event)" />
</a-form-item>
<a-form-item label="模块名称" required>
<a-input v-model:value="module.b_name" />
@@ -70,7 +75,14 @@ function updateRoute(value) {
/>
</a-form-item>
<a-form-item label="多语言键">
<a-input v-model:value="module.b_i18n" />
<div class="i18n-editor-row">
<a-input v-model:value="module.b_i18n" />
<ModuleTranslationEditor
v-model:translations="translations"
:i18n-key="module.b_i18n"
:disabled="!module.b_i18n?.trim()"
/>
</div>
</a-form-item>
<a-form-item label="查询表或视图">
<a-input v-model:value="module.b_viewtable" />
@@ -81,12 +93,9 @@ function updateRoute(value) {
<a-form-item label="主键字段">
<a-input v-model:value="module.b_keyfield" />
</a-form-item>
<a-form-item label="排序字段">
<a-form-item label="排序条件">
<a-input v-model:value="module.b_orderfield" />
</a-form-item>
<a-form-item label="排序方向">
<a-segmented v-model:value="module.b_ordertype" :options="['ASC', 'DESC']" />
</a-form-item>
<a-form-item label="显示顺序">
<a-input-number v-model:value="module.b_xh" :min="0" />
</a-form-item>
@@ -100,7 +109,7 @@ function updateRoute(value) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding-top: 10px;
padding-top: 10px 0;
}
.directory-form {
@@ -124,6 +133,17 @@ function updateRoute(value) {
gap: 0 10px;
}
.i18n-editor-row {
display: flex;
align-items: center;
gap: 8px;
}
.i18n-editor-row :deep(.ant-input) {
min-width: 0;
flex: 1 1 auto;
}
:deep(.ant-form-item) {
margin-bottom: 8px;
}
@@ -1,29 +1,165 @@
<script setup>
import { computed, ref, watch } from "vue";
import { App } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { nextIdApi } from "@/services/api";
const props = defineProps({
moduleId: { type: [String, Number], default: "" },
moduleCode: { type: String, default: "" },
});
const fields = defineModel("fields", { type: Array, default: () => [] });
const { message } = App.useApp();
const adding = ref(false);
const selectedRowKeys = ref([]);
const columns = [
{ dataIndex: "b_field", title: "字段", width: 150 },
{ dataIndex: "b_name", title: "名称", width: 160 },
{ dataIndex: "b_type", title: "类型", width: 120 },
{ dataIndex: "b_canlist", title: "列表", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_canform", title: "表单", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_must", title: "必填", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_canquery", title: "查询", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_readonly", title: "只读", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_width", title: "宽度", width: 90 },
{ dataIndex: "b_xh", title: "顺序", width: 80 },
];
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
watch(
() => fields.value.map((item) => String(item.b_id)),
(keys) => {
const available = new Set(keys);
selectedRowKeys.value = selectedRowKeys.value.filter((key) => available.has(String(key)));
},
);
function nextFieldCode() {
const existing = new Set(fields.value.map((item) => item.b_field));
let index = 1;
while (existing.has(`b_new_${index}`)) index += 1;
return `b_new_${index}`;
}
async function onAdd() {
if (!props.moduleId || adding.value) return;
adding.value = true;
try {
const code = nextFieldCode();
const id = String((await nextIdApi()).data);
fields.value = [
...fields.value,
{
b_id: id,
b_module_id: props.moduleId,
b_field: code,
b_name: "新字段",
b_i18n: props.moduleCode ? `field.${props.moduleCode}.${code}` : null,
b_type: "string",
b_canlist: 1,
b_canform: 1,
b_must: 0,
b_canquery: 0,
b_readonly: 0,
b_width: 120,
b_value: null,
b_xh: (fields.value.length + 1) * 10,
b_canuse: 1,
},
];
} catch (error) {
message.error(error.message || "字段创建失败");
} finally {
adding.value = false;
}
}
function onBatchDelete() {
const selected = new Set(selectedRowKeys.value.map(String));
fields.value = fields.value.filter((item) => !selected.has(String(item.b_id)));
selectedRowKeys.value = [];
}
const columns = ref([
{ dataIndex: "b_field", title: "字段", width: 150, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "名称", width: 150, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 230, editor: { maxlength: 150 } },
{
dataIndex: "b_type",
title: "类型",
width: 120,
editor: {
type: "select",
options: ["string", "text", "number", "select", "date", "boolean"].map((value) => ({
value,
label: value,
})),
},
},
{
dataIndex: "b_canlist",
title: "列表",
width: 64,
align: "center",
headerAlign: "center",
editor: "checkbox",
},
{
dataIndex: "b_canform",
title: "表单",
width: 64,
align: "center",
headerAlign: "center",
editor: {
type: "checkbox",
onChange: (value, row) => {
if (value === 0) row.b_must = 0;
},
},
},
{
dataIndex: "b_must",
title: "必填",
width: 64,
align: "center",
headerAlign: "center",
editor: { type: "checkbox", disabled: (row) => row.b_canform !== 1 },
},
{
dataIndex: "b_canquery",
title: "查询",
width: 64,
align: "center",
headerAlign: "center",
editor: "checkbox",
},
{
dataIndex: "b_readonly",
title: "只读",
width: 64,
align: "center",
headerAlign: "center",
editor: "checkbox",
},
{ dataIndex: "b_width", title: "宽度", width: 90, editor: { type: "number", min: 1 } },
{ dataIndex: "b_xh", title: "顺序", width: 80, editor: { type: "number", min: 0 } },
]);
</script>
<template>
<section class="table-pane">
<div class="toolbar">
<a-button type="primary" :loading="adding" @click="onAdd">
<template #icon><Plus /></template>
新增
</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="onBatchDelete">
<template #icon><Trash2 /></template>
批量删除
</a-button>
</div>
<FmsTable
class="sub-table"
row-key="b_id"
:columns="columns"
v-model:columns="columns"
:data-source="fields"
:row-selection="rowSelection"
/>
</section>
</template>
@@ -37,6 +173,13 @@ const columns = [
overflow: hidden;
}
.toolbar {
display: flex;
gap: 8px;
flex: none;
padding: 10px 0;
}
.sub-table {
flex: 1 1 auto;
min-height: 0;
@@ -1,24 +1,107 @@
<script setup>
import { computed, ref, watch } from "vue";
import { App } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { nextIdApi } from "@/services/api";
const props = defineProps({
moduleId: { type: [String, Number], default: "" },
moduleCode: { type: String, default: "" },
});
const powers = defineModel("powers", { type: Array, default: () => [] });
const { message } = App.useApp();
const adding = ref(false);
const selectedRowKeys = ref([]);
const columns = [
{ dataIndex: "b_code", title: "权限代码", width: 180 },
{ dataIndex: "b_name", title: "名称", width: 200 },
{ dataIndex: "b_i18n", title: "多语言键", width: 200 },
{ dataIndex: "b_canuse", title: "启用", width: 74, align: "center", headerAlign: "center" },
{ dataIndex: "b_xh", title: "顺序", width: 90 },
];
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
watch(
() => powers.value.map((item) => String(item.b_id)),
(keys) => {
const available = new Set(keys);
selectedRowKeys.value = selectedRowKeys.value.filter((key) => available.has(String(key)));
},
);
function nextPowerCode() {
const existing = new Set(powers.value.map((item) => item.b_code));
let index = 1;
while (existing.has(`new_${index}`)) index += 1;
return `new_${index}`;
}
async function onAdd() {
if (!props.moduleId || adding.value) return;
adding.value = true;
try {
const code = nextPowerCode();
const id = String((await nextIdApi()).data);
powers.value = [
...powers.value,
{
b_id: id,
b_module_id: props.moduleId,
b_code: code,
b_name: "新权限",
b_i18n: props.moduleCode ? `power.${props.moduleCode}.${code}` : null,
b_canuse: 1,
b_xh: (powers.value.length + 1) * 10,
},
];
} catch (error) {
message.error(error.message || "权限创建失败");
} finally {
adding.value = false;
}
}
function onBatchDelete() {
const selected = new Set(selectedRowKeys.value.map(String));
powers.value = powers.value.filter((item) => !selected.has(String(item.b_id)));
selectedRowKeys.value = [];
}
const columns = ref([
{ dataIndex: "b_code", title: "权限代码", width: 180, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "名称", width: 180, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 240, editor: { maxlength: 150 } },
{
dataIndex: "b_canuse",
title: "启用",
width: 74,
align: "center",
headerAlign: "center",
editor: "checkbox",
},
{ dataIndex: "b_xh", title: "顺序", width: 90, editor: { type: "number", min: 0 } },
]);
</script>
<template>
<section class="table-pane">
<div class="toolbar">
<a-button type="primary" :loading="adding" @click="onAdd">
<template #icon><Plus /></template>
新增
</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="onBatchDelete">
<template #icon><Trash2 /></template>
批量删除
</a-button>
</div>
<FmsTable
class="sub-table"
row-key="b_id"
:columns="columns"
v-model:columns="columns"
:data-source="powers"
:row-selection="rowSelection"
/>
</section>
</template>
@@ -32,6 +115,13 @@ const columns = [
overflow: hidden;
}
.toolbar {
display: flex;
gap: 8px;
flex: none;
padding: 10px 0;
}
.sub-table {
flex: 1 1 auto;
min-height: 0;
@@ -0,0 +1,59 @@
<script setup>
import { reactive } from "vue";
import { App } from "antdv-next";
import { Languages } from "@lucide/vue";
import { I18N_LOCALES, isValidI18nKey } from "@/utils/i18n";
const props = defineProps({
i18nKey: { type: String, default: "" },
disabled: { type: Boolean, default: false },
});
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const { message } = App.useApp();
const dialog = reactive({ open: false, key: "", values: {} });
function openEditor() {
const key = props.i18nKey.trim();
if (!isValidI18nKey(key)) {
message.warning("请先填写正确的多语言键");
return;
}
dialog.key = key;
dialog.values = Object.fromEntries(
I18N_LOCALES.map((locale) => [locale.value, translations.value[key]?.[locale.value] || ""]),
);
dialog.open = true;
}
function saveTranslations() {
translations.value = {
...translations.value,
[dialog.key]: { ...dialog.values },
};
dialog.open = false;
}
</script>
<template>
<a-button :disabled="disabled" @click="openEditor">
<template #icon><Languages /></template>
多语言
</a-button>
<a-modal
v-model:open="dialog.open"
title="更新多语言"
ok-text="更新"
cancel-text="取消"
@ok="saveTranslations"
>
<a-form layout="vertical">
<a-form-item label="多语言键">
<a-input :value="dialog.key" disabled />
</a-form-item>
<a-form-item v-for="locale in I18N_LOCALES" :key="locale.value" :label="locale.label">
<a-input v-model:value="dialog.values[locale.value]" :maxlength="500" />
</a-form-item>
</a-form>
</a-modal>
</template>
@@ -1,57 +0,0 @@
<script setup>
import { computed } from "vue";
import FmsTable from "@/components/FmsTable.vue";
const LOCALES = [
{ value: "zh-CN", label: "简体中文" },
{ value: "en-US", label: "English" },
{ value: "zh-TW", label: "繁體中文" },
];
const props = defineProps({
keys: { type: Array, default: () => [] },
});
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const rows = computed(() =>
props.keys.map((key) => {
const entry = translations.value[key] || {};
const row = { key };
LOCALES.forEach((locale) => {
row[locale.value] = entry[locale.value];
});
return row;
}),
);
const columns = [
{ dataIndex: "key", title: "多语言键", width: 280, fixed: "left" },
...LOCALES.map((locale) => ({ dataIndex: locale.value, title: locale.label, width: 220 })),
];
</script>
<template>
<section class="translation-pane">
<FmsTable
class="sub-table"
row-key="key"
:columns="columns"
:data-source="rows"
/>
</section>
</template>
<style scoped>
.translation-pane {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.sub-table {
flex: 1 1 auto;
min-height: 0;
}
</style>
@@ -5,6 +5,7 @@ import FmsTree from "@/components/FmsTree.vue";
const props = defineProps({
modules: { type: Array, required: true },
protectedKeys: { type: Array, default: () => [] },
selectedKey: { type: String, default: "" },
loading: Boolean,
canCreate: Boolean,
@@ -16,15 +17,16 @@ const contextNode = ref(null);
const treeData = computed(() => {
const keyword = searchText.value.trim().toLowerCase();
const codeById = new Map(props.modules.map((item) => [item.b_id, item.b_code]));
const visible = keyword
? new Set(
props.modules
.filter(
(item) =>
String(item.b_id).toLowerCase().includes(keyword) ||
String(item.b_code).toLowerCase().includes(keyword) ||
String(item.b_name).toLowerCase().includes(keyword),
)
.flatMap((item) => [item.b_id, item.b_parent_id]),
.flatMap((item) => [item.b_code, codeById.get(item.b_parent_id)].filter(Boolean)),
)
: null;
const children = new Map();
@@ -35,11 +37,11 @@ const treeData = computed(() => {
});
const build = (parent) =>
(children.get(parent) || [])
.filter((item) => !visible || visible.has(item.b_id))
.filter((item) => !visible || visible.has(item.b_code))
.map((item) => {
const nodeChildren = build(item.b_id);
return {
key: item.b_id,
key: item.b_code,
title: item.b_name,
children: nodeChildren,
isLeaf: nodeChildren.length === 0,
@@ -69,7 +71,7 @@ const contextMenu = computed(() => {
key: "delete",
label: "删除",
danger: true,
disabled: !node || !props.canDelete || ["M_SYSTEM", "M_MODULE"].includes(node.key),
disabled: !node || !props.canDelete || props.protectedKeys.includes(String(node.key)),
},
],
onClick: ({ key }) => {
@@ -1,18 +1,18 @@
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { App } from "antdv-next";
import { Database, FileText, Languages, RotateCcw, Save, ShieldCheck, Trash2 } from "@lucide/vue";
import { Database, FileText, RotateCcw, Save, ShieldCheck, Trash2 } from "@lucide/vue";
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
import { I18N_LOCALE_CODES } from "@/utils/i18n";
import ModuleBasicInfoPanel from "./components/ModuleBasicInfoPanel.vue";
import ModuleFieldDefinitionPanel from "./components/ModuleFieldDefinitionPanel.vue";
import ModulePowerPanel from "./components/ModulePowerPanel.vue";
import ModuleTranslationsPanel from "./components/ModuleTranslationsPanel.vue";
import ModuleTreePanel from "./components/ModuleTreePanel.vue";
const PROTECTED_MODULE_CODES = ["m_module", "m_i18n", "m_biz"];
const { message } = App.useApp();
const LOCALES = ["zh-CN", "en-US", "zh-TW"];
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
const loading = ref(false);
@@ -23,6 +23,7 @@ const isNew = ref(false);
const deleteConfirmOpen = ref(false);
const pendingDeleteKey = ref("");
const original = ref("");
const originalTranslationKeys = ref([]);
const data = reactive({
modules: [],
fields: [],
@@ -33,28 +34,27 @@ const data = reactive({
});
const draft = reactive({ module: null, fields: [], powers: [], translations: {} });
const canCreate = computed(() => permissionStore.canPower("M_MODULE", "create"));
const canUpdate = computed(() => permissionStore.canPower("M_MODULE", "update"));
const canDelete = computed(() => permissionStore.canPower("M_MODULE", "delete"));
const canCreate = computed(() => permissionStore.canPower("m_module", "create"));
const canUpdate = computed(() => permissionStore.canPower("m_module", "update"));
const canDelete = computed(() => permissionStore.canPower("m_module", "delete"));
const canSave = computed(() => (isNew.value ? canCreate.value : canUpdate.value));
const dirty = computed(() => Boolean(draft.module) && serializeDraft() !== original.value);
const parentOptions = computed(() =>
data.modules
.filter((item) => item.b_id !== draft.module?.b_id && item.b_type !== "data")
.map((item) => ({ value: item.b_id, label: item.b_name + " (" + item.b_id + ")" })),
.map((item) => ({ value: item.b_id, label: item.b_name + " (" + item.b_code + ")" })),
);
const directoryParentOptions = computed(() =>
data.modules
.filter((item) => item.b_type === "directory")
.map((item) => ({ value: item.b_id, label: item.b_name + " (" + item.b_id + ")" })),
.map((item) => ({ value: item.b_id, label: item.b_name + " (" + item.b_code + ")" })),
);
const moduleKindLabel = computed(() => {
if (draft.module?.b_type === "directory") return "模块";
return draft.module?.b_route?.trim() ? "页面模块" : "数据模块";
});
const translationKeys = computed(() => rowKeys());
const pendingDeleteModule = computed(() =>
data.modules.find((item) => item.b_id === pendingDeleteKey.value),
data.modules.find((item) => item.b_code === pendingDeleteKey.value),
);
function clone(value) {
@@ -86,19 +86,23 @@ function clearDraft() {
draft.powers = [];
draft.translations = {};
original.value = "";
originalTranslationKeys.value = [];
activeTab.value = "basic";
}
function selectModule(code) {
const module = data.modules.find((item) => item.b_id === code);
function selectModule(moduleCode) {
const module = data.modules.find((item) => item.b_code === moduleCode);
if (!module) return;
selectedKey.value = code;
selectedKey.value = moduleCode;
isNew.value = false;
draft.module = clone(module);
draft.fields = clone(data.fields.filter((item) => item.b_module_id === code));
draft.powers = clone(data.powers.filter((item) => item.b_module_id === code));
draft.fields = clone(data.fields.filter((item) => item.b_module_id === module.b_id));
draft.powers = clone(data.powers.filter((item) => item.b_module_id === module.b_id));
const keys = new Set(rowKeys());
data.translations.forEach((item) => {
if (String(item.b_key).split(".").includes(module.b_code)) keys.add(item.b_key);
});
draft.translations = {};
data.translations
@@ -108,6 +112,7 @@ function selectModule(code) {
draft.translations[item.b_key][item.b_locale] = item.b_value;
});
originalTranslationKeys.value = Array.from(keys);
original.value = serializeDraft();
if (module.b_type === "directory") activeTab.value = "basic";
}
@@ -131,7 +136,7 @@ async function loadAll(preferredKey = selectedKey.value) {
userModules: userModules.data,
userPowers: userPowers.data,
});
const target = data.modules.some((item) => item.b_id === preferredKey) ? preferredKey : "";
const target = data.modules.some((item) => item.b_code === preferredKey) ? preferredKey : "";
if (target) {
selectModule(target);
@@ -146,75 +151,63 @@ async function loadAll(preferredKey = selectedKey.value) {
}
function nextModuleCode(prefix) {
const existing = new Set(data.modules.map((item) => item.b_id));
const existing = new Set(data.modules.map((item) => item.b_code));
let index = 1;
while (existing.has(prefix + index)) index += 1;
return prefix + index;
}
function parentForNew(contextKey) {
const context = data.modules.find((item) => item.b_id === contextKey);
const context = data.modules.find((item) => item.b_code === contextKey);
if (!context) return null;
return context.b_type === "directory" ? context.b_id : context.b_parent_id || null;
}
function addModule(kind, contextKey) {
async function addModule(kind, contextKey) {
const isDirectory = kind === "directory";
const id = nextModuleCode(isDirectory ? "M_DIR_NEW_" : "M_PAGE_NEW_");
const id = String((await nextIdApi()).data);
const code = nextModuleCode(isDirectory ? "m_dir_new_" : "m_page_new_");
const parentId = parentForNew(contextKey);
const route = isDirectory ? null : "/" + id.toLowerCase().replace(/^m_/, "").replaceAll("_", "-");
const route = isDirectory ? null : "/" + code.replace(/^m_/, "").replaceAll("_", "-");
draft.module = {
b_id: id,
b_code: code,
b_parent_id: parentId,
b_name: isDirectory ? "新模块" : "新页面",
b_i18n: "module." + id,
b_i18n: "module." + code,
b_type: isDirectory ? "directory" : "page",
b_route: route,
b_viewtable: null,
b_savetable: null,
b_keyfield: isDirectory ? null : "b_id",
b_orderfield: isDirectory ? null : "b_id",
b_ordertype: isDirectory ? null : "DESC",
b_orderfield: isDirectory ? null : "b_id DESC",
b_canmenu: isDirectory || route ? 1 : 0,
b_canuse: 1,
b_xh: 100,
};
draft.fields = [];
draft.powers = [];
originalTranslationKeys.value = [];
draft.translations = {
["module." + id]: { "zh-CN": draft.module.b_name },
["module." + code]: { "zh-CN": draft.module.b_name },
};
selectedKey.value = id;
selectedKey.value = code;
isNew.value = true;
original.value = "";
activeTab.value = "basic";
}
function handleAdd(kind, contextKey) {
addModule(kind === "module" ? "directory" : "page", contextKey);
async function handleAdd(kind, contextKey) {
try {
await addModule(kind === "module" ? "directory" : "page", contextKey);
} catch (error) {
message.error(error.message || "模块创建失败");
}
}
function updateModuleId(value) {
if (!isNew.value) return;
const oldId = draft.module.b_id;
const oldKey = draft.module.b_i18n;
const newId = String(value || "").toUpperCase();
draft.module.b_id = newId;
if (oldKey === "module." + oldId) {
const newKey = "module." + newId;
draft.module.b_i18n = newKey;
draft.translations[newKey] = draft.translations[oldKey] || {};
delete draft.translations[oldKey];
}
selectedKey.value = newId;
draft.fields.forEach((item) => {
item.b_module_id = newId;
});
draft.powers.forEach((item) => {
item.b_module_id = newId;
});
function updateModuleCode(value) {
draft.module.b_code = String(value || "").toLowerCase();
}
function normalizeModuleType() {
@@ -225,9 +218,9 @@ function normalizeModuleType() {
function validate() {
const module = draft.module;
if (!/^[A-Z][A-Z0-9_]{1,49}$/.test(module.b_id)) return "模块编码格式不正确";
if (!/^[a-z][a-z0-9_]{1,49}$/.test(module.b_code)) return "模块编码格式不正确";
if (!module.b_name?.trim()) return "模块名称不能为空";
if (isNew.value && data.modules.some((item) => item.b_id === module.b_id)) {
if (data.modules.some((item) => item.b_code === module.b_code && item.b_id !== module.b_id)) {
return "模块编码已经存在";
}
if (
@@ -238,15 +231,20 @@ function validate() {
}
if (module.b_type === "data" && module.b_canmenu === 1) return "数据模块不能显示在菜单";
const identifiers = [
module.b_viewtable,
module.b_savetable,
module.b_keyfield,
module.b_orderfield,
].filter(Boolean);
const identifiers = [module.b_viewtable, module.b_savetable, module.b_keyfield].filter(Boolean);
if (identifiers.some((item) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(item))) {
return "表名和字段名只能包含字母、数字和下划线";
}
const orderItemPattern =
/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*(?:\s+(?:ASC|DESC))?$/i;
if (
module.b_orderfield &&
String(module.b_orderfield)
.split(",")
.some((item) => !orderItemPattern.test(item.trim()))
) {
return "排序条件格式不正确";
}
const fieldCodes = draft.fields.map((item) => item.b_field);
if (new Set(fieldCodes).size !== fieldCodes.length) return "字段编码不能重复";
@@ -281,14 +279,14 @@ function diffRows(current, previous) {
}
async function translationChanges(previousKeys) {
const currentKeys = new Set(rowKeys());
const currentKeys = new Set([...rowKeys(), ...Object.keys(draft.translations)]);
const relevantKeys = new Set([...previousKeys, ...currentKeys]);
const oldRows = data.translations.filter((item) => relevantKeys.has(item.b_key));
const desired = [];
const missing = [];
for (const key of currentKeys) {
for (const locale of LOCALES) {
for (const locale of I18N_LOCALE_CODES) {
const value = draft.translations[key]?.[locale]?.trim();
if (!value) continue;
const old = oldRows.find((item) => item.b_key === key && item.b_locale === locale);
@@ -299,6 +297,9 @@ async function translationChanges(previousKeys) {
}
}
}
oldRows
.filter((item) => !I18N_LOCALE_CODES.includes(item.b_locale))
.forEach((item) => desired.push(item));
const ids = [];
for (let index = 0; index < missing.length; index += 1) {
@@ -340,7 +341,7 @@ async function saveModule() {
try {
const oldModule = isNew.value
? null
: data.modules.find((item) => item.b_id === selectedKey.value);
: data.modules.find((item) => item.b_code === selectedKey.value);
const oldFields = oldModule
? data.fields.filter((item) => item.b_module_id === oldModule.b_id)
: [];
@@ -349,6 +350,7 @@ async function saveModule() {
: [];
const previousKeys = new Set(
[
...originalTranslationKeys.value,
oldModule?.b_i18n,
...oldFields.map((item) => item.b_i18n),
...oldPowers.map((item) => item.b_i18n),
@@ -391,7 +393,7 @@ async function saveModule() {
),
);
const savedKey = draft.module.b_id;
const savedKey = draft.module.b_code;
message.success("模块配置已保存");
await loadAll(savedKey);
} catch (error) {
@@ -401,38 +403,31 @@ async function saveModule() {
}
}
function requestDeleteModule(moduleId) {
const module = data.modules.find((item) => item.b_id === moduleId);
if (!module || ["M_SYSTEM", "M_MODULE"].includes(moduleId)) return;
if (data.modules.some((item) => item.b_parent_id === moduleId)) {
function requestDeleteModule(moduleCode) {
const module = data.modules.find((item) => item.b_code === moduleCode);
if (!module || PROTECTED_MODULE_CODES.includes(moduleCode)) return;
if (data.modules.some((item) => item.b_parent_id === module.b_id)) {
message.warning("请先删除下级模块");
return;
}
pendingDeleteKey.value = moduleId;
pendingDeleteKey.value = moduleCode;
deleteConfirmOpen.value = true;
}
async function deleteModule(moduleId = draft.module?.b_id) {
const module = data.modules.find((item) => item.b_id === moduleId);
if (!module || ["M_SYSTEM", "M_MODULE"].includes(moduleId)) return;
if (data.modules.some((item) => item.b_parent_id === moduleId)) {
async function deleteModule(moduleCode = draft.module?.b_code) {
const module = data.modules.find((item) => item.b_code === moduleCode);
if (!module || PROTECTED_MODULE_CODES.includes(moduleCode)) return;
if (data.modules.some((item) => item.b_parent_id === module.b_id)) {
message.warning("请先删除下级模块");
return;
}
saving.value = true;
try {
const powers = data.powers.filter((item) => item.b_module_id === moduleId);
const powers = data.powers.filter((item) => item.b_module_id === module.b_id);
const powerIds = new Set(powers.map((item) => String(item.b_id)));
const fields = data.fields.filter((item) => item.b_module_id === moduleId);
const keys = new Set(
[
module.b_i18n,
...fields.map((item) => item.b_i18n),
...powers.map((item) => item.b_i18n),
].filter(Boolean),
);
const fields = data.fields.filter((item) => item.b_module_id === module.b_id);
const requests = [
{
@@ -450,7 +445,7 @@ async function deleteModule(moduleId = draft.module?.b_id) {
inserts: [],
updates: [],
deletes: data.userModules
.filter((item) => item.b_module_id === moduleId)
.filter((item) => item.b_module_id === module.b_id)
.map((item) => ({ b_id: item.b_id })),
},
{
@@ -467,21 +462,12 @@ async function deleteModule(moduleId = draft.module?.b_id) {
updates: [],
deletes: fields.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_i18n",
key_field: "b_id",
inserts: [],
updates: [],
deletes: data.translations
.filter((item) => keys.has(item.b_key))
.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_module",
key_field: "b_id",
inserts: [],
updates: [],
deletes: [{ b_id: moduleId }],
deletes: [{ b_id: module.b_id }],
},
];
await saveObjectApi(
@@ -493,7 +479,7 @@ async function deleteModule(moduleId = draft.module?.b_id) {
message.success("模块已删除");
deleteConfirmOpen.value = false;
pendingDeleteKey.value = "";
await loadAll(selectedKey.value === moduleId ? "" : selectedKey.value);
await loadAll(selectedKey.value === moduleCode ? "" : selectedKey.value);
} catch (error) {
message.error(error.message || "模块删除失败");
} finally {
@@ -523,6 +509,7 @@ onMounted(async () => {
<div class="module-management">
<ModuleTreePanel
:modules="data.modules"
:protected-keys="PROTECTED_MODULE_CODES"
:selected-key="selectedKey"
:loading="loading"
:can-create="canCreate"
@@ -540,7 +527,7 @@ onMounted(async () => {
<div class="title-block">
<div class="title-line">
<h1>{{ draft.module.b_name }}</h1>
<a-tag>{{ draft.module.b_id }}</a-tag>
<a-tag>{{ draft.module.b_code }}</a-tag>
<a-tag color="blue">{{ moduleKindLabel }}</a-tag>
</div>
</div>
@@ -572,14 +559,14 @@ onMounted(async () => {
<a-popconfirm
title="确定删除当前模块吗?"
:disabled="
isNew || !canDelete || ['M_SYSTEM', 'M_MODULE'].includes(draft.module.b_id)
isNew || !canDelete || PROTECTED_MODULE_CODES.includes(draft.module.b_code)
"
@confirm="deleteModule()"
>
<a-button
danger
:disabled="
isNew || !canDelete || ['M_SYSTEM', 'M_MODULE'].includes(draft.module.b_id)
isNew || !canDelete || PROTECTED_MODULE_CODES.includes(draft.module.b_code)
"
>
<Trash2 :size="15" />删除
@@ -596,51 +583,41 @@ onMounted(async () => {
</div>
</header>
<ModuleBasicInfoPanel
v-if="draft.module.b_type === 'directory'"
v-model:module="draft.module"
:directory-parent-options="directoryParentOptions"
:is-new="isNew"
:parent-options="parentOptions"
@update-module-id="updateModuleId"
/>
<a-tabs v-else v-model:active-key="activeTab" class="workspace-tabs">
<a-tabs v-model:active-key="activeTab" class="workspace-tabs">
<a-tab-pane key="basic">
<template #tab>
<span class="tab-label"><FileText :size="15" />基本信息</span>
</template>
<ModuleBasicInfoPanel
v-model:module="draft.module"
v-model:translations="draft.translations"
:directory-parent-options="directoryParentOptions"
:is-new="isNew"
:parent-options="parentOptions"
@update-module-id="updateModuleId"
@update-module-code="updateModuleCode"
/>
</a-tab-pane>
<a-tab-pane key="fields">
<a-tab-pane v-if="draft.module.b_type !== 'directory'" key="fields">
<template #tab>
<span class="tab-label">
<Database :size="15" />字段定义 <a-badge :count="draft.fields.length" />
</span>
</template>
<ModuleFieldDefinitionPanel v-model:fields="draft.fields" />
<ModuleFieldDefinitionPanel
v-model:fields="draft.fields"
:module-id="draft.module.b_id"
:module-code="draft.module.b_code"
/>
</a-tab-pane>
<a-tab-pane key="powers">
<a-tab-pane v-if="draft.module.b_type !== 'directory'" key="powers">
<template #tab>
<span class="tab-label">
<ShieldCheck :size="15" />操作权限 <a-badge :count="draft.powers.length" />
</span>
</template>
<ModulePowerPanel v-model:powers="draft.powers" />
</a-tab-pane>
<a-tab-pane key="i18n">
<template #tab>
<span class="tab-label"><Languages :size="15" />多语言</span>
</template>
<ModuleTranslationsPanel
v-model:translations="draft.translations"
:keys="translationKeys"
<ModulePowerPanel
v-model:powers="draft.powers"
:module-id="draft.module.b_id"
:module-code="draft.module.b_code"
/>
</a-tab-pane>
</a-tabs>
@@ -664,7 +641,8 @@ onMounted(async () => {
>
<p>
确定删除
{{ pendingDeleteModule?.b_name || pendingDeleteKey }} 吗?关联字段、权限和多语言将一并删除。
{{ pendingDeleteModule?.b_name || pendingDeleteKey }}
吗?关联字段和权限将一并删除;共享多语言资源会保留在多语言中心。
</p>
</a-modal>
</div>
+11 -11
View File
@@ -3,8 +3,8 @@
## 设计约定
- 表名和字段名沿用旧系统的 `b_` 风格。
- `b_module.b_id` 直接使用模块代码,例如 `M_BIZ`,创建后不可修改。
- 其他表的 `b_id` 使用 `bigint` 雪花 ID,返回前端时序列化为字符串。
- 所有表的 `b_id` 使用 `bigint` 雪花 ID,返回前端时序列化为字符串。
- `b_module.b_code` 是可修改的模块编码,使用小写字母、数字和下划线,例如 `m_biz`。
- 布尔字段使用 `int`:`1` 表示是,`0` 表示否,并限制只能写入 `0` 或 `1`。
- `b_xh` 表示显示顺序,数值越小越靠前。
- 当前按机构使用独立数据库,表中不保存机构编号。
@@ -31,8 +31,9 @@ b_module / b_module_field / b_module_power
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `b_id` | `varchar(50)` | 主键,模块代码,例如 `M_BIZ` |
| `b_parent_id` | `varchar(50)` | 上级模块代码,根节点为空 |
| `b_id` | `bigint` | 不可变雪花 ID 主键 |
| `b_code` | `varchar(50)` | 可修改的唯一模块编码,例如 `m_biz` |
| `b_parent_id` | `bigint` | 上级模块 ID,根节点为空 |
| `b_name` | `nvarchar(100)` | 默认中文名称 |
| `b_i18n` | `varchar(150)` | 多语言键 |
| `b_type` | `varchar(20)` | `directory`、`page` 或 `data` |
@@ -40,8 +41,7 @@ b_module / b_module_field / b_module_power
| `b_viewtable` | `varchar(128)` | 查询表或视图 |
| `b_savetable` | `varchar(128)` | 保存目标表 |
| `b_keyfield` | `varchar(50)` | 主键字段 |
| `b_orderfield` | `varchar(50)` | 排序字段 |
| `b_ordertype` | `varchar(4)` | `ASC` 或 `DESC` |
| `b_orderfield` | `varchar(500)` | 排序条件,例如 `b_xh ASC, b_id DESC` |
| `b_canmenu` | `int` | 是否显示在菜单,默认 `0` |
| `b_canuse` | `int` | 是否启用,默认 `1` |
| `b_xh` | `int` | 同级显示顺序 |
@@ -49,7 +49,7 @@ b_module / b_module_field / b_module_power
约束:
- `b_parent_id` 外键引用 `b_module.b_id`。
- `b_id` 格式为 `^[A-Z][A-Z0-9_]{1,49}$`。
- `b_code` 唯一,格式为 `^[a-z][a-z0-9_]{1,49}$`。
- `b_route` 非空时唯一。
- 目录不能配置路由和数据表;数据模块不能显示在菜单中。
- 表名、视图名和字段名必须由后端校验,不能直接拼接 SQL。
@@ -61,7 +61,7 @@ b_module / b_module_field / b_module_power
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `b_id` | `bigint` | 雪花 ID 主键 |
| `b_module_id` | `varchar(50)` | 所属模块代码 |
| `b_module_id` | `bigint` | 所属模块 ID |
| `b_field` | `varchar(50)` | 数据库字段名 |
| `b_name` | `nvarchar(100)` | 默认中文名称 |
| `b_i18n` | `varchar(150)` | 多语言键 |
@@ -90,7 +90,7 @@ b_module / b_module_field / b_module_power
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `b_id` | `bigint` | 雪花 ID 主键 |
| `b_module_id` | `varchar(50)` | 所属模块代码 |
| `b_module_id` | `bigint` | 所属模块 ID |
| `b_code` | `varchar(50)` | 操作编码,例如 `create`、`update`、`delete` |
| `b_name` | `nvarchar(100)` | 操作名称 |
| `b_i18n` | `varchar(150)` | 多语言键 |
@@ -111,7 +111,7 @@ b_module / b_module_field / b_module_power
| --- | --- | --- |
| `b_id` | `bigint` | 雪花 ID 主键 |
| `b_user_id` | `bigint` | 用户雪花 ID |
| `b_module_id` | `varchar(50)` | 模块代码 |
| `b_module_id` | `bigint` | 模块 ID |
| `b_inputuser_id` | `bigint` | 授权人雪花 ID |
| `b_inputdatetime` | `datetime` | 授权时间 |
@@ -140,7 +140,7 @@ b_module / b_module_field / b_module_power
### `dbo.b_i18n`
保存模块、字段和按钮的多语言文本。
统一保存菜单、模块、权限、字段、按钮、提示和校验信息等界面文案;业务对象只绑定稳定的 `b_key`。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
+9 -6
View File
@@ -24,6 +24,8 @@
```text
fms-api/config/migrations/001_module_management.sql
fms-api/config/migrations/002_module_order_condition.sql
fms-api/config/migrations/003_module_surrogate_key.sql
```
按以下顺序建立表:
@@ -49,10 +51,10 @@ fms-api/config/migrations/001_module_management.sql
| 模块 | 类型 | 路由 |
| --- | --- | --- |
| `M_SYSTEM` | `directory` | - |
| `M_MODULE` | `page` | `/system/modules` |
| `m_system` | `directory` | - |
| `m_module` | `page` | `/system/modules` |
为 `M_MODULE` 初始化按钮:
为 `m_module` 初始化按钮:
```text
create 新增模块
@@ -104,8 +106,9 @@ grant 用户授权
- `b_module_field.b_id`
- `b_module_power.b_id`
- `b_i18n.b_id`
- `b_module.b_id`
`b_module.b_id` 直接使用模块代码,不调用取号接口。
`b_module.b_id` 使用不可变雪花 ID;可修改的小写模块编码保存在 `b_module.b_code`。
### 模块保存
@@ -139,7 +142,7 @@ b_i18n
前端保存前校验:
- 模块代码格式正确且创建后不可修改。
- 模块代码使用小写字母、数字和下划线,且在模块间唯一。
- 父节点存在且不会形成循环。
- 目录、页面、数据模块字段组合合法。
- 同一模块字段名和按钮编码不重复。
@@ -149,7 +152,7 @@ b_i18n
### 模块删除
- `M_SYSTEM` 和 `M_MODULE` 在前端禁止删除。
- `m_system` 和 `m_module` 在前端禁止删除。
- 存在下级模块时禁止删除。
- 普通模块删除时,一次 `/data/saveobjt` 按依赖顺序删除: