20260723225202

This commit is contained in:
oneao committed 2026-07-23 22:52:02 +08:00
1 parent 5f73b54802
commit 4d118a0037
22 files changed
+1403 -259

No files matched your search

@@ -0,0 +1,96 @@
---
name: checkbox-cell-unified-config
overview: "将模块管理各面板中的 checkbox 列从 `customCell: createCheckboxCell(...)` 重构为声明式 `editor: { type: \"checkbox\" }`,由 FmsTable 在显示态与编辑态都渲染可交互的 checkbox,面板不再手动导入渲染器。按钮(删除)暂不动,用户仍在考虑。"
todos:
- id: make-checkbox-always-interactive
content: 在 createEditableCellRenderer.js 中将 checkbox 分支独立为始终可交互,复用 update 回调
status: completed
- id: update-panels-checkbox
content: 将 4 个面板 5 处 checkbox 列改为 editor:{type:'checkbox',onChange},并删除 createCheckboxCell 导入
status: completed
dependencies:
- make-checkbox-always-interactive
- id: remove-dead-renderers
content: 从 createCellRenderers.js 删除 createCheckboxCell 与 createSelectCell 死代码
status: completed
dependencies:
- update-panels-checkbox
- id: verify-no-residual
content: 全局搜索确认无 createCheckboxCell/createSelectCell 残留引用,并运行 read_lints
status: completed
dependencies:
- remove-dead-renderers
---
## 用户需求
重构表格单元格渲染方式:对于布尔型字段(如 `b_visible`、`b_canuse`、`b_sortable`),在列配置里直接声明 `editor: { type: "checkbox" }` 即可,显示态与编辑态均为可直接点击的 checkbox,无需面板自己 import 并调用 `createCheckboxCell`。
## 核心要点
- checkbox 类列(共 5 处,跨越 4 个面板)从 `customCell: createCheckboxCell(...)` 改为声明式 `editor: { type: "checkbox", onChange: () => emitUpdate() }`。
- 表格组件内部让 `checkbox` 类型始终可交互(显示态也是可点击 checkbox),不再区分编辑/非编辑态;值按 `col.dataIndex` 自动读写,默认 `checkedValue=1 / uncheckedValue=0`,与现有 1/0 布尔存储一致。
- 删除按钮等 `createDeleteCell` 本次不重构,保持 `customCell` 现状(用户仍在考虑按钮方案)。
- 重构后 `createCheckboxCell` 不再被任何面板引用,从 `createCellRenderers.js` 中移除;`createSelectCell` 已是死代码一并移除。
## 技术栈与既有模式
- 前端:Vue 3 `<script setup>` + Ant Design Vue(antdv-next)+ StkTable(`stk-table-vue`)。
- 列渲染统一由 `FmsTable.vue` 封装:`column.editor && !column.customCell` 的列会被自动包成 `EditableCell`(`createEditableCellRenderer` 产物),业务面板只声明 `editor`,不直接写 `customCell`。
- `EditableCell` 当前已支持 `editor.type === "checkbox"`,但非编辑态渲染为只读静态 checkbox(`pointer-events: none`),编辑态才渲染可交互 checkbox。
## 实现方案
### 1. 让 `EditableCell` 的 checkbox 始终可交互(核心改动)
文件:`fms-vue/src/components/fms-table/utils/createEditableCellRenderer.js`
- 将 checkbox 分支从 `if (!isEditing)` 中抽出,放在最前面独立处理,忽略 `isEditing`:
- 读取 `checkedValue = editor.checkedValue ?? 1`、`uncheckedValue = editor.uncheckedValue ?? 0`。
- 渲染 `h(Checkbox, { checked: cellValue === checkedValue, disabled: resolveOption(editor.disabled, row), onClick: e => e.stopPropagation(), "onUpdate:checked": (checked) => { const v = checked ? checkedValue : uncheckedValue; row[col.dataIndex] = v; editor.onChange?.(v, row, col); } })`。
- 复用现有 `update` 逻辑(已做 `row[col.dataIndex] = value; editor.onChange?.(value, row, col)`),保持与 select/number 一致的行为。
- `if (!isEditing)` 分支内移除 checkbox 的静态处理,仅保留 select 文本展示与默认文本展示。
理由:最少量改动实现"显示=编辑=可交互 checkbox",且复用同一套 `update`/脏数据回调,不改 `FmsTable.vue` 包裹逻辑。
### 2. 面板列配置改造(4 个文件,5 处)
将每处:
```js
customCell: createCheckboxCell("b_canuse", (row, field, value) => { row[field] = value; emitUpdate(); }),
```
改为:
```js
editor: { type: "checkbox", onChange: () => emitUpdate() },
```
涉及:
- `ModuleListConfigPanel.vue`:`b_visible`、`b_sortable` 两列(删除 `import { createCheckboxCell } ...`)。
- `ModuleFieldsPanel.vue`:`b_canuse` 列(删除 `import { createCheckboxCell } ...`)。
- `ModulePowersPanel.vue`:`b_canuse` 列(删除 `import { createCheckboxCell } ...`)。
- `ModuleQueryConfigPanel.vue`:`b_canuse` 列(删除 `import { createCheckboxCell } ...`)。
说明:表格按 `col.dataIndex`(如 `b_canuse`)自动读写,面板无需再传字段名;`emitUpdate` 维持现有脏数据/向上通知机制不变。
### 3. 清理死代码
文件:`fms-vue/src/components/fms-table/utils/createCellRenderers.js`
- 删除 `createCheckboxCell`(重构后无引用)。
- 删除 `createSelectCell`(已无人引用,表格内置 `editor.type==="select"` 已取代)。
- 保留 `createCellRenderer` 基础工厂、`createButtonCell`、`createDeleteCell`、`selectValue`(如有引用)。
## 设计要点 / 性能
- checkbox 复用 `EditableCell` 单实例渲染函数,每次渲染仅 `h(Checkbox, ...)` 纯函数调用,无额外组件层级,无 row 引用常驻。
- 不引入新列属性,沿用 `editor.type` 枚举,与 `select`/`number` 一致,架构可扩展(后续按钮类可类似声明式接入)。
- 不修改 `FmsTable.vue` 的包裹与 `update:columns` 往返逻辑,避免回归。
## 验证
- `read_lints` 检查上述文件 0 报错(除既有未使用变量提示外)。
- 全局搜索 `createCheckboxCell`、`createSelectCell` 引用应仅剩定义处(随后被删除),无面板残留 import。
- 功能:模块管理下"字段定义/列表配置/编辑配置/查询配置/权限配置"面板中布尔列显示并可点击切换,切换后脏标记与保存行为不变;非编辑态下 checkbox 仍可直接点击。
@@ -0,0 +1,177 @@
---
name: i18n-type-management
overview: 新增「多语言类型」功能:在系统管理下增加一个管理页面,维护系统支持的语言清单(简称/编码、名称、是否默认、是否启用、排序),默认中文(zh-CN)不可删除。包含数据库新表、迁移脚本、前端路由/菜单/管理页面,全部复用现有通用接口。
design:
architecture:
framework: vue
styleKeywords:
- 企业后台
- 简洁清爽
- 卡片化表格
- 浅色主题
- 蓝主色
fontSystem:
fontFamily: PingFang SC
heading:
size: 20px
weight: 600
subheading:
size: 14px
weight: 500
body:
size: 13px
weight: 400
colorSystem:
primary:
- "#1677ff"
- "#4096ff"
background:
- "#f5f7fa"
- "#ffffff"
text:
- "#1f2329"
- "#646a73"
functional:
- "#52c41a"
- "#ff4d4f"
- "#faad14"
todos:
- id: add-i18n-type-migration
content: 新建 001_add_i18n_type.sql:建表并播种模块与权限
status: completed
- id: register-route-menu
content: 在 routes.js 与 AppSidebar.vue 注册路由与系统菜单项
status: completed
- id: build-i18n-type-page
content: 使用 [skill:antdv-next] 实现多语言类型管理页面(列表/新增/编辑/删除/启用开关/默认保护)
status: completed
dependencies:
- register-route-menu
---
## 用户需求
在「系统管理」下新增「多语言类型」管理功能,用于维护系统中支持的语言清单。
## 产品概述
新增一张数据库表 `dbo.b_i18n_type` 记录系统支持的语言(简称、名称、是否默认、是否启用、排序),并提供对应的前端管理页面。默认中文(zh-CN)为内置默认语言,不可删除、其编码与默认标记不可修改。页面挂在「系统管理」菜单下,与「模块管理」并列。
## 核心功能
- 语言列表展示:简称(b_code)、名称(b_name)、是否默认、是否启用、排序
- 新增语言:弹窗表单填写编码/名称/是否启用/是否默认/排序
- 编辑语言:弹窗预填,默认语言锁定编码与默认标记
- 删除语言:二次确认;默认语言(中文)禁用删除
- 是否启用开关:行内 `a-switch` 即时保存
- 全部数据读写复用现有通用接口,不新增后端接口
## 技术栈
- 后端:Spring Boot(不直接改代码),复用通用接口 `/data/loaddata`、`/data/saveobjt`、`/data/nextid`
- 前端:Vue 3 + Vite + antdv-next + FmsTable 封装组件 + Pinia + 路由
- 数据库:SQL Server(机构库),新增表迁移脚本
## 实现方案
整体采用与「模块管理」一致的模式:新增一张业务表 + 一个迁移脚本(建表并播种模块/权限),前端新增一个独立路由页面,页面用 `FmsTable` 展示,通过 `loadDataApi / nextIdApi / saveObjectApi` 完成增改删,沿用 `b_` 命名、`bigint` 雪花主键、`int` 布尔、`b_xh` 排序等规范(见 `新数据库结构.md`)。
关键决策:
1. **新建独立模块 `m_i18n_type`** 而非复用 `m_metadata`:保持权限语义独立,与 `000_reset_metadata.sql` 末尾的模块/权限播种方式一致,在迁移中插入 `b_module`(parent=`m_system`, type=`page`, route=`/system/i18n-type`)、`b_module_power`(create/update/delete)、管理员 `b_user_module`/`b_user_power`。前端 `AppSidebar` 与路由守卫按 `moduleCode:"m_i18n_type"` 自动做权限过滤。
2. **不新增 services 文件、不新增后端接口**:页面直接调用 `src/services/api.js` 已有函数,符合开发规范第三条。
3. **启用开关即存即改**:`a-switch` 变更仅 `updates:[{b_id, b_canuse}]`,单字段保存,避免整行回写。
4. **默认语言保护**:渲染时对 `b_default=1` 的行禁用删除按钮;编辑弹窗中 `b_code` 与「是否默认」禁用,仅允许改名称/启用/排序。
性能与可靠性:该表为系统级小表,使用 `loadDataApi` 全量加载并按 `b_xh, b_id` 排序即可,无需分页;保存走 `saveObjectApi` 单事务批量 upsert/delete,失败整体回滚。无 N+1、无重复渲染风险。
## 实现注意事项
- 迁移脚本命名 `001_add_i18n_type.sql`,沿用 `000_reset_metadata.sql` 的建表风格(命名约束、CHECK 约束、bigint 主键、`int` 布尔默认 1)。
- `b_code` 唯一且非空;播种至少 `(zh-CN, 简体中文, b_default=1, b_canuse=1, b_xh=0)`。
- 前端组件 `<style scoped lang="scss">`,少量 CSS,复用 antdv-next 能力与现有桌面端布局,不写移动端媒体查询。
- 保留用户尚未提交的模块管理改动,本任务不触碰 `module-management` 相关文件。
- 按规范交付时不主动执行构建/测试。
## 架构设计
```mermaid
flowchart LR
A[多语言类型页面 index.vue] --> B[services/api.js]
B --> C[/data/loaddata]
B --> D[/data/saveobjt]
B --> E[/data/nextid]
C & D & E --> F[(机构库 dbo.b_i18n_type)]
A --> G[AppSidebar 菜单 + 路由守卫 canModule]
G --> H[(b_user_module / b_module m_i18n_type)]
```
## 目录结构与文件
```
fms-api/config/migrations/
└── 001_add_i18n_type.sql # [NEW] 建表 dbo.b_i18n_type(b_id/b_code/b_name/b_i18n/b_canuse/b_default/b_xh + 约束/索引),并播种模块 m_i18n_type、操作权限、管理员授权,使页面默认可见可用。
fms-vue/src/router/
└── routes.js # [MODIFY] 在 children 中新增 /system/i18n-type 路由,meta 含 title/section/menuKey/moduleCode:"m_i18n_type",组件 import 到 views/system/i18n-type/index.vue。
fms-vue/src/layouts/components/
└── AppSidebar.vue # [MODIFY] 在 systemPages 数组新增 { key:"/system/i18n-type", label:"多语言类型", moduleCode:"m_i18n_type" },沿用 canModule 过滤。
fms-vue/src/views/system/i18n-type/
└── index.vue # [NEW] 多语言类型管理页。FmsTable 列表 + 工具栏「新增」+ 行内「编辑/删除」+ 启用 switch;弹窗表单(antdv-next a-modal/a-form);默认中文锁定;读写走 loadDataApi/nextIdApi/saveObjectApi。scoped scss。
```
## 关键代码结构
新建表 DDL(参考现有 b_ 表风格):
```sql
CREATE TABLE dbo.b_i18n_type (
b_id bigint NOT NULL,
b_code varchar(20) NOT NULL,
b_name nvarchar(100) NOT NULL,
b_i18n varchar(150) NULL,
b_canuse int NOT NULL CONSTRAINT DF_b_i18n_type_canuse DEFAULT (1),
b_default int NOT NULL CONSTRAINT DF_b_i18n_type_default DEFAULT (0),
b_xh int NOT NULL CONSTRAINT DF_b_i18n_type_xh DEFAULT (0),
CONSTRAINT PK_b_i18n_type PRIMARY KEY (b_id),
CONSTRAINT UQ_b_i18n_type_code UNIQUE (b_code),
CONSTRAINT CK_b_i18n_type_canuse CHECK (b_canuse IN (0,1)),
CONSTRAINT CK_b_i18n_type_default CHECK (b_default IN (0,1))
);
CREATE INDEX IX_b_i18n_type_xh ON dbo.b_i18n_type (b_xh, b_id);
```
保存请求体(复用 `saveObjectApi`):
```ts
// { table, key_field, inserts?, updates?, deletes? }[]
{
table: "b_i18n_type",
key_field: "b_id",
inserts: [{ b_id, b_code, b_name, b_i18n, b_canuse, b_default, b_xh }],
updates: [{ b_id, b_name, b_canuse, b_default, b_xh }],
deletes: [{ b_id }]
}
```
## 设计风格
采用与企业后台一致的专业、简洁管理台风格(参考「模块管理」页面),浅色背景、卡片化表格、清晰分区。顶部工具条(新增按钮)+ 下方 FmsTable 列表,行内禁用开关与操作按钮。整体克制、信息密度适中,蓝(#1677ff)为主操作色,桌面端实现,无需响应式断点。弹窗表单沿用 antdv-next Modal/Form 规范,交互顺滑。
## 页面区块(单页)
- 顶部页头:标题「多语言类型」+ 描述,右对齐「新增」主按钮
- 工具条:仅含新增按钮(与模块管理布局一致,间距紧凑)
- 列表区:FmsTable 列含 简称、名称、是否默认(标签)、是否启用(a-switch)、排序、操作(编辑/删除)
- 新增/编辑弹窗:a-form 表单(编码、名称、是否默认、是否启用、排序),默认语言锁定编码与默认
- 删除:a-popconfirm 二次确认;默认行删除按钮禁用
## Agent Extensions
### Skill
- **antdv-next**
- 用途:在实现管理页面时查阅 antdv-next 组件(a-modal、a-form、a-switch、a-popconfirm、a-table 相关)的 API、属性与插槽用法,确保用法正确。
- 预期结果:页面正确复用 antdv-next 组件,弹窗表单与开关交互符合组件规范,不重复造轮子。
+43
View File
@@ -0,0 +1,43 @@
import configparser
import pyodbc
# 读取 git-ignored 的本地机构库配置(不输出任何凭证)
cfg = configparser.ConfigParser()
cfg.read("fms-api/config/dbconfigs/G3HD.properties")
url = cfg.get("DEFAULT", "url", fallback="")
user = cfg.get("DEFAULT", "username", fallback="")
pwd = cfg.get("DEFAULT", "password", fallback="")
# url 形如 jdbc:sqlserver://host:port;databaseName=xxx
host_part = url.split("//", 1)[1].split(";", 1)[0]
if ":" in host_part:
host, port = host_part.split(":", 1)
else:
host, port = host_part, "1433"
print(f"Connecting to {host}:{port} as {user} ...")
conn = pyodbc.connect(
driver="{ODBC Driver 17 for SQL Server}",
server=f"{host},{port}",
uid=user,
pwd=pwd,
autocommit=False,
)
print("Connected OK")
with open("fms-api/config/migrations/001_add_i18n_type.sql", encoding="utf-8") as f:
sql = f.read()
# 按 GO 切分批量执行(SQL Server 约定)
batches = [b.strip() for b in sql.split("\nGO\n") if b.strip()]
cur = conn.cursor()
n = 0
for batch in batches:
cur.execute(batch)
n += 1
conn.commit()
cur.close()
conn.close()
print(f"Migration applied: {n} batch(es) executed on G3HD.")
@@ -0,0 +1,79 @@
/*
新增「多语言类型」表 dbo.b_i18n_type,并注册独立模块 m_i18n_type 与其权限、管理员授权。
用于维护系统支持的语言清单(简称、名称、是否默认、是否启用、排序),默认中文为内置默认语言。
本脚本可重复执行:表、模块、权限均通过存在性判断避免重复播种;
配合 000_reset_metadata.sql 使用(先跑 000 重置,再跑本脚本)即可重建。
注意:b_user 表由 000 脚本保留,本脚本依赖其中 g3soft 管理员账号授于权限。
*/
SET NOCOUNT ON;
BEGIN TRANSACTION;
-- 管理员账号(与 000 脚本保持一致)
DECLARE @AdminId bigint = (
SELECT TOP (1) b_id
FROM dbo.b_user
WHERE b_account = N'g3soft' AND b_canuse = 1
);
-- 系统管理目录(000 脚本播种)
DECLARE @SystemId bigint = (
SELECT TOP (1) b_id FROM dbo.b_module WHERE b_code = N'm_system'
);
-- 1. 多语言类型表
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'b_i18n_type')
BEGIN
CREATE TABLE dbo.b_i18n_type (
b_id bigint NOT NULL,
b_code varchar(20) NOT NULL,
b_name nvarchar(100) NOT NULL,
b_canuse int NOT NULL CONSTRAINT DF_b_i18n_type_canuse DEFAULT (1),
b_default int NOT NULL CONSTRAINT DF_b_i18n_type_default DEFAULT (0),
b_xh int NOT NULL CONSTRAINT DF_b_i18n_type_xh DEFAULT (0),
CONSTRAINT PK_b_i18n_type PRIMARY KEY (b_id),
CONSTRAINT UQ_b_i18n_type_code UNIQUE (b_code),
CONSTRAINT CK_b_i18n_type_canuse CHECK (b_canuse IN (0, 1)),
CONSTRAINT CK_b_i18n_type_default CHECK (b_default IN (0, 1))
);
CREATE INDEX IX_b_i18n_type_xh ON dbo.b_i18n_type (b_xh, b_id);
-- 预置数据:默认中文不可删除,可选英文
INSERT INTO dbo.b_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) VALUES
(920000000000000001, N'zh-CN', N'简体中文', 1, 1, 0),
(920000000000000002, N'en-US', N'English', 1, 0, 10);
END
-- 2. 注册模块与管理员权限(仅在模块不存在时播种)
IF NOT EXISTS (SELECT 1 FROM dbo.b_module WHERE b_code = N'm_i18n_type')
BEGIN
DECLARE @I18nTypeId bigint = 910000000000000003;
INSERT INTO dbo.b_module (
b_id, b_code, b_parent_id, b_name, b_i18n, b_module_type,
b_route, b_canmenu, b_canuse, b_xh
) VALUES (
@I18nTypeId, N'm_i18n_type', @SystemId, N'多语言类型', 'module.m_i18n_type',
'page', N'/system/i18n-type', 1, 1, 20
);
INSERT INTO dbo.b_module_power (b_id, b_module_id, b_code, b_name, b_i18n, b_xh, b_canuse) VALUES
(910000000000000105, @I18nTypeId, 'create', N'新增', 'power.m_i18n_type.create', 10, 1),
(910000000000000106, @I18nTypeId, 'update', N'保存', 'power.m_i18n_type.update', 20, 1),
(910000000000000107, @I18nTypeId, 'delete', N'删除', 'power.m_i18n_type.delete', 30, 1);
IF @AdminId IS NOT NULL
BEGIN
INSERT INTO dbo.b_user_module (b_id, b_user_id, b_module_id, b_inputuser_id) VALUES
(910000000000000202, @AdminId, @I18nTypeId, @AdminId);
INSERT INTO dbo.b_user_power (b_id, b_user_id, b_power_id, b_inputuser_id)
SELECT 910000000000000303 + ROW_NUMBER() OVER (ORDER BY b_id), @AdminId, b_id, @AdminId
FROM dbo.b_module_power
WHERE b_module_id = @I18nTypeId;
END
END
COMMIT TRANSACTION;
@@ -0,0 +1,36 @@
/*
新增「系统管理」目录(m_system),并将「多语言类型」模块(m_i18n_type)挂到其下。
本脚本可重复执行:
- m_system 仅在其不存在时创建;
- m_i18n_type 仅在其父级未指向系统管理目录时才更新。
依赖:b_module 表已存在(由 001_add_i18n_type.sql 或业务库结构保证)。
*/
SET NOCOUNT ON;
BEGIN TRANSACTION;
-- 系统管理目录(若不存在则创建)
DECLARE @SystemId bigint = (SELECT TOP (1) b_id FROM dbo.b_module WHERE b_code = N'm_system');
IF @SystemId IS NULL
BEGIN
SET @SystemId = 911000000000000001;
INSERT INTO dbo.b_module (
b_id, b_code, b_parent_id, b_name, b_i18n, b_module_type,
b_canmenu, b_canuse, b_xh
) VALUES (
@SystemId, N'm_system', NULL, N'系统管理', 'module.m_system',
'directory', 1, 1, 10
);
END
-- 多语言类型挂到系统管理目录
UPDATE dbo.b_module
SET b_parent_id = @SystemId
WHERE b_code = N'm_i18n_type'
AND (b_parent_id IS NULL OR b_parent_id <> @SystemId);
COMMIT TRANSACTION;
@@ -2,6 +2,7 @@
import { computed, nextTick, ref, useSlots } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { StkTable } from "stk-table-vue";
import { createActionsCellRenderer } from "./utils/createActionsCellRenderer";
import { createEditableCellRenderer } from "./utils/createEditableCellRenderer";
import { createSelectionCellRenderers } from "./utils/createSelectionCellRenderers";
@@ -41,14 +42,20 @@ const { SelectionCell, SelectionHeaderCell } = createSelectionCellRenderers({
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
});
// StkTable 的编辑单元格和多选列统一在封装层生成,业务页面只声明 editor / rowSelection。
// StkTable 的编辑单元格、操作列和多选列统一在封装层生成,业务页面只声明 editor / actions / rowSelection。
const tableColumnsModel = computed({
get: () => {
const columns = props.columns.map((column) =>
column.editor && !column.customCell
? { ...column, customCell: EditableCell, __fmsEditor: true }
: column,
);
// 依赖 activeRowKey:切换编辑行时触发 columns 重算,迫使 StkTable 重渲染单元格
void props.activeRowKey;
const columns = props.columns.map((column) => {
if (column.actions && !column.customCell) {
return { ...column, customCell: createActionsCellRenderer(column.actions), __fmsAction: true };
}
if (column.editor && !column.customCell) {
return { ...column, customCell: EditableCell, __fmsEditor: true };
}
return column;
});
if (!props.rowSelection) return columns;
return [
{
@@ -72,10 +79,11 @@ const tableColumnsModel = computed({
value
.filter((column) => !column.__fmsSelection)
.map((column) => {
if (!column.__fmsEditor) return column;
if (!column.__fmsEditor && !column.__fmsAction) return column;
const rest = { ...column };
delete rest.customCell;
delete rest.__fmsEditor;
delete rest.__fmsAction;
return rest;
}),
),
@@ -117,6 +125,18 @@ const forwardSlots = computed(() => {
}
return result;
});
// 滚动到指定行;用于新增后自动聚焦
async function scrollToRow(rowKey) {
await nextTick();
const el = tableRef.value?.$el;
if (!el) return;
const key = String(rowKey);
const rowEl = el.querySelector(`[data-row-key="${key}"]`);
rowEl?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
}
defineExpose({ scrollToRow });
</script>
<template>
@@ -0,0 +1,117 @@
import { h } from "vue";
import { Button, Popconfirm, Dropdown } from "antdv-next";
const stopBubble = (e) => e.stopPropagation();
function resolve(val, row) {
return typeof val === "function" ? val(row) : val;
}
/**
* 将 actions 数组中的 type 映射为 antdv-next Button 的 type + danger 属性。
*
* type 语义是按钮颜色:
* "danger" → type="link" danger
* "primary" → type="primary"
* 其他 → 直接作为 Button type
*/
function mapButtonType(type) {
if (type === "danger") return { btnType: "link", danger: true };
return { btnType: type || "link", danger: false };
}
/**
* 声明式操作列渲染器。
*
* actions 数组每项:
* { type: "primary"|"danger"|"link"|..., label, confirm?, danger?, disabled?, visible?, onClick }
* { type: "more", label?:"更多", items: [{ key, label, danger?, disabled?, onClick }], visible? }
*
* @param {Array} actions
* @returns {Function} customCell 渲染函数
*/
export function createActionsCellRenderer(actions) {
return function ActionsCell({ row }) {
const visibleActions = actions.filter((a) => {
if (a.visible === undefined) return true;
return resolve(a.visible, row);
});
const children = visibleActions.map((action, idx) => {
// ── more 下拉菜单 ──
if (action.type === "more") {
const items = (action.items || []).map((item) => ({
key: item.key,
label: resolve(item.label, row),
danger: item.danger,
disabled: resolve(item.disabled, row),
}));
return h(
Dropdown,
{
key: idx,
menu: {
items,
onClick: ({ key }) => {
const item = action.items.find((i) => i.key === key);
item?.onClick?.(row);
},
},
trigger: ["click"],
},
{
default: () =>
h(
Button,
{
type: "link",
size: "small",
disabled: resolve(action.disabled, row),
onClick: stopBubble,
},
() => resolve(action.label, row) || "更多",
),
},
);
}
// ── 普通按钮(可包含 confirm) ──
const { btnType, danger } = mapButtonType(action.type);
const btnProps = {
type: btnType,
size: "small",
danger: action.danger ?? danger,
disabled: resolve(action.disabled, row),
onClick: (e) => {
stopBubble(e);
if (!action.confirm) action.onClick?.(row);
},
};
const btnNode = h(Button, btnProps, () => resolve(action.label, row));
if (action.confirm) {
const confirmConf =
typeof action.confirm === "object" ? action.confirm : {};
return h(
Popconfirm,
{
key: idx,
title: resolve(confirmConf.title, row) ?? "确定?",
okText: resolve(confirmConf.okText, row),
cancelText: resolve(confirmConf.cancelText, row),
onConfirm: () => action.onClick?.(row),
},
{ default: () => btnNode },
);
}
return h("span", { key: idx }, btnNode);
});
return h(
"span",
{ style: { display: "inline-flex", gap: "4px", alignItems: "center" } },
children,
);
};
}
@@ -0,0 +1,42 @@
import { h } from "vue";
// ── 静态常量(避免每次渲染重复创建) ────────────────────────
const stopBubble = (e) => e.stopPropagation();
// ── 基础工厂 ────────────────────────────────────────────────
/**
* 通用 cell 渲染器工厂。
*
* 返回的函数是纯渲染函数,不保存 row 引用、不产生额外组件层级。
*
* @param {object} config
* @param {object} config.component Vue 组件(Button / Checkbox / Select …)
* @param {object} [config.props] 静态 props(创建一次,不随行变化)
* @param {(row, col, ctx) => object} [config.getProps] 动态 props(每次 cell 渲染调用)
* @param {(row, col, ctx) => object} [config.getChildren] 动态 children / slots
* @param {boolean} [config.stopPropagation=true] 自动注入 onClick stopPropagation
* @returns {Function} (ctx) => VNode customCell 渲染函数
*/
export function createCellRenderer({
component,
props = {},
getProps,
getChildren,
stopPropagation = true,
}) {
return (ctx) => {
const merged = getProps ? { ...props, ...getProps(ctx.row, ctx.col, ctx) } : { ...props };
if (stopPropagation) {
const click = merged.onClick;
merged.onClick = click ? (e) => (e.stopPropagation(), click(e)) : stopBubble;
}
const children = getChildren ? getChildren(ctx.row, ctx.col, ctx) : undefined;
return h(component, merged, children);
};
}
@@ -11,13 +11,24 @@ export function createEditableCellRenderer({ getRowKey, getActiveRowKey }) {
const activeRowKey = getActiveRowKey();
const isEditing = activeRowKey != null && String(getRowKey(row)) === String(activeRowKey);
const update = (value) => {
row[col.dataIndex] = value;
editor.onChange?.(value, row, col);
};
// checkbox 始终可交互(显示态与编辑态一致)
if (editor.type === "checkbox") {
const checkedValue = editor.checkedValue ?? 1;
const uncheckedValue = editor.uncheckedValue ?? 0;
return h(Checkbox, {
checked: cellValue === checkedValue,
disabled: resolveOption(editor.disabled, row),
onClick: (event) => event.stopPropagation(),
"onUpdate:checked": (checked) => update(checked ? checkedValue : uncheckedValue),
});
}
if (!isEditing) {
if (editor.type === "checkbox") {
return h(Checkbox, {
checked: cellValue === (editor.checkedValue ?? 1),
style: { pointerEvents: "none" },
});
}
if (editor.type === "select") {
const options = resolveOption(editor.options, row) || [];
const option = options.find((item) => item.value === cellValue);
@@ -26,14 +37,17 @@ export function createEditableCellRenderer({ getRowKey, getActiveRowKey }) {
return h("span", { class: "fms-cell-text" }, cellValue ?? "");
}
const update = (value) => {
row[col.dataIndex] = value;
editor.onChange?.(value, row, col);
};
const common = {
size: "small",
style: { width: "100%" },
onClick: (event) => event.stopPropagation(),
// 编辑态首次渲染自动聚焦
ref: (instance) => {
if (instance) {
const target = instance.focus ? instance : instance.$el;
target?.focus?.();
}
},
};
if (editor.type === "number") {
@@ -56,16 +70,6 @@ export function createEditableCellRenderer({ getRowKey, getActiveRowKey }) {
"onUpdate:value": update,
});
}
if (editor.type === "checkbox") {
const checkedValue = editor.checkedValue ?? 1;
const uncheckedValue = editor.uncheckedValue ?? 0;
return h(Checkbox, {
checked: cellValue === checkedValue,
disabled: resolveOption(editor.disabled, row),
onClick: (event) => event.stopPropagation(),
"onUpdate:checked": (checked) => update(checked ? checkedValue : uncheckedValue),
});
}
return h(Input, {
...common,
value: cellValue,
@@ -15,6 +15,7 @@ const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
const menuItems = computed(() => {
const systemPages = [
{ key: "/system/module-management", label: "模块管理", moduleCode: "m_metadata" },
{ key: "/system/i18n-type", label: "多语言类型", moduleCode: "m_i18n_type" },
].filter((item) => permissionStore.canModule(item.moduleCode));
const items = [{ key: "/dashboard", icon: menuIcon(House), label: "工作台" }];
if (systemPages.length)
+11
View File
@@ -35,6 +35,17 @@ const routes = [
moduleCode: "m_metadata",
},
},
{
path: "system/i18n-type",
name: "system-i18n-type",
component: () => import("@/views/system/i18n-type/index.vue"),
meta: {
title: "多语言类型",
section: "系统管理",
menuKey: "/system/i18n-type",
moduleCode: "m_i18n_type",
},
},
],
},
{
@@ -1,10 +1,14 @@
<script setup>
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import { Languages } from "@lucide/vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
const props = defineProps({
moduleId: { type: String, required: true },
data: { type: Object, default: null },
loading: { type: Boolean, default: false },
parentName: { type: String, default: "" },
});
const emit = defineEmits(["update"]);
@@ -32,6 +36,85 @@ function setField(field, value) {
formData.value[field] = value === "" ? null : value;
emitUpdate();
}
// 多语言键由模块编码自动生成(module.<编码>),无需手动填写。
function onCodeInput(v) {
setField("b_code", v);
if (!formData.value.b_i18n && v) {
formData.value.b_i18n = `module.${v}`;
emitUpdate();
}
}
const i18nModalVisible = ref(false);
const i18nSaving = ref(false);
const i18nKey = ref("");
const i18nEntries = ref([]);
const langTypes = ref([]);
async function loadLangTypes() {
try {
const res = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
langTypes.value = res.data || [];
} catch (e) {
message.error("加载语言类型失败:" + (e.message || e));
}
}
async function openI18nModal() {
const key = formData.value?.b_i18n || `module.${formData.value?.b_code || ""}`;
i18nKey.value = key;
const escaped = key.replace(/'/g, "''");
try {
const [i18nRes] = await Promise.all([
loadDataApi("b_i18n", `b_key = '${escaped}'`, "b_locale ASC"),
loadLangTypes(),
]);
const rows = i18nRes.data || [];
const byLocale = Object.fromEntries(rows.map((r) => [r.b_locale, r]));
i18nEntries.value = langTypes.value.map((t) => {
const ex = byLocale[t.b_code];
const isDefault = t.b_default === 1 || t.b_code === "zh-CN";
return {
b_id: ex ? ex.b_id : null,
b_locale: t.b_code,
b_name: t.b_name,
b_value: ex ? ex.b_value : isDefault ? formData.value?.b_name || "" : "",
};
});
} catch {
i18nEntries.value = [];
}
i18nModalVisible.value = true;
}
async function saveI18n() {
if (!i18nKey.value) {
message.warning("请填写多语言键");
return;
}
i18nSaving.value = true;
try {
setField("b_i18n", i18nKey.value);
const req = { table: "b_i18n", key_field: "b_id", inserts: [], updates: [] };
for (const e of i18nEntries.value) {
const value = e.b_value ?? "";
if (!e.b_id && !value) continue;
const row = { b_key: i18nKey.value, b_locale: e.b_locale, b_value: value };
if (e.b_id) req.updates.push({ ...row, b_id: e.b_id });
else req.inserts.push(row);
}
if (req.inserts.length || req.updates.length) {
await saveObjectApi([req]);
}
message.success("多语言已保存");
i18nModalVisible.value = false;
} catch (err) {
message.error("保存失败:" + (err?.message || err));
} finally {
i18nSaving.value = false;
}
}
</script>
<template>
@@ -44,7 +127,7 @@ function setField(field, value) {
<a-input
:value="formData.b_code"
placeholder="如 m_sea"
@update:value="(v) => setField('b_code', v)"
@update:value="onCodeInput"
/>
</a-form-item>
</a-col>
@@ -75,16 +158,26 @@ function setField(field, value) {
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="上级模块">
<a-input :value="formData.b_parent_id" disabled placeholder="通过左侧树选中后自动设置" />
<a-input :value="parentName" readonly placeholder="通过左侧树选中后自动设置" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="多语言键">
<a-input
:value="formData.b_i18n"
placeholder="如 module.m_sea"
@update:value="(v) => setField('b_i18n', v)"
/>
<div class="i18n-key-row">
<a-input
:value="formData.b_i18n"
readonly
placeholder="填写模块编码后自动生成"
/>
<a-button
class="i18n-key-btn"
type="text"
title="快速设置多语言"
@click="openI18nModal"
>
<Languages :size="16" />
</a-button>
</div>
</a-form-item>
</a-col>
<a-col :span="4">
@@ -151,6 +244,25 @@ function setField(field, value) {
</a-row>
</template>
</a-form>
<a-modal
v-model:open="i18nModalVisible"
title="快速设置多语言"
:confirm-loading="i18nSaving"
@ok="saveI18n"
>
<a-form layout="vertical">
<a-form-item label="多语言键">
<a-input v-model:value="i18nKey" disabled />
</a-form-item>
<a-form-item label="多语言值">
<div v-for="(item, idx) in i18nEntries" :key="idx" class="i18n-entry-row">
<span class="i18n-locale-label">{{ item.b_name }} ({{ item.b_locale }})</span>
<a-input v-model:value="item.b_value" class="i18n-value" />
</div>
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
@@ -171,4 +283,37 @@ function setField(field, value) {
.basic-form {
max-width: 960px;
}
.i18n-key-row {
display: flex;
align-items: center;
gap: 4px;
.ant-input {
flex: 1;
min-width: 0;
}
}
.i18n-key-btn {
flex: none;
}
.i18n-entry-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.i18n-locale-label {
flex: none;
width: 140px;
color: #646a73;
font-size: 13px;
}
.i18n-value {
flex: 1;
}
}
</style>
@@ -3,7 +3,6 @@ import { computed, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import FieldGroupManager from "./FieldGroupManager.vue";
import { createDeleteCell, createSelectCell, createToggleCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
@@ -17,6 +16,7 @@ const emit = defineEmits(["update", "updateGroups"]);
const localEdit = ref([]);
const groupManagerVisible = ref(false);
const activeKey = ref(null);
watch(
() => props.editConfig,
@@ -30,11 +30,6 @@ function emitUpdate() {
emit("update", localEdit.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === undefined || value === null ? null : value;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
@@ -72,7 +67,7 @@ function makeSwitchCol(field, title, width) {
title,
width: width || 60,
align: "center",
customCell: createToggleCell(field, (row) => setField(row, field, row[field] === 1 ? 0 : 1)),
editor: { type: "checkbox", onChange: () => emitUpdate() },
};
}
@@ -81,12 +76,11 @@ const columns = [
dataIndex: "b_field_id",
title: "字段",
width: 160,
customCell: createSelectCell({
value: "b_field_id",
getOptions: () => availableFields.value,
onChange: (row, v) => setField(row, "b_field_id", v),
placeholder: "选择字段",
}),
editor: {
type: "select",
options: () => availableFields.value,
onChange: () => emitUpdate(),
},
},
makeSwitchCol("b_required", "必填"),
makeSwitchCol("b_readonly", "只读"),
@@ -95,12 +89,11 @@ const columns = [
dataIndex: "b_group_id",
title: "编辑分组",
width: 160,
customCell: createSelectCell({
value: "b_group_id",
getOptions: () => groupOptions.value,
onChange: (row, v) => setField(row, "b_group_id", v),
placeholder: "选择分组",
}),
editor: {
type: "select",
options: () => groupOptions.value,
onChange: () => emitUpdate(),
},
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
@@ -108,7 +101,7 @@ const columns = [
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
},
];
</script>
@@ -122,7 +115,13 @@ const columns = [
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localEdit" row-key="b_id" />
<FmsTable
:columns="columns"
:data-source="localEdit"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(ev, row) => (activeKey = row.b_id)"
/>
</div>
<FieldGroupManager
v-model:visible="groupManagerVisible"
@@ -144,13 +143,13 @@ const columns = [
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
padding: 12px 20px 0;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
padding: 12px 20px 16px;
display: flex;
flex-direction: column;
}
@@ -1,7 +1,6 @@
<script setup>
import { ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createSelectCell, createToggleCell } from "./cellRenderers";
const FIELD_TYPES = [
{ label: "输入框", value: "input" },
@@ -24,6 +23,7 @@ const props = defineProps({
const emit = defineEmits(["update", "sync"]);
const localFields = ref([]);
const activeKey = ref(null);
watch(
() => props.fields,
@@ -37,11 +37,6 @@ function emitUpdate() {
emit("update", localFields.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === "" ? null : value;
emitUpdate();
}
const columns = [
{ dataIndex: "b_field", title: "字段名", width: 140 },
{ dataIndex: "b_name", title: "字段名称", width: 140 },
@@ -49,11 +44,11 @@ const columns = [
dataIndex: "b_type",
title: "控件类型",
width: 130,
customCell: createSelectCell({
value: "b_type",
getOptions: () => FIELD_TYPES,
onChange: (row, v) => setField(row, "b_type", v),
}),
editor: {
type: "select",
options: FIELD_TYPES,
onChange: () => emitUpdate(),
},
},
{ dataIndex: "b_default_value", title: "默认值", width: 120 },
{ dataIndex: "b_i18n", title: "多语言键", width: 140 },
@@ -62,7 +57,7 @@ const columns = [
title: "启用",
width: 70,
align: "center",
customCell: createToggleCell("b_canuse", (row) => setField(row, "b_canuse", row.b_canuse === 1 ? 0 : 1)),
editor: { type: "checkbox", onChange: () => emitUpdate() },
},
];
</script>
@@ -81,7 +76,13 @@ const columns = [
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localFields" row-key="b_id" />
<FmsTable
:columns="columns"
:data-source="localFields"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(ev, row) => (activeKey = row.b_id)"
/>
</div>
</div>
</template>
@@ -97,13 +98,13 @@ const columns = [
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
padding: 12px 20px 0;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
padding: 12px 20px 16px;
display: flex;
flex-direction: column;
}
@@ -1,11 +1,10 @@
<script setup>
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import { computed, h, nextTick, ref, watch } from "vue";
import { message, Select } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
import { createDeleteCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
@@ -17,12 +16,30 @@ const originals = ref([]);
const isLoading = ref(false);
const isSaving = ref(false);
const searchKey = ref("");
const langOptions = ref([]);
const activeKey = ref(null);
const tableRef = ref(null);
async function loadLangTypes() {
try {
const res = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
langOptions.value = (res.data || []).map((t) => ({
label: `${t.b_name} (${t.b_code})`,
value: t.b_code,
}));
} catch (e) {
message.error("加载语言类型失败:" + (e.message || e));
}
}
async function loadI18n() {
isLoading.value = true;
try {
const res = await loadDataApi("b_i18n", null, "b_key ASC, b_locale ASC");
originals.value = res.data || [];
const [i18nRes] = await Promise.all([
loadDataApi("b_i18n", null, "b_key ASC, b_locale ASC"),
loadLangTypes(),
]);
originals.value = i18nRes.data || [];
i18nData.value = cloneData(originals.value);
} catch (e) {
message.error("加载多语言失败:" + (e.message || e));
@@ -33,13 +50,19 @@ async function loadI18n() {
watch(() => props.moduleId, loadI18n, { immediate: true });
function addRow() {
async function addRow() {
const def = langOptions.value[0]?.value || "zh-CN";
const newId = createTempId();
i18nData.value.push({
b_id: createTempId(),
b_id: newId,
b_key: "",
b_locale: "zh-CN",
b_locale: def,
b_value: "",
});
activeKey.value = String(newId);
await nextTick();
tableRef.value?.scrollToRow(String(newId));
}
function deleteRow(record) {
@@ -76,16 +99,34 @@ const displayedData = computed(() => {
);
});
function createLocaleCell() {
return (props) => {
const row = props.row;
return h(
Select,
{
value: row.b_locale,
size: "small",
style: { width: "100%" },
options: langOptions.value,
onChange: (val) => {
row.b_locale = val;
},
},
);
};
}
const columns = [
{ dataIndex: "b_key", title: "键", width: 200 },
{ dataIndex: "b_locale", title: "语言", width: 100 },
{ dataIndex: "b_locale", title: "语言", width: 160, customCell: createLocaleCell() },
{ dataIndex: "b_value", title: "翻译文本", width: 300 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
},
];
</script>
@@ -105,7 +146,7 @@ const columns = [
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="displayedData" row-key="b_id" />
<FmsTable ref="tableRef" :columns="columns" :data-source="displayedData" row-key="b_id" :active-row-key="activeKey" @row-click="(_, row) => (activeKey = String(row.b_id))" />
</div>
</div>
</template>
@@ -121,13 +162,13 @@ const columns = [
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
padding: 12px 20px 0;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
padding: 12px 20px 16px;
display: flex;
flex-direction: column;
}
@@ -3,7 +3,6 @@ import { computed, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import FieldGroupManager from "./FieldGroupManager.vue";
import { createDeleteCell, createSelectCell, createToggleCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
@@ -17,6 +16,7 @@ const emit = defineEmits(["update", "updateGroups"]);
const localList = ref([]);
const groupManagerVisible = ref(false);
const activeKey = ref(null);
watch(
() => props.listConfig,
@@ -30,11 +30,6 @@ function emitUpdate() {
emit("update", localList.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === undefined || value === null ? null : value;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
@@ -73,12 +68,11 @@ const columns = [
dataIndex: "b_field_id",
title: "字段",
width: 160,
customCell: createSelectCell({
value: "b_field_id",
getOptions: () => availableFields.value,
onChange: (row, v) => setField(row, "b_field_id", v),
placeholder: "选择字段",
}),
editor: {
type: "select",
options: () => availableFields.value,
onChange: () => emitUpdate(),
},
},
{ dataIndex: "b_title", title: "列标题", width: 120 },
{ dataIndex: "b_width", title: "列宽", width: 80 },
@@ -86,26 +80,25 @@ const columns = [
dataIndex: "b_group_id",
title: "分组",
width: 140,
customCell: createSelectCell({
value: "b_group_id",
getOptions: () => groupOptions.value,
onChange: (row, v) => setField(row, "b_group_id", v),
placeholder: "选择分组",
}),
editor: {
type: "select",
options: () => groupOptions.value,
onChange: () => emitUpdate(),
},
},
{
dataIndex: "b_visible",
title: "显示",
width: 60,
align: "center",
customCell: createToggleCell("b_visible", (row) => setField(row, "b_visible", row.b_visible === 1 ? 0 : 1)),
editor: { type: "checkbox", onChange: () => emitUpdate() },
},
{
dataIndex: "b_sortable",
title: "排序",
width: 60,
align: "center",
customCell: createToggleCell("b_sortable", (row) => setField(row, "b_sortable", row.b_sortable === 1 ? 0 : 1)),
editor: { type: "checkbox", onChange: () => emitUpdate() },
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
@@ -113,7 +106,7 @@ const columns = [
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
},
];
</script>
@@ -127,7 +120,13 @@ const columns = [
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localList" row-key="b_id" />
<FmsTable
:columns="columns"
:data-source="localList"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(ev, row) => (activeKey = row.b_id)"
/>
</div>
<FieldGroupManager
v-model:visible="groupManagerVisible"
@@ -149,13 +148,13 @@ const columns = [
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
padding: 12px 20px 0;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
padding: 12px 20px 16px;
display: flex;
flex-direction: column;
}
@@ -1,11 +1,11 @@
<script setup>
import { ref, watch } from "vue";
import { nextTick, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import { createDeleteCell, createToggleCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
moduleCode: { type: String, default: "" },
powers: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
@@ -13,11 +13,20 @@ const props = defineProps({
const emit = defineEmits(["update"]);
const localPowers = ref([]);
const activeKey = ref(null);
const tableRef = ref(null);
// 多语言键按 power.<模块代码>.<操作编码> 自动生成,无需手动填写。
watch(
() => props.powers,
(val) => {
localPowers.value = val?.map((r) => ({ ...r })) || [];
() => [props.powers, props.moduleCode],
([val]) => {
localPowers.value = (val || []).map((r) => ({
...r,
b_i18n:
r.b_code && props.moduleCode
? `power.${props.moduleCode}.${r.b_code}`
: (r.b_i18n ?? null),
}));
},
{ immediate: true },
);
@@ -26,9 +35,10 @@ function emitUpdate() {
emit("update", localPowers.value.map((r) => ({ ...r })));
}
function addRow() {
async function addRow() {
const newId = createTempId();
localPowers.value.push({
b_id: createTempId(),
b_id: newId,
b_module_id: props.moduleId,
b_code: "",
b_name: "",
@@ -37,6 +47,10 @@ function addRow() {
b_canuse: 1,
});
emitUpdate();
activeKey.value = String(newId);
await nextTick();
tableRef.value?.scrollToRow(String(newId));
}
function deleteRow(record) {
@@ -45,26 +59,23 @@ function deleteRow(record) {
}
const columns = [
{ dataIndex: "b_code", title: "操作编码", width: 160 },
{ dataIndex: "b_name", title: "操作名称", width: 160 },
{ dataIndex: "b_code", title: "操作编码", width: 160, editor: { onChange: () => emitUpdate() } },
{ dataIndex: "b_name", title: "操作名称", width: 160, editor: { onChange: () => emitUpdate() } },
{ dataIndex: "b_i18n", title: "多语言键", width: 160 },
{ dataIndex: "b_xh", title: "排序", width: 80 },
{ dataIndex: "b_xh", title: "排序", width: 80, editor: { onChange: () => emitUpdate() } },
{
dataIndex: "b_canuse",
title: "启用",
width: 70,
align: "center",
customCell: createToggleCell("b_canuse", (row) => {
row.b_canuse = row.b_canuse === 1 ? 0 : 1;
emitUpdate();
}),
editor: { type: "checkbox", onChange: () => emitUpdate() },
},
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
},
];
</script>
@@ -77,7 +88,7 @@ const columns = [
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localPowers" row-key="b_id" />
<FmsTable ref="tableRef" :columns="columns" :data-source="localPowers" row-key="b_id" :active-row-key="activeKey" @row-click="(_, row) => (activeKey = String(row.b_id))" />
</div>
</div>
</template>
@@ -93,13 +104,13 @@ const columns = [
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
padding: 12px 20px 0;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
padding: 12px 20px 16px;
display: flex;
flex-direction: column;
}
@@ -2,7 +2,6 @@
import { computed, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import { createDeleteCell, createSelectCell, createToggleCell } from "./cellRenderers";
const COMPONENT_OPTIONS = [
{ label: "输入框", value: "input" },
@@ -28,6 +27,7 @@ const props = defineProps({
const emit = defineEmits(["update"]);
const localQuery = ref([]);
const activeKey = ref(null);
watch(
() => props.queryConfig,
@@ -41,11 +41,6 @@ function emitUpdate() {
emit("update", localQuery.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === undefined || value === null ? null : value;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
@@ -76,32 +71,31 @@ const columns = [
dataIndex: "b_field_id",
title: "字段",
width: 160,
customCell: createSelectCell({
value: "b_field_id",
getOptions: () => availableFields.value,
onChange: (row, v) => setField(row, "b_field_id", v),
placeholder: "选择字段",
}),
editor: {
type: "select",
options: () => availableFields.value,
onChange: () => emitUpdate(),
},
},
{
dataIndex: "b_component",
title: "查询组件",
width: 140,
customCell: createSelectCell({
value: "b_component",
getOptions: () => COMPONENT_OPTIONS,
onChange: (row, v) => setField(row, "b_component", v),
}),
editor: {
type: "select",
options: COMPONENT_OPTIONS,
onChange: () => emitUpdate(),
},
},
{
dataIndex: "b_operator",
title: "操作符",
width: 140,
customCell: createSelectCell({
value: "b_operator",
getOptions: () => OPERATOR_OPTIONS,
onChange: (row, v) => setField(row, "b_operator", v),
}),
editor: {
type: "select",
options: OPERATOR_OPTIONS,
onChange: () => emitUpdate(),
},
},
{ dataIndex: "b_default_value", title: "默认查询值", width: 140 },
{
@@ -109,7 +103,7 @@ const columns = [
title: "启用",
width: 60,
align: "center",
customCell: createToggleCell("b_canuse", (row) => setField(row, "b_canuse", row.b_canuse === 1 ? 0 : 1)),
editor: { type: "checkbox", onChange: () => emitUpdate() },
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
@@ -117,7 +111,7 @@ const columns = [
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
},
];
</script>
@@ -130,7 +124,13 @@ const columns = [
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localQuery" row-key="b_id" />
<FmsTable
:columns="columns"
:data-source="localQuery"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(ev, row) => (activeKey = row.b_id)"
/>
</div>
</div>
</template>
@@ -146,13 +146,13 @@ const columns = [
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
padding: 12px 20px 0;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
padding: 12px 20px 16px;
display: flex;
flex-direction: column;
}
@@ -1,64 +0,0 @@
import { h } from "vue";
import { Button, Popconfirm, Select } from "antdv-next";
// 单元格渲染器统一采用 stk-table-vue 的 customCell 写法:
// customCell 会被当作函数式组件调用(props 含 row/col/rowIndex/cellValue),
// 必须返回 h(...) 生成的 VNode,返回普通对象会渲染成 [object Object]。
// 删除操作单元格
export function createDeleteCell(onDelete) {
return (props) =>
h(
Popconfirm,
{
title: "确定删除该记录?",
okText: "删除",
okButtonProps: { danger: true },
onConfirm: () => onDelete(props.row),
},
{
default: () =>
h(
Button,
{ type: "link", size: "small", danger: true, onClick: (e) => e.stopPropagation() },
"删除",
),
},
);
}
// 是否(0/1)切换单元格
export function createToggleCell(field, onChange) {
return (props) => {
const checked = props.row[field] === 1;
return h(
"span",
{
style: { cursor: "pointer" },
onClick: (e) => {
e.stopPropagation();
onChange(props.row, field);
},
},
checked ? "是" : "否",
);
};
}
// 下拉选择单元格。getOptions 返回最新选项(保持响应式)。
export function createSelectCell({ value, getOptions, onChange, placeholder, allowClear }) {
return (props) =>
h(Select, {
value:
props.row[value] != null && props.row[value] !== ""
? String(props.row[value])
: undefined,
size: "small",
style: { width: "100%" },
options: getOptions(),
allowClear: allowClear !== false,
placeholder: placeholder || "请选择",
"onUpdate:value": (v) => onChange(props.row, v),
onClick: (e) => e.stopPropagation(),
});
}
@@ -1,6 +1,6 @@
<script setup>
import { computed, h, ref, watch } from "vue";
import { message } from "antdv-next";
import { message, Modal } from "antdv-next";
import FmsTree from "@/components/FmsTree.vue";
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
@@ -20,6 +20,26 @@ const treeLoading = ref(false);
const selectedModuleId = ref(null);
const skipLoad = ref(false);
const activeTab = ref("basic");
const contextNode = ref(null);
const contextMenuOpen = ref(false);
const contextMenuItems = computed(() => {
const node = contextNode.value;
const items = [];
if (!node) return { items };
if (node.b_module_type === "directory") {
items.push(
{ label: "添加目录", key: "add-directory" },
{ label: "添加页面", key: "add-page" },
{ label: "添加数据", key: "add-data" },
);
} else if (node.b_module_type === "page") {
items.push({ label: "添加数据", key: "add-data" });
}
if (items.length) items.push({ type: "divider" });
items.push({ label: "删除", key: "delete", danger: true });
return { items, onClick: onContextMenuClick };
});
// 原始数据(加载时保存,用于 diff)
const originals = ref({
@@ -49,6 +69,21 @@ const configSaving = ref(false);
// 当前选中模块对象(从 drafts.module 获取)
const selectedModule = computed(() => drafts.value.module);
// 上级模块名称(从模块树解析 b_parent_id 对应的名称,用于基本信息展示)
const parentModuleName = computed(() => {
const pid = selectedModule.value?.b_parent_id;
if (!pid) return "";
const find = (nodes) => {
for (const n of nodes || []) {
if (String(n.b_id) === String(pid)) return n.b_name || n.b_code;
const child = find(n.children);
if (child) return child;
}
return "";
};
return find(treeData.value);
});
// 可见 Tab
const visibleTabs = computed(() => {
const type = selectedModule.value?.b_module_type;
@@ -62,10 +97,12 @@ const visibleTabs = computed(() => {
{ key: "query", tab: "查询配置", icon: "Search" },
);
}
tabs.push(
{ key: "powers", tab: "权限配置", icon: "Lock" },
{ key: "i18n", tab: "多语言", icon: "Translation" },
);
if (type === "page") {
tabs.push({ key: "powers", tab: "权限配置", icon: "Lock" });
}
if (type !== "directory") {
tabs.push({ key: "i18n", tab: "多语言", icon: "Translation" });
}
return tabs;
});
@@ -210,21 +247,43 @@ async function onAddModule(parentId, moduleType = "directory") {
const addModuleMenu = {
items: [
{ key: "directory", label: "目录" },
{ key: "page", label: "页面" },
{ key: "data", label: "数据" },
],
onClick: ({ key }) => onAddModule(null, key),
};
// ---- 删除模块 ----
async function onDeleteModule() {
const moduleId = selectedModuleId.value;
if (!moduleId) return;
// ---- 右键菜单 ----
function onTreeRightClick({ node }) {
contextNode.value = node;
}
function onContextMenuClick({ key }) {
contextMenuOpen.value = false;
const node = contextNode.value;
contextNode.value = null;
if (!node) return;
if (key.startsWith("add-")) {
onAddModule(node.key, key.replace("add-", ""));
} else if (key === "delete") {
Modal.confirm({
title: `确定删除「${node.title}」?`,
content: "此操作不可恢复",
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => onDeleteNodeById(node.key),
});
}
}
async function onDeleteNodeById(moduleId) {
try {
await saveObjectApi([{ table: "b_module", key_field: "b_id", deletes: [{ b_id: moduleId }] }]);
message.success("模块已删除");
selectedModuleId.value = null;
drafts.value.module = null;
if (selectedModuleId.value === moduleId) {
selectedModuleId.value = null;
drafts.value.module = null;
}
await loadTree();
} catch (e) {
message.error("删除失败:" + (e.message || e));
@@ -412,23 +471,28 @@ loadTree();
<div v-if="treeData.length === 0" class="mm-tree-empty">
<a-empty description="暂无模块" />
</div>
<FmsTree
<a-dropdown
v-else
class="mm-tree"
:tree-data="treeData"
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
:field-names="{ title: 'title', key: 'key', children: 'children' }"
@select="onTreeSelect"
:trigger="['contextmenu']"
:menu="contextMenuItems"
>
<template #title="{ key, title }">
<span class="mm-tree-node">
<span>{{ title }}</span>
<span class="mm-tree-node-actions">
<a-button type="text" @click.stop="onAddModule(key)">+</a-button>
</span>
</span>
</template>
</FmsTree>
<div style="flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;">
<FmsTree
class="mm-tree"
:tree-data="treeData"
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
:field-names="{ title: 'title', key: 'key', children: 'children' }"
@select="onTreeSelect"
@right-click="onTreeRightClick"
>
<template #title="node">
<span class="mm-tree-node">
<span>{{ node.title }}</span>
</span>
</template>
</FmsTree>
</div>
</a-dropdown>
</aside>
<!-- 右侧详情 -->
@@ -438,9 +502,9 @@ loadTree();
<div class="mm-header">
<a-space>
<a-button type="primary" :loading="configSaving" @click="saveAll">保存</a-button>
<a-popconfirm title="确定删除该模块?" @confirm="onDeleteModule">
<a-button danger>删除模块</a-button>
</a-popconfirm>
<a-popconfirm title="确定删除该模块?" @confirm="onDeleteNodeById(selectedModuleId)">
<a-button danger>删除模块</a-button>
</a-popconfirm>
</a-space>
<div class="mm-header-center">
<a-space size="middle">
@@ -481,6 +545,7 @@ loadTree();
v-if="tab.key === 'basic'"
:module-id="String(selectedModule.b_id)"
:data="drafts.module"
:parent-name="parentModuleName"
:loading="configSaving"
@update="(v) => onUpdate('module', v)"
/>
@@ -524,6 +589,7 @@ loadTree();
<ModulePowersPanel
v-else-if="tab.key === 'powers'"
:module-id="String(selectedModule.b_id)"
:module-code="selectedModule?.b_code || ''"
:powers="drafts.powers"
:loading="configSaving"
@update="(v) => onUpdate('powers', v)"
@@ -585,7 +651,7 @@ loadTree();
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 0;
padding: 8px 10px;
}
.mm-tree-empty {
@@ -602,15 +668,7 @@ loadTree();
justify-content: space-between;
width: 100%;
gap: 4px;
}
.mm-tree-node-actions {
display: none;
flex: none;
}
.mm-tree-node:hover .mm-tree-node-actions {
display: flex;
user-select: none;
}
.mm-main {
@@ -687,6 +745,11 @@ loadTree();
:deep(.ant-tabs-tabpane) {
height: 100%;
}
:deep(.ant-tabs-body),
:deep(.ant-tabs-body-top) {
height: 100%;
}
}
.mm-loading {
@@ -0,0 +1,322 @@
<script setup>
import { computed, h, onMounted, ref } from "vue";
import { message, Switch, Button, Popconfirm, Tag } from "antdv-next";
import { Plus } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi, nextIdApi } from "@/services/api";
import { usePermissionStore } from "@/stores/permissions";
const permissionStore = usePermissionStore();
const data = ref([]);
const loading = ref(false);
const saving = ref(false);
const canCreate = computed(() => permissionStore.canPower("m_i18n_type", "create"));
const canUpdate = computed(() => permissionStore.canPower("m_i18n_type", "update"));
const canDelete = computed(() => permissionStore.canPower("m_i18n_type", "delete"));
async function loadData() {
loading.value = true;
try {
const res = await loadDataApi("b_i18n_type", "", "b_xh ASC, b_id ASC");
data.value = res.data || [];
} catch (e) {
message.error("加载多语言类型失败:" + (e?.message || e));
} finally {
loading.value = false;
}
}
onMounted(loadData);
/* ---------- 行内单元格渲染 ---------- */
function createDefaultCell() {
return (props) =>
props.row.b_default === 1
? h(Tag, { color: "blue" }, "默认")
: h("span", { class: "muted" }, "—");
}
function createCanuseCell() {
return (props) => {
const row = props.row;
return h(Switch, {
checked: row.b_canuse === 1,
size: "small",
disabled: !canUpdate.value,
"onUpdate:checked": (val) => {
const next = val ? 1 : 0;
if (row.b_canuse === next) return;
row.b_canuse = next;
saveRow(row, { b_canuse: next });
},
});
};
}
function createActionsCell() {
return (props) => {
const row = props.row;
const isDefault = row.b_default === 1;
const children = [];
if (canUpdate.value) {
children.push(
h(
Button,
{ type: "link", size: "small", onClick: (e) => { e.stopPropagation(); openEdit(row); } },
"编辑",
),
);
}
if (canDelete.value && !isDefault) {
children.push(
h(
Popconfirm,
{
title: "确定删除该语言?",
okText: "删除",
okButtonProps: { danger: true },
onConfirm: () => removeRow(row),
},
{
default: () =>
h(
Button,
{ type: "link", size: "small", danger: true, onClick: (e) => e.stopPropagation() },
"删除",
),
},
),
);
}
return h("span", { class: "actions-cell" }, children);
};
}
const columns = [
{ dataIndex: "b_code", title: "简称 / 编码", width: 150 },
{ dataIndex: "b_name", title: "名称", width: 160 },
{ dataIndex: "b_default", title: "默认", width: 80, align: "center", customCell: createDefaultCell() },
{ dataIndex: "b_canuse", title: "是否启用", width: 100, align: "center", customCell: createCanuseCell() },
{ dataIndex: "b_xh", title: "排序", width: 80, align: "center" },
{ dataIndex: "actions", title: "操作", width: 130, align: "center", customCell: createActionsCell() },
];
/* ---------- 行内开关即时保存 ---------- */
async function saveRow(row, patch) {
try {
await saveObjectApi([
{ table: "b_i18n_type", key_field: "b_id", updates: [{ b_id: row.b_id, ...patch }] },
]);
message.success("已更新");
} catch (e) {
message.error("更新失败:" + (e?.message || e));
await loadData();
}
}
async function removeRow(row) {
if (row.b_default === 1) return;
saving.value = true;
try {
await saveObjectApi([
{ table: "b_i18n_type", key_field: "b_id", deletes: [{ b_id: row.b_id }] },
]);
message.success("删除成功");
await loadData();
} catch (e) {
message.error("删除失败:" + (e?.message || e));
} finally {
saving.value = false;
}
}
/* ---------- 新增 / 编辑弹窗 ---------- */
const modalVisible = ref(false);
const modalMode = ref("create");
const editingId = ref(null);
const editingDefault = ref(false);
const form = ref({
b_code: "",
b_name: "",
b_xh: 0,
defaultOn: false,
canuseOn: true,
});
function openCreate() {
modalMode.value = "create";
editingId.value = null;
editingDefault.value = false;
form.value = { b_code: "", b_name: "", b_xh: 0, defaultOn: false, canuseOn: true };
modalVisible.value = true;
}
function openEdit(row) {
modalMode.value = "edit";
editingId.value = row.b_id;
editingDefault.value = row.b_default === 1;
form.value = {
b_code: row.b_code,
b_name: row.b_name,
b_xh: row.b_xh ?? 0,
defaultOn: row.b_default === 1,
canuseOn: row.b_canuse === 1,
};
modalVisible.value = true;
}
async function saveForm() {
const f = form.value;
if (!f.b_code || !f.b_code.trim()) {
message.warning("请填写语言编码");
return;
}
if (!f.b_name || !f.b_name.trim()) {
message.warning("请填写语言名称");
return;
}
saving.value = true;
try {
const isNew = modalMode.value === "create" || !editingId.value;
const b_id = isNew ? (await nextIdApi(1)).data : editingId.value;
const row = {
b_id,
b_code: f.b_code.trim(),
b_name: f.b_name.trim(),
b_canuse: f.canuseOn ? 1 : 0,
b_default: f.defaultOn ? 1 : 0,
b_xh: f.b_xh ?? 0,
};
const req = { table: "b_i18n_type", key_field: "b_id", inserts: [], updates: [], deletes: [] };
if (isNew) req.inserts.push(row);
else req.updates.push(row);
// 保证仅一个默认语言:若本次设为默认,则将其它语言取消默认
if (row.b_default === 1) {
for (const r of data.value) {
if (r.b_id !== b_id && r.b_default === 1) {
req.updates.push({ b_id: r.b_id, b_default: 0 });
}
}
}
await saveObjectApi([req]);
message.success("保存成功");
modalVisible.value = false;
await loadData();
} catch (e) {
message.error("保存失败:" + (e?.message || e));
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="i18n-type-page">
<div class="panel-card">
<div class="panel-toolbar">
<a-button v-if="canCreate" type="primary" @click="openCreate">
<Plus :size="16" /> 新增语言
</a-button>
</div>
<div class="panel-body">
<FmsTable
:columns="columns"
:data-source="data"
row-key="b_id"
:loading="loading"
:pagination="false"
/>
</div>
</div>
<a-modal
v-model:open="modalVisible"
:title="modalMode === 'create' ? '新增语言' : '编辑语言'"
:confirm-loading="saving"
@ok="saveForm"
@cancel="modalVisible = false"
>
<a-form layout="vertical">
<a-form-item label="语言编码(简称)" required>
<a-input
v-model:value="form.b_code"
:disabled="editingDefault"
/>
</a-form-item>
<a-form-item label="语言名称" required>
<a-input v-model:value="form.b_name" />
</a-form-item>
<a-form-item label="是否默认">
<a-switch v-model:checked="form.defaultOn" :disabled="editingDefault" />
<span class="form-tip">设为默认后,其它语言将自动取消默认</span>
</a-form-item>
<a-form-item label="是否启用">
<a-switch v-model:checked="form.canuseOn" />
</a-form-item>
<a-form-item label="排序">
<a-input-number v-model:value="form.b_xh" :min="0" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped lang="scss">
.i18n-type-page {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
}
.panel-card {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
overflow: hidden;
}
.panel-toolbar {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
&__title {
font-size: 14px;
font-weight: 500;
color: #1f2329;
}
}
.panel-body {
flex: 1;
min-height: 0;
padding: 12px 16px;
}
.actions-cell {
display: inline-flex;
gap: 4px;
align-items: center;
}
.muted {
color: #c0c4cc;
}
.form-tip {
margin-left: 8px;
font-size: 12px;
color: #646a73;
}
</style>
+1
View File
@@ -29,6 +29,7 @@
- 新增 CSS 应限制在当前组件作用域内,避免影响其他页面。
- 不需要兼容移动端,按桌面端实现即可。
- 表格尽量使用 `fms-table` 组件(项目封装的表格组件),不重复从零实现表格能力。
- 表单输入控件不需要添加 placeholder(占位提示文字),用字段 label 或说明文字表达意图即可。
## 四、变更范围