From 4fc9040a8aab34fec4a720352244341266fe4c39 Mon Sep 17 00:00:00 2001 From: oneao Date: Sun, 2 Aug 2026 22:05:44 +0800 Subject: [PATCH] 20260802220544 --- .../plans/file-api-implementation_11cb90d6.md | 27 + code/fms/.workbuddy/memory/2026-08-02.md | 166 +++ code/fms/.workbuddy/memory/MEMORY.md | 18 + code/fms/.workbuddy/skills/fms-dev/SKILL.md | 490 ++++++++ .../fms-dev/references/api-reference.md | 148 +++ .../skills/fms-dev/references/schema.md | 136 +++ code/fms/CLAUDE.md | 114 ++ code/fms/fms-api/.gitignore | 3 + .../027_add_othercompany_category_i18n.sql | 24 + .../config/migrations/028_add_edit_width.sql | 13 + .../029_add_otherdata_sort_field.sql | 63 + .../030_drop_otherdata_category_i18n.sql | 19 + .../031_drop_othercompany_category_i18n.sql | 19 + .../migrations/032_rebuild_otherdata.sql | 384 ++++++ code/fms/fms-api/pom.xml | 14 +- .../fmsapi/config/FileStorageProperties.java | 68 ++ .../g3soft/fmsapi/config/JwtAuthFilter.java | 1 + .../fmsapi/controller/FileController.java | 112 ++ .../cn/g3soft/fmsapi/database/OrgContext.java | 22 + .../fmsapi/database/OrgDataSourceFactory.java | 9 +- .../cn/g3soft/fmsapi/service/FileService.java | 267 +++++ .../src/main/resources/application.yaml | 13 +- .../fms-api/target/classes/application.yaml | 13 +- .../config/FileStorageProperties$Local.class | Bin 0 -> 963 bytes .../config/FileStorageProperties$Oss.class | Bin 0 -> 743 bytes .../fmsapi/config/FileStorageProperties.class | Bin 0 -> 1731 bytes .../g3soft/fmsapi/config/JwtAuthFilter.class | Bin 4567 -> 4601 bytes .../fmsapi/controller/FileController.class | Bin 0 -> 6660 bytes .../g3soft/fmsapi/database/OrgContext.class | Bin 1319 -> 1716 bytes .../database/OrgDataSourceFactory.class | Bin 3446 -> 3544 bytes .../g3soft/fmsapi/service/FileService.class | Bin 0 -> 13970 bytes .../OtherDataMigration$OldBmflEntry.class | Bin 0 -> 436 bytes .../OtherDataMigration$TableMapping.class | Bin 0 -> 442 bytes .../tools/migration/OtherDataMigration.class | Bin 0 -> 34308 bytes .../tools/migration/OtherDataMigration.java | 1022 ++++++++++++++++ code/fms/fms-api/tools/migration/README.md | 9 +- .../migration/mssql-jdbc-13.4.0.jre11.jar | Bin 0 -> 1547706 bytes code/fms/fms-vue/package.json | 1 + .../src/components/fms-file/FileCard.vue | 277 +++++ .../components/fms-file/FileCategoryPanel.vue | 86 ++ .../src/components/fms-file/FileListPanel.vue | 808 +++++++++++++ .../components/fms-file/FilePreviewPanel.vue | 233 ++++ .../src/components/fms-file/FmsFile.vue | 234 ++++ .../src/components/fms-file/FmsFileModal.vue | 88 ++ .../fms-file/composables/useFileCategory.js | 65 + .../fms-file/composables/useFileList.js | 192 +++ .../src/components/fms-file/fileVisual.js | 62 + .../src/components/fms-file/useFileModal.js | 46 + .../fms-module-list/FmsModuleListPage.vue | 314 ++++- .../fms-module-list/FmsModuleTable.vue | 52 +- .../src/components/fms-table/FmsTable.vue | 17 +- .../fms-table/utils/createCellRenderer.js | 15 + .../utils/createEditableCellRenderer.js | 5 +- .../fms/fms-vue/src/layouts/DefaultLayout.vue | 12 +- code/fms/fms-vue/src/services/fileService.js | 147 +++ code/fms/fms-vue/src/styles/main.scss | 49 +- .../src/views/base/othercompany/index.vue | 44 +- .../src/views/base/otherdata/index.vue | 128 +- .../fms/fms-vue/src/views/dashboard/index.vue | 1066 ++++------------- code/fms/分类树多语言实施计划.md | 66 + 60 files changed, 6273 insertions(+), 908 deletions(-) create mode 100644 code/fms/.codebuddy/plans/file-api-implementation_11cb90d6.md create mode 100644 code/fms/.workbuddy/memory/2026-08-02.md create mode 100644 code/fms/.workbuddy/memory/MEMORY.md create mode 100644 code/fms/.workbuddy/skills/fms-dev/SKILL.md create mode 100644 code/fms/.workbuddy/skills/fms-dev/references/api-reference.md create mode 100644 code/fms/.workbuddy/skills/fms-dev/references/schema.md create mode 100644 code/fms/CLAUDE.md create mode 100644 code/fms/fms-api/config/migrations/027_add_othercompany_category_i18n.sql create mode 100644 code/fms/fms-api/config/migrations/028_add_edit_width.sql create mode 100644 code/fms/fms-api/config/migrations/029_add_otherdata_sort_field.sql create mode 100644 code/fms/fms-api/config/migrations/030_drop_otherdata_category_i18n.sql create mode 100644 code/fms/fms-api/config/migrations/031_drop_othercompany_category_i18n.sql create mode 100644 code/fms/fms-api/config/migrations/032_rebuild_otherdata.sql create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/config/FileStorageProperties.java create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/FileController.java create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/FileService.java create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Local.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Oss.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/FileController.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/FileService.class create mode 100644 code/fms/fms-api/tools/migration/OtherDataMigration$OldBmflEntry.class create mode 100644 code/fms/fms-api/tools/migration/OtherDataMigration$TableMapping.class create mode 100644 code/fms/fms-api/tools/migration/OtherDataMigration.class create mode 100644 code/fms/fms-api/tools/migration/OtherDataMigration.java create mode 100644 code/fms/fms-api/tools/migration/mssql-jdbc-13.4.0.jre11.jar create mode 100644 code/fms/fms-vue/src/components/fms-file/FileCard.vue create mode 100644 code/fms/fms-vue/src/components/fms-file/FileCategoryPanel.vue create mode 100644 code/fms/fms-vue/src/components/fms-file/FileListPanel.vue create mode 100644 code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue create mode 100644 code/fms/fms-vue/src/components/fms-file/FmsFile.vue create mode 100644 code/fms/fms-vue/src/components/fms-file/FmsFileModal.vue create mode 100644 code/fms/fms-vue/src/components/fms-file/composables/useFileCategory.js create mode 100644 code/fms/fms-vue/src/components/fms-file/composables/useFileList.js create mode 100644 code/fms/fms-vue/src/components/fms-file/fileVisual.js create mode 100644 code/fms/fms-vue/src/components/fms-file/useFileModal.js create mode 100644 code/fms/fms-vue/src/services/fileService.js create mode 100644 code/fms/分类树多语言实施计划.md diff --git a/code/fms/.codebuddy/plans/file-api-implementation_11cb90d6.md b/code/fms/.codebuddy/plans/file-api-implementation_11cb90d6.md new file mode 100644 index 00000000..1955b9fa --- /dev/null +++ b/code/fms/.codebuddy/plans/file-api-implementation_11cb90d6.md @@ -0,0 +1,27 @@ +--- +name: file-api-implementation +overview: 实现文件组件的前后端接口层(不含页面组件)。后端新增 FileStorageProperties 配置类、FileService 业务逻辑、FileController 控制器(5 个端点)。前端新增 fileService.js 服务层。支持 local/aliyun-oss 双模式,通过 fms.file.storage-type 切换,使用 moduleId 作为模块标识。 +todos: + - id: modify-configs + content: 修改 application.yaml 新增 fms.file.* 配置段,修改 fms-api/.gitignore 追加 data/files/ + status: completed + - id: create-properties + content: 新建 FileStorageProperties.java 配置属性类(纯 getter/setter,无逻辑) + status: completed + dependencies: + - modify-configs + - id: create-file-service + content: 新建 FileService.java,实现 localUpload / ossCredential / delete 三个核心方法 + status: completed + dependencies: + - create-properties + - id: create-file-controller + content: 新建 FileController.java,5 个端点(config/upload/upload-credential/delete/static) + status: completed + dependencies: + - create-file-service + - id: create-frontend-service + content: 新建 fileService.js,封装上传/凭证/删除/预览逻辑,根据 storageType 自动切换链路 + status: completed +--- + diff --git a/code/fms/.workbuddy/memory/2026-08-02.md b/code/fms/.workbuddy/memory/2026-08-02.md new file mode 100644 index 00000000..1c72b59e --- /dev/null +++ b/code/fms/.workbuddy/memory/2026-08-02.md @@ -0,0 +1,166 @@ +# 2026-08-02 + +## 项目分析 + Skill 创建 +- 深入分析了 fms-api(Spring Boot + SQL Server 通用数据接口)和 fms-vue(Vue 3 + antdv-next)的完整架构 +- 创建了项目级 CodeBuddy Skill:`fms-dev`,位置 `.workbuddy/skills/fms-dev/` +- Skill 包含: + - `SKILL.md` — 开发模式速查(后端 3 种模式、前端 4 种页面模式、迁移脚本模板、踩坑清单、后端/前端代码约定、多语言规范)+ **ERP 前端样式规范**(三条禁令、间距/颜色/字体体系、弹窗规则、组件选型表,共 8 个子章节) + - `references/schema.md` — 模块管理六表 + 扩展配置表完整 schema + - `references/api-reference.md` — 所有通用端点详细参数文档 + +## fms-file 组件 UI 重构 +- 第一次误删了卡片网格和预览面板,用户指出只要改 UI 风格不要砍功能 → 回退后重新改造。 + +### 正确改造(保留全部功能,仅调 UI) +**FileCard.vue** — 缩略图 110→80px,背景 gradient→纯色,去掉 transition/box-shadow/backdrop-filter/hover 遮罩,按钮始终可见,选中态改用 1px 描边 +**FilePreviewPanel.vue** — 工具栏 44→36px,背景色调浅,图片去阴影、图标缩 96→72px +**FileListPanel.vue** — Header/Footer 44→36px,grid gap 12→8px,padding 收紧 +**FmsFile.vue** — 三面板 default-size 各缩 10%(240→220, 700→660),border-radius 6→4px +**FmsFileModal.vue** — 宽 1400→1200px,高 620→580px,标题改"附件管理" +**Dashboard** — 演示按钮文案更新 +- 编译验证通过(vite build: 833ms, 0 errors)。 + +### 追加:卡片/列表双模式 + 缩略图自适应 +**FileCard.vue** — `object-fit: cover` → `contain`,图片在固定卡片尺寸内完整展示不裁剪 +**FileListPanel.vue** — 新增 `viewMode`(grid/list)双模式切换: +- Header 右侧加 LayoutGrid/List 两个图标按钮,active 态高亮 +- 列表模式用 `a-table`(size=small),列:文件名(可点击预览)/大小/类型(tag)/操作(下载+删除) +- 列表与卡片共享同一 `selectedFileIds`,切换模式时选中状态保持 +- 列表模式待上传文件显示"待上传"tag,checkbox 禁用 +- `ResizeObserver` 自适应表格滚动高度 +- 编译验证通过(vite build: 822ms, 0 errors) + +### 追加:Modal 百分比布局 + 待上传文件 checkbox +**FmsFileModal.vue** — 宽度 `80%`,高度 `calc(70vh - 55px)`,自适应屏幕分辨率 +**FileCard.vue** — 去掉待上传文件 checkbox 的 `v-if="!isPending"` 限制,待上传文件也显示 checkbox +**FileListPanel.vue** — 修复 5 处阻止待上传文件选中的逻辑: +- `allChecked` 基于全部 `displayFiles` 而非仅 `uploadedFiles` +- `listRowSelection` 去掉 `disabled: !!record._pending` +- `toggleAll` 全选包含待上传 +- `onFileSelect` 去掉 `if (file._pending) return` 拦截 +- 模板 `:selected` 去掉 `!f._pending &&` 条件 +- `deleteSelected` 拆分为已上传(调接口)和待上传(从队列移除),降序删除防索引漂移 +- 编译验证通过(vite build: 792ms, 0 errors) + +### 追加:UI 优化(按钮默认大小、模式切换右移、空状态居中、拖拽/粘贴/点击上传) +- 所有按钮去掉 `size="small"`(FileListPanel header/footer/列表 + FileCard 删除/下载),header/footer 高度 36→40px +- 模式切换按钮移到 header 最右侧(位于删除按钮之后) +- 空状态仅显示"暂无相关文件"居中,去掉"选择文件"按钮和 `#description` 模板 +- 新增拖拽上传:`fl-main` 区域监听 dragenter/dragover/dragleave/drop,含蓝色虚线遮罩提示"释放文件到此处" +- 新增粘贴上传:`document.addEventListener("paste", handlePaste)` 检测剪贴板文件 +- 提取 `addFiles(fileList)` 统一处理点击/拖拽/粘贴文件的入队逻辑 +- 编译验证通过(vite build: 783ms, 0 errors) + +### fms-file 视觉优化(frontend-design skill 驱动,用户确认"全部落地") +- 新增 `fileVisual.js`:集中 EXT_COLOR 扩展名语义配色映射、extColor/extFromName/isImageType/formatSize,消除 FileCard/FileListPanel/FilePreviewPanel 三处重复 +- `main.scss` 补充令牌:`--fms-primary:#1677ff --fms-danger:#ff4d4f --fms-warning:#fa8c16` +- **令牌统一**:fms-file 全部组件硬编码灰/蓝(#e5e7eb/#fafafa/#8b8f96/#1677ff/#cf1322/#91caff)替换为 `--fms-*`,与全站设计语言对齐 +- **三栏顶部对齐**:FilePreviewPanel 工具栏 36→44px;FileCategoryPanel 新增 44px"分类"标题条;三栏顶部齐平 +- **FileCard 去噪音**:缩略图只留 checkbox;删除/下载改为 hover/选中才显现的底部操作条(不再四角浮动);"待上传"去掉 opacity 降透明度改左侧琥珀色边角标 + 文件名旁琥珀 tag;选中改 inset 2px 实色描边 +- **彩色类型方块(签名元素)**:按扩展名语义着色(PDF红/DOC蓝/XLS绿/IMG紫/ZIP橙…),卡片缩略图、列表 ext chip、预览通用图标三处一致复用 +- **间距/排版**:`.fl-main` 四边统一 12px;网格 gap 8→10px;消灭 10–11px 极小字,卡片名 13px/500、meta 12px/400;空态 height:100% 居中 +- 拖拽遮罩改 `--fms-primary` 虚线 + color-mix 底色 +- 编译验证通过(vite build: 769ms, 0 errors) + +### fms-file 允许重复上传同一文件 +- 问题:上传一次后无法再次上传相同文件。排查结论:后端 `FileService` 无任何同名去重(每次 INSERT 新 subid + UUID 磁盘名),唯一拦截点是前端 `addFiles()` 内的 `pendingFiles.some(name+size+lastModified)` 跨调用去重。 +- 改动:移除 `addFiles()` 中去重比较,允许同一文件在多次独立操作(重新选/再拖一次)里反复加入待上传队列并提交。拖拽重复入栈问题此前已由 dragenter/dragleave 的 `currentTarget` 判断修好,无需保留该去重。 +- 验证:vite build 756ms 0 errors。 + +### fms-file 修复 `--fms-border is not defined` + 删除分类标题节点 +- 根因:`a-modal` 默认把内容 teleport 到 ``,脱离了 `.theme-root`;而 `--fms-*` 令牌原先只定义在 `.theme-root`,导致模态框内全部令牌未定义 → 报 "--fms-border is not defined",且卡片/面板边框、颜色全部失效(这也解释了上一轮用户问"卡片怎么没边框")。 +- 修复 `main.scss`:把 10 个 `--fms-*` 令牌从 `.theme-root` 提升到 `:root`,全局可用;`.theme-root` 仅保留 color/background/overflow。 +- 按要求删除 FileCategoryPanel.vue 的 `
分类
` 标题节点及其 `&__header` 样式块(该节点仅含未定义令牌引用,删除后 `fc-panel__tree` 以 flex:1 撑满,布局完整)。 +- 验证:vite build 777ms 0 errors。 + +### fms-file 卡片删除多余黄色竖线 +- 来源:FileCard.vue 的 `.fcard__stripe`(width:3px / background:var(--fms-warning) 左侧琥珀竖条,仅待上传卡片渲染),表现为多余黄色线条。 +- 删除模板 `` 节点及其 `.fcard__stripe` 样式块;待上传状态仍由「待上传」amber 文字标签(.fcard__tag)表达。 +- 其余黄色(待上传 tag、ZIP/RAR ext 配色)为语义标记保留;无伪元素黄色线条。 +- 验证:vite build 758ms 0 errors。 + +### fms-file 卡片加常驻「下载/删除/更多」操作栏 +- FileCard.vue:移除原本 hover/选中才出现的半透明浮层 `.fcard__actions`;改为卡片底部常驻 `.fcard__footer` 操作栏(下载 / 删除 / 更多 三等分,顶部 1px 分隔线,删除与更多之间加竖向分隔线)。 +- 「更多」用 `a-dropdown` + `a-menu`,当前含「预览」一项(可扩展);`onMoreClick` 按 menu key 分发。 +- 待上传卡片:下载/更多 disabled,删除按钮文案变「移除」。 +- 图标新增 Eye、ChevronDown。 +- 验证:vite build 766ms 0 errors。 + +### fms-file 卡片底部操作栏 hover 闪烁/错位修复 +- 现象:hover 卡片「下载/删除/更多」操作栏时出现闪烁、错位、疑似多出边框。 +- 根因:antdv-next 按钮自带的 `::before`/`::after` 波纹伪元素 hover/点击时出现边框状残影并闪烁;按钮默认 `outline` 焦点环也表现为边框;且 `.ant-btn` 默认 `transition: all` 会在 hover 时引发微重排。 +- 修复 FileCard.vue `.fcard__footer`:关闭 `.ant-btn` 的 `::before`/`::after`(`display:none !important`)、`outline:none`,固定 `box-sizing:border-box`,并将 transition 限缩为 `background-color,color`(去掉 all),hover 仅做纯背景/颜色切换。 +- 验证:vite build 1.06s 0 errors。 + +### fms-file 卡片取消 hover 放大 + 选中去 box-shadow +- 现象:hover 卡片时看似「放大」(实为 `.fcard:hover` 边框由浅灰变蓝,视觉上像被框大);选中态 `box-shadow: inset 0 0 0 2px` 描边用户不想要。 +- 改动 FileCard.vue:删除 `.fcard` 的 `transition: border-color` 与 `&:hover { border-color: primary }`(hover 不再改边框,仅 footer 按钮给反馈);`&--selected` 去掉 `box-shadow`,仅保留 `border-color: var(--fms-primary)` 纯蓝 1px 边框区分选中(不引起 1px 位移)。 +- 验证:vite build 783ms 0 errors。 + +### fms-file 卡片操作按钮 hover 收缩修复 +- 现象:hover 底部操作栏「下载/移除」按钮时感觉收缩。 +- 根因:上一轮去闪烁时把按钮基线 `border` 设为 `none`(宽度 0);而 antdv 按钮默认预留 `border: 1px solid transparent`,hover 时边框宽度被补回 → `box-sizing:border-box` 下内容区被挤 1px,视觉收缩。 +- 修复 FileCard.vue `.fcard__footer`:基线改为 `border: 1px solid transparent` 恒定预留;hover 显式 `border-color: transparent`;分隔线按钮仅改 `border-left-color`,宽度始终恒定,不再收缩。 +- 验证:vite build 778ms 0 errors。 + +### fms-file 空状态「暂无相关文件」垂直水平居中(两次修正) +- 现象:空状态仅在自身盒子内居中、贴顶,未在文件区整体垂直居中。 +- 第一版(无效):`.fl-empty--fill` 改 `position:absolute; inset:0`。错误假设定位上下文是 `.fl-main`。 +- 真实根因(查 antdv 编译 CSS 确认):`.ant-spin-container` 默认 `position:relative` 且高度 `auto`。空状态绝对定位就近解析到 `.ant-spin-container`(而非 `.fl-main`),而它高度 auto → inset:0 只填满内容高度,仍贴顶。 +- 正确修复:在 `.fl-spin` 中追加 `:deep(.ant-spin-container){ height:100% }`,让容器撑满(高度链:`.fl-main`确定高 → `.ant-spin`/`.fl-spin` height:100% → `.ant-spin-container` height:100%),空状态 inset:0 才真正铺满文件区并居中。`.fl-empty--fill` 保持 `position:absolute; inset:0`。 +- 教训:antdv `a-spin` 的 `.ant-spin-container` 自带 relative + auto 高度,绝对定位子元素会被它截胡,必须显式撑满该容器。 +- 验证:vite build 758ms 0 errors。 + +### fms-file toolbar 重构:下载/删除移到左侧 + 模式切换改 a-segmented 图标模式 +- FileListPanel.vue header:把「下载」「删除」两个按钮从右侧(spacer 之后)移到「全选」checkbox 之后(左侧);`fl-header__spacer`(flex:1)保留在按钮之后、把右侧推给模式切换。 +- 右侧模式切换:去掉原先两个 `a-button`+`a-tooltip` 文字按钮,改为 `a-segmented`(`v-model:value="viewMode"`、`:options="modeOptions"`、`size="small"`);`modeOptions` 仅含 `{ value, icon }`(grid→LayoutGrid、list→List),不含 label → 仅图标。 +- 脚本:`import { h } from "vue"`,`modeOptions` 用 `() => h(LayoutGrid,{size:14})` 渲染图标项(antdv-next 1.4.2 的 Segmented 支持 options 的 icon 字段,无 label 时仅渲染图标)。 +- 样式:`.fl-header__modes` 去掉原 `is-active` 文字按钮激活样式,改为 `:deep(.ant-segmented-item-selected){ color: var(--fms-primary) }` 同步选中态主题色。 +- 验证:vite build 798ms 0 errors。 +- 后续修复:仅图标模式下 segmented 图标顶部被裁。根因:antdv `.ant-segmented-item-label` 默认 `overflow:hidden` + 基线对齐,inline svg 上移 1~2px 被裁。修复:对该 label 加 `:deep(.ant-segmented-item-label){ display:flex; align-items:center; justify-content:center; overflow:visible }`(仅作用于 `.fl-header__modes` 内 segmented)。验证 vite build 802ms 0 errors。 +- **按官方写法重做**:用户要求不要特意改样式、参考 antdv 官方 Segmented 案例。改用 antdv 自带图标 `AppstoreOutlined`(grid)/`BarsOutlined`(list)(来自 `@antdv-next/icons`),`options` 直接 `icon: h(AppstoreOutlined)` 不传固定 size(图标按 1em 缩放,天然不裁切);删除上一版的 `.ant-segmented-item-label` overflow 兜底样式与选中色覆盖,回归官方无定制外观。 +- 依赖处理:`@antdv-next/icons` 原先只是 antdv-next 的传递依赖(pnpm 严格模式顶层无软链,裸导入会失败),已加入 `package.json` dependencies(`^1.1.1`);本机 pnpm 不可用(`corepack pnpm install` 报 "packages field missing or empty" 环境错误),改用 PowerShell `New-Item -ItemType Junction` 把 store 内包软链到 `node_modules/@antdv-next/icons` 使其可解析。lockfile 已含该包,无需下载。验证 vite build 801ms 0 errors。 +- **环境教训**:本仓库 fms-vue 用 pnpm 但本机 pnpm 跑不起来(workspace 配置报错),需要新增/链接依赖时优先用 junction 软链到 `.pnpm` store,而非强跑 pnpm install。 + +- **分区配色优化(fl-header/fl-footer/fp-toolbar/fp-content)**:用户反馈这些区域背景/文字色过近、边界模糊、融为一体。原状:三个工具条都用 `--fms-hover`(#f2f3f5 浅灰)、文件内容区白底、预览内容区 `--fms-bg`(#f5f7fa),不一致且灰条贴白底对比弱。改法(ERP 克制、靠对比不靠重阴影):工具条统一改白 `--fms-surface` + 1px `--fms-border` 分隔线(浮起条),内容区统一改浅灰画布 `--fms-bg`(文件区 `.fl-main` 显式加 bg,预览区 `.fp-content` 本就灰)。白条夹灰画布,层次分明且整体协调。同步把 `.fl-main--dragover` 拖拽底色从混白改为混 `--fms-bg`。验证 vite build 788ms 0 errors。 +- **fl-header/fp-toolbar 高度统一 + 面板分隔**:① `fp-toolbar` height 44→48px,与 `fl-header`/`fl-footer`(均48px) 对齐;② 先给 `.fl-panel` 加了 `border-right` 做面板分隔,但用户指出不必逐个加边框,直接改 antdv splitter 分割条中线更通用。改法:撤回 `.fl-panel` 的 border-right,`FmsFile.vue` 的 `.ff-splitter` 作用域内用 `:deep` 覆盖 `.ant-splitter-bar .ant-splitter-bar-dragger::before`(及其 hover 态)的 background,由默认 `--ant-control-item-bg-hover`(近乎隐形) 改为 `var(--fms-border)`,hover 也覆盖以免悬停时分隔变淡。验证 vite build 812ms 0 errors。 + +- **修正:splitter 分隔线全局 scss 方案**:上一轮 scoped :deep 没生效的真因——a-splitter 渲染后 .ff-splitter 与 .ant-splitter 是同一元素(class 合并),选择器写成 .ff-splitter :deep(.ant-splitter > ...) 要求祖先-后代关系,同一节点永远匹配不上。用户要求改用全局 scss。做法:① 删除 FmsFile.vue 中失效的 :deep 块;② 在 main.scss 末尾追加全局规则,直接定位 .ant-splitter-bar .ant-splitter-bar-dragger::before(含 hover 态)background: var(--fms-border) !important。未采用全局改 --ant-control-item-bg-hover 令牌——该令牌被 19 处控件共用(级联菜单/树节点/上传列表/标签页下拉等),全局改会把这些 hover 底色全染灰,影响面过大。验证 vite build 784ms 0 errors。 + +- **fl-main 回白底 + 网格回两列 + 卡片操作栏纯图标**:用户反馈三处:① 文件内容区背景不必灰色,`.fl-main` background 从 `--fms-bg` 改回 `--fms-surface`(白),dragover 混色同步改 surface;② 宫格从一行三个(`repeat(3,…)`)改回一行两个(`repeat(2,…)`);③ 卡片底部操作栏(下载/删除/更多)原用中文文字按钮,等宽三列在其它语言下文字更长会撑破布局,对多语言不友好。改为纯图标按钮(Download/Trash2/MoreHorizontal,去掉文字),图标固定尺寸、等宽列不依赖文字长度,语言无关。`更多`触发器图标由 ChevronDown 换 MoreHorizontal。下拉菜单的「预览」仍为文字(菜单项属次级,不在本次操作行布局问题范围内)。验证 vite build 762ms 0 errors。 + +- **FmsFileModal 加边界约束**:用户确认 modal 用百分比宽高(width 80% + body height calc(70vh-55px))而非固定值,但纯百分比在超宽屏/极小屏/矮屏有坑。在 `.ff-modal` 作用域(.ant-modal)加 `max-width:1440px`(超宽封顶)、`min-width:960px`(极小屏三栏保底),(.ant-modal-body)加 `min-height:520px`(矮屏保底)。保留用户明确的 80%/70vh 不变。验证 vite build 811ms 0 errors。 + +- **卡片 checkbox 去重感**:用户觉得 `.fcard__check` 套层白底+阴影太重。移除该包裹层的 background(rgba白0.92) 和 box-shadow,及固定 20x20 圆角方框,改为仅绝对定位的透明壳,checkbox 直接浮在缩略图左上角。验证 vite build 790ms 0 errors。 + +- **fl-main 滚动失效修复**:用户反馈上传多文件后 fl-main 不出现 Y 轴滚动条、底部 padding 像失效。根因:`.fl-spin` 与 `.ant-spin-container` 都被设成 `height:100%`,把内层盒子锁死成等于 fl-main 可视高的固定盒,超长 `.fl-grid` 只是靠 `overflow:visible` 溢出绘制而不撑大盒子,fl-main(overflow:auto) 收不到超出高度 → 不滚动、底部留白也贴盒底。修复(FileListPanel.vue `.fl-spin`):① 去掉 `height:100%`,改 `min-height:100%`(内容短仍撑满、内容长自然溢出);② `.ant-spin-container` 改 `height:auto; position:static`(取消固定高度、把定位上下文让回 `.fl-main` position:relative),使空态 `.fl-empty--fill`(position:absolute;inset:0) 正确铺满居中、且 grid 溢出时由 fl-main 统一滚动。注意:改 position:static 后加载遮罩 `::after` 会绑定到 fl-main(覆盖整个文件区,属预期更佳效果);spinner 指示器 `.ant-spin-section` 仍绑定 `.ant-spin`(fl-spin, antdv 自带 position:relative) 居中。验证 vite build 793ms 0 errors。 + +### fms-file segmented 图标换 lucide + 居中修复 + 分隔线深浅答疑 +- **segmented 图标换 @lucide/vue**:用户要求把右侧模式切换(`a-segmented`)的图标从 `@antdv-next/icons`(AppstoreOutlined/BarsOutlined) 换成 `@lucide/vue`(LayoutGrid/List)。改动 FileListPanel.vue:移除 antdv 图标导入,改用 `h(LayoutGrid,{size:16})` / `h(List,{size:16})` 渲染图标项(size:16 保证在 segmented 内尺寸合适)。验证 vite build 769ms 0 errors。 +- **lucide 图标在 segmented 内顶部被裁/偏上根因与修复**:antdv `.ant-segmented-item-label` 用固定 `minHeight`+`lineHeight` 做垂直居中并带 `overflow:hidden`;而 `@antdv-next/icons` 的 svg 自带 `display:inline-block`/`vertical-align:-0.125em` 专为基线坐定,lucide 的裸 `` 无这些属性、按默认 `vertical-align:baseline` 对齐 → 顶部溢出被裁。先给 `.ant-segmented-item-icon` 加 inline-flex 居中(仍偏上,因 icon span 在 label 内仍基线对齐);最终让 `.ant-segmented-item-label` 自身 `display:flex; align-items:center; justify-content:center` 解决(此处仅放图标无文字,不影响文字模式)。 +- **居中规则进 main.scss(opt-in 工具类)**:用户问是否把 `.ant-segmented-item-label{flex居中}` 写进 main.scss 全局。结论:不直接 blanket 全局——该规则本质是 lucide 裸 svg 的 workaround,全局化会影响所有(含图文/纯文字)segmented 的 `overflow:hidden` 截断与横排方式。折中:在 main.scss 末尾加 opt-in 类 `.ant-segmented.fms-seg-icon .ant-segmented-item-label{display:flex;align-items:center;justify-content:center}`,组件里去掉 scoped `:deep` 块、给 `` 加 `fms-seg-icon` 类。既一处定义、零波及面,以后别处用图标模式只需加该类。验证 vite build 0 errors。 +- **splitter 分隔线 vs fl-header 边框"深浅不一"答疑**:两者同用 `--fms-border`,颜色值一致,差异来自线宽+发丝线抗锯齿:分隔条中线 `.ant-splitter-bar-dragger::before` 是 2px 实心色带(splitBarSize 默认 2),fl-header 是 1px solid 发丝线——同色下实心 2px 接收更多纯色像素读起来更"实";1px 边框在 DPR=1 屏被抗锯齿淡化。Retina 下差异明显变小。结论:合理层次表现,非 bug,无需修;若强一致性可把 ::before 降 1px。 + +### fms-file Splitter 默认宽度(像素固定 + 预览占余) +- 用户最终确认布局:分类区固定 `220` px、文件列表固定 `420` px、预览区不指定 `default-size`(autoPtgSizes 自动占剩余,min440 保底)。相对最初 220/420/660,实际只把预览区从固定 660 改为自动占剩余。 +- 根因确认(读 antdv `useSizes.js`+`sizeUtil.js`):Splitter 的 `default-size` 数字=px(折算 px/容器宽 的百分比)、字符串"%"=直接百分比、不传=由 `autoPtgSizes` 把 `1−已定义比例之和` 均分给未定义面板;混用 px+% 不溢出(超 100% 时按 scale 缩放、未定义项归 0)。 +- 改动 FmsFile.vue 三面板:① 分类 `:default-size="220"`(min160/max320);② 文件列表 `:default-size="420"`(min280);③ 预览去掉 `:default-size`,仅留 `:min="440" collapsible`。showTree=false 时仅剩列表 420px + 预览占剩余。 +- 注意:最小屏(模态 min-width 960 − 两条分隔条≈4px)下 220+420=640 固定,预览得约 316px,会短暂低于其 440 min(仅出现在最小模态宽度);常规/较宽屏预览正常占余、主导显示。 +- 验证 vite build 779ms 0 errors。 + +### fms-file 空状态「暂无相关文件」可点击选择文件 +- 用户要求:显示「暂无相关文件」时空状态要 cursor:pointer,点击即可选择文件。 +- 改动 FileListPanel.vue:① 空状态 div(`.fl-empty.fl-empty--fill`,仅 `!hasFiles && !loading` 渲染、本就 `position:absolute;inset:0` 铺满文件区)加 `@click="!readonly && triggerSelect()"`——复用隐藏 `uploadInputRef` 唤起文件框(与底部「选择」按钮同一入口),点击文件区任意空白都能触发;② 新增 `.fl-empty--clickable{cursor:pointer}`,并用 `:class="{ 'fl-empty--clickable': !readonly }` 仅非只读时显示手型、点击才生效,只读模式不误导。 +- 验证 vite build 777ms 0 errors。 + +### fms-file 卡片操作列禁用态样式优化 +- 用户反馈:卡片操作列(下载/更多按钮)在 pending(`_pending=true`)禁用时,"禁用样式不明显"。 +- 根因:`.fcard__footer :deep(.ant-btn)` 里无条件写了 `color: var(--fms-text-secondary)` 与恒定透明边框,把 antdv 自带 `:disabled`(变淡灰 + not-allowed)覆盖掉;lucide 图标用 `currentColor`,禁用与启用图标同色、且鼠标仍 pointer → 看不出禁用。 +- 改动:① `main.scss` :root 新增令牌 `--fms-disabled: #c9cdd4`(浅灰,常见禁用色);② `FileCard.vue` footer 的 `:deep(.ant-btn)` 内新增 `&:disabled, &[disabled]` 规则:`color:var(--fms-disabled); background:transparent; border-color:transparent; cursor:not-allowed; opacity:1`(特异性 `.fcard__footer .ant-btn:disabled`=0,3,0 高于通用 `.ant-btn`=0,2,0 与 antdv 默认,稳定生效)。图标因 currentColor 自动变浅灰。删除按钮(danger)始终启用不受影响;禁用时的竖向分隔线(border-left-color:var(--fms-border))保留。 +- 验证 vite build 791ms 0 errors。 + +### fms-file 卡片操作列「删除|更多」分隔线消失修复 +- 用户反馈:下载|删除分隔线可见,但删除|更多分隔线看不到(尤其在 pending 卡片上)。 +- 根因:原分隔线挂在按钮左边框上(`.ant-btn-dangerous` 命中删除、`.ant-dropdown-trigger` 命中更多)。「更多」按钮在 pending 时禁用,而禁用规则 `&:disabled{border-color:transparent}` 特异性 (0,3,0) 高于分隔线 `.ant-dropdown-trigger` (0,2,0),把其左边框也抹成透明 → 分隔线消失;且「更多」被 a-dropdown 包裹结构更脆。 +- 修复:弃用按钮类名/border 方案,在模板 `fcard__footer` 内下载/删除、删除/更多之间各插入独立 ``;新增 `.fcard__divider{flex:none;width:1px;align-self:stretch;background:var(--fms-border)}`(随 footer 撑满高度);删除原先 `:deep(.ant-btn-dangerous),:deep(.ant-dropdown-trigger){border-left-color}` 规则。分隔线不再受按钮禁用态/类名影响,稳定显示。 +- 验证 vite build 784ms 0 errors。 diff --git a/code/fms/.workbuddy/memory/MEMORY.md b/code/fms/.workbuddy/memory/MEMORY.md new file mode 100644 index 00000000..18c69bea --- /dev/null +++ b/code/fms/.workbuddy/memory/MEMORY.md @@ -0,0 +1,18 @@ +# FMS 项目长期记忆 + +## 设计令牌作用域(重要踩坑) +- `--fms-*` 设计令牌必须在 `:root` 上定义(全局),不能只放在 `.theme-root`。 +- 原因:`a-modal` 等 antdv 组件默认 teleport 到 ``,脱离 `.theme-root`,令牌在其内部全部失效,表现为 "var(--fms-border) is not defined" 报错 + 边框/颜色丢失。 +- 现状:`fms-vue/src/styles/main.scss` 的 `:root` 已声明全部令牌;`.theme-root` 仅保留 color/background/overflow。 + +## fms-file 附件组件关键约定 +- 宫格/列表双模式由 `FileListPanel.vue` 的 `viewMode`(grid/list)控制。 +- 上传:点击/拖拽/粘贴均汇入 `addFiles()`,允许重复上传同一文件(前端已移除去重,后端无同名约束)。 +- 拖拽重复入栈防护在 `handleDragEnter/Leave` 的 `e.currentTarget !== e.target` 判断,非靠去重。 +- 待上传文件虚拟 id 形如 `_pending_N`,`deleteSelected` 需降序删除防索引漂移。 + +## 项目结构速查 +- 后端 `fms-api/` Spring Boot 4.1 + Java 21 + SQL Server(Druid 多组织路由);端口 8088,context-path `/api`。 +- 前端 `fms-vue/` Vue 3 + Vite + Pinia + antdv-next;dev `:5180`,代理 `/api`→`127.0.0.1:8088`。 +- 通用数据接口:`/data/loaddata`、`/data/page`、`/data/saveobjt`、`/data/nextid`、`/auth/login`、`/file/upload`。勿新增业务端点。 +- 开发纪律:改完不自行 build/起服务,除非用户要求;可用 `vite build`(或 `--mode development --emptyOutDir false` 规避 dist 清理权限问题)验证。 diff --git a/code/fms/.workbuddy/skills/fms-dev/SKILL.md b/code/fms/.workbuddy/skills/fms-dev/SKILL.md new file mode 100644 index 00000000..4d1db90c --- /dev/null +++ b/code/fms/.workbuddy/skills/fms-dev/SKILL.md @@ -0,0 +1,490 @@ +--- +name: fms-dev +description: > + FMS (模块/数据管理平台) 全栈开发技能。本项目后端为 Spring Boot + SqlServer 通用数据接口架构, + 前端为 Vue 3 + antdv-next + Pinia。本技能描述项目约定、通用组件用法、API 模式、 + 数据库规范及常见开发任务的标准操作。当需要新增页面、新增后端查询/保存逻辑、创建数据库迁移脚本、 + 或修改 FMS 现有功能时使用此技能。 +agent_created: true +--- + +# FMS 开发技能 + +## 项目概览 + +FMS 是一个**配置驱动的低代码数据管理平台**,核心设计理念是:后端不新增业务接口,一切数据读写走少量通用接口;前端用元数据(模块/字段/按钮/多语言)动态拼出页面。 + +- `fms-api/` — Spring Boot 3.x + Java 21 + Maven,端口 8088,context-path `/api` +- `fms-vue/` — Vue 3 + Vite + Pinia + antdv-next + stk-table-vue,开发端口 5180 +- 数据库:SQL Server,多机构独立库,按 JWT 中 orgId 路由 +- 连接池:Alibaba Druid,每机构一个 + +## 触发场景 + +- 新增/修改前端页面(数据浏览页、CRUD 页、复杂自定义页) +- 新增/修改后端通用接口调用逻辑 +- 创建数据库迁移脚本 +- 修改模块管理(s_module / s_module_field 等元数据) +- 新增文件上传/下载功能 +- 排查保存或查询问题 +- 任何需要理解本项目独特约定的开发任务 + +## 核心铁律(必须遵守) + +1. **不新增业务专用后端接口**。所有数据操作复用 `DataController` 通用端点。 +2. **不新增前端 API 地址**。`services/api.js` 封装的函数是唯一入口。 +3. **改动 scope 到当前任务**,不顺手重构无关代码。 +4. **改完不要自己跑 build/启动服务**,除非用户明确要求。测试验证(`mvnw test`/`pnpm fmt:check`)可以。 +5. **bigint 雪花 ID 必须以字符串返回前端**(JavaScript 无法安全表示 64 位整数)。 +6. **表名/字段名只允许 `[A-Za-z_][A-Za-z0-9_]*`**,后端 `DbUtils` 会强校验。 +7. **迁移脚本必须可重复执行**(`IF NOT EXISTS`),不修改已有业务表。 + +--- + +## 后端架构速查 + +### 通用数据端点 + +| 端点 | 用途 | 参数关键字段 | +|------|------|-------------| +| `POST /data/loaddata` | 条件查询 | `view_name`, `search_condition`, `order_by`, `search_columns` | +| `POST /data/page` | 分页查询 | `view_name`, `order_by`, `page_no`, `page_size`, `search_condition`, `search_columns` | +| `POST /data/saveobjt` | 跨表事务保存 | `[{table, key_field, inserts[], updates[], deletes[]}]` | +| `GET /data/nextid` | 雪花 ID | `?count=N`(默认1) | +| `POST /data/loaddatabysql` | 自定义 SQL | `{sql}` — **仅 SELECT/WITH 开头,禁止 DML** | +| `POST /data/describe` | 表结构 | `{table}` | +| `GET /data/nextcode` | 自动编号 | `?moduleId=N&count=N` | + +### 保存引擎 (DataSaveService) + +`saveobjt` 在一个**手动 JDBC 事务**中执行,顺序:**delete → update → insert**。任一表失败触发 `connection.rollback()`。 + +关键行为: +- 主键值 `< 0` 或 `null` → 自动分配雪花 ID +- `key_field` 值从 UPDATE SET 中排除(作为 WHERE 条件) +- INSERT 只写入 `writable=true` 且在数据中的列 +- 要求每条 INSERT/UPDATE/DELETE 影响行数恰好为 1 + +### 多机构路由 + +``` +JwtAuthFilter → OrgContext.setOrgId() → OrgRoutingDataSource + → OrgDataSourceManager.getConnection() → DruidDataSource[orgId] + → 对应 SQL Server 实例 +``` + +机构数据库配置文件:`config/dbconfigs/{ORG_ID}.properties`(git-ignored)。 + +### 异常与响应 + +| 异常类型 | code | 用途 | +|---------|------|------| +| `AuthenticationException` | 401 | 认证失败 | +| `BusinessException` | 1000 | 业务校验失败,message 直接展示 | +| `SaveObjectException` | 2001 | 保存失败,带 `{table, action, index}` | +| `SQLException` / 其他 | 500 | 统一"系统异常" | + +统一响应:`ApiResponse(int code, String message, T data)`,`code == 0` 表示成功。 + +### 后端包结构速查 + +``` +cn.g3soft.fmsapi +├── config/ JwtAuthFilter, 配置属性 +├── controller/ DataController, AuthController, FileController +├── database/ 多机构路由、OrgContext(ThreadLocal) +├── exception/ 异常类 + GlobalExceptionHandler +├── service/ DataService, DataSaveService, AuthService, FileService +└── utils/ DbUtils(核心), JwtUtils, ApiResponse, IdGenerator(雪花) +``` + +--- + +## 前端架构速查 + +### HTTP 层 + +`services/http.js`:axios 实例,baseURL `/api`,自动注入 Bearer Token,拦截 `code !== 0` 转为 Error。认证失效(401/40101)自动跳转登录。 + +### API 服务层 + +`services/api.js` 封装全部通用接口调用——这是前端与后端交互的**唯一通道**: + +```js +import { loadDataApi, pageDataApi, saveObjectApi, nextIdApi, describeApi, loginApi } from '@/services/api'; +``` + +所有 API 返回 `{ success, code, message, data }`,其中 `success` 为前端补充(`code === 0`)。 + +### 国际化 + +不走 vue-i18n,翻译文本来自数据库 `b_i18n` 表。`app` store 的 `t(key, fallback)` 函数做翻译。多语言键规范:`field.{moduleCode}.{fieldCode}`、`menu.{moduleCode}`、`power.{moduleCode}.{powerCode}`。 + +### 权限 + +`stores/permissions.js` 从 `b_user_module` / `b_user_power` / `s_module_power` 加载权限。`canAccess(moduleId)` 判断页面权限,`canPower(scopeCode, powerCode)` 判断操作权限。特殊账号 `g3soft` 为超级管理员,跳过所有权限检查。 + +### 核心组件 + +#### FmsModuleListPage(数据列表页) + +**最常用的页面组件——大多数 CRUD 页面只需配置一个 data-code 即可运行。** + +Props 速查: +| Prop | 用途 | +|------|------| +| `data-code` | 数据模块编码(必填),对应 `s_module.b_code` | +| `fixed-search-condition` | 固定查询条件(不含 WHERE 关键字) | +| `row-actions` | 行操作按钮 `[{key, label, onClick}]` | +| `editable` | 可编辑模式(行内编辑+新增行) | +| `row-draggable` | 行拖拽排序 | +| `page-size` | 每页条数(默认 20) | +| `column-extensions` | 列自定义(formatter, clickable 等) | + +Slots:`#actions`(工具栏按钮区)、`#actions="{ saveChanges, dirtyCount, saving }"`(可编辑模式工具栏) + +暴露方法:`reload()`, `saveChanges()`, `addRow()`, `initialize()`, `resetColumns()` + +#### FmsTree(树形组件) + +Prop `treeData` 接收扁平数组,自动构建树。支持虚拟滚动、拖拽、右键菜单。自动通过 `b_i18n` 做标题翻译。 + +### 路由与页面注册 + +1. 在 `router/routes.js` 添加路由配置,使用静态 `import()` 注册组件 +2. `meta.moduleId` 指定权限检查的模块 code +3. `meta.title` 指定页面标题 + +```js +{ + path: 'my-feature', + name: 'my-feature', + component: () => import('@/views/my-feature/index.vue'), + meta: { title: '我的功能', moduleId: 'my_feature_code' }, +} +``` + +--- + +## 后端开发模式 + +### 模式 1:新增简单查询端点 + +在现有 Controller(通常是 `DataController` 或 `FileController` 同级)中添加方法: + +```java +@PostMapping("/my/query") +public ApiResponse myQuery(@RequestBody Map params) throws SQLException { + String viewName = ParamUtils.getRequiredString(params, "view_name"); + String condition = ParamUtils.getString(params, "search_condition"); + // 业务逻辑 + 调用 dataService.loadData / dbUtils.loadData + return ApiResponse.success(result); +} +``` + +### 模式 2:新增保存端点 + +封装数据为 `[{table, key_field, inserts[], updates[], deletes[]}]` 格式,调用 `dataSaveService.save(requests)`。 + +```java +List> requests = new ArrayList<>(); +Map req = new HashMap<>(); +req.put("table", "my_table"); +req.put("key_field", "b_id"); +req.put("inserts", newList); +dataSaveService.save(requests); +``` + +### 模式 3:自定编号生成 + +调用 `dataSaveService.nextCode(moduleId, count)`,返回 `List`。 + +--- + +## 前端开发模式(按复杂度排序) + +### 模式 A:纯数据列表页(零代码) + +适用于只需展示已有数据模块的页面。 + +```vue + + + + + +``` + +### 模式 B:带行操作和自定义工具栏 + +```vue + + + +``` + +### 模式 C:可编辑表格(行内编辑) + +```vue + + + +``` + +### 模式 D:完全自定义页面(复杂场景) + +直接使用 `loadDataApi`、`saveObjectApi`、`nextIdApi` + 原生 antdv-next 组件。需自行管理数据加载、修改追踪(使用 `utils/dataChanges.js` 的 `diffRows`/`cloneData`)、ID 分配(`utils/tempId.js` 的 `createTempId` + `allocateTemporaryIds`)。 + +关键工具函数: +- `cloneData(obj)` — JSON 深拷贝,存储原始数据快照 +- `diffRows(current, previous)` — 对比得到 `{inserts, updates, deletes}` +- `allocateTemporaryIds(rowGroups)` — 负号临时 ID 替换为真实雪花 ID +- `tableChange(table, changes)` — 生成 `{table, key_field, ...changes}` 结构 + +--- + +## 数据库迁移 + +### 脚本命名 + +`fms-api/config/migrations/NNN_description.sql`,三位数字递增。 + +### 脚本模板 + +```sql +SET XACT_ABORT ON; +BEGIN TRANSACTION; + +-- 描述:创建 xxx 表 +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'xxx') +BEGIN + CREATE TABLE [xxx] ( + [b_id] BIGINT NOT NULL PRIMARY KEY, + [b_name] NVARCHAR(200) NOT NULL, + -- ... + ); +END; + +COMMIT; +``` + +关键规则: +- 使用 `XACT_ABORT ON` + 显式 `BEGIN/COMMIT TRANSACTION` +- 所有 DDL 用 `IF NOT EXISTS` 包裹 +- 不删除或修改已有业务表 +- 表名/列名使用小写或驼峰,统一 +- 大字段主键用 `BIGINT` + +### 执行 + +使用 PowerShell `.NET SqlClient`(本机 SQL Server 兼容性),参照 `fms-api/tools/migration/` 下的工具脚本。 + +--- + +## 代码约定细节 + +### 后端 +- Controller 方法声明 `throws SQLException`,返回 `ApiResponse` +- Service 注入 `DataSource dataSource`(自动多机构路由)+ `DbUtils dbUtils` +- 参数提取用 `ParamUtils.getRequiredString/getString/getList` +- ID 生成用 `IdGenerator.nextId()` +- 当前用户 ID 用 `OrgContext.getUserId()`,机构 ID 用 `OrgContext.requireOrgId()` +- 业务失败抛 `BusinessException("消息")` + +### 前端 +- SFC 使用 ` + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FileCategoryPanel.vue b/code/fms/fms-vue/src/components/fms-file/FileCategoryPanel.vue new file mode 100644 index 00000000..619840a5 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FileCategoryPanel.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue b/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue new file mode 100644 index 00000000..dbb5c861 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue @@ -0,0 +1,808 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue b/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue new file mode 100644 index 00000000..59dfaeaa --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FmsFile.vue b/code/fms/fms-vue/src/components/fms-file/FmsFile.vue new file mode 100644 index 00000000..1d579640 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FmsFile.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FmsFileModal.vue b/code/fms/fms-vue/src/components/fms-file/FmsFileModal.vue new file mode 100644 index 00000000..196b2f19 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FmsFileModal.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/composables/useFileCategory.js b/code/fms/fms-vue/src/components/fms-file/composables/useFileCategory.js new file mode 100644 index 00000000..4758735b --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/composables/useFileCategory.js @@ -0,0 +1,65 @@ +import { ref, computed } from "vue"; +import { loadDataApi } from "@/services/api"; + +/** + * 文件分类逻辑 —— 加载 bf_files_cateid 扁平表并通过 FmsTree 展示 + */ +export function useFileCategory() { + const categories = ref([]); + const loading = ref(false); + const error = ref(null); + const selectedKey = ref(""); + + /** 加载所有启用的分类 */ + async function loadCategories() { + loading.value = true; + error.value = null; + try { + const res = await loadDataApi( + "bf_files_cateid", + "b_canuse = 1", + "b_xh ASC, b_id ASC", + ); + categories.value = (res.data || []).map((item) => ({ + ...item, + b_id: String(item.b_id), + })); + } catch (e) { + error.value = e?.message || String(e); + categories.value = []; + } finally { + loading.value = false; + } + } + + /** 扁平分类转 FmsTree 所需的 treeData(b_id → key, b_name → title) */ + const treeData = computed(() => + categories.value.map((item) => ({ + key: String(item.b_id), + title: item.b_name || item.b_code || "", + b_id: String(item.b_id), + isLeaf: true, + })), + ); + + /** 按 key 查找分类原始数据 */ + function findCategory(key) { + const id = String(key || ""); + return categories.value.find((item) => String(item.b_id) === id) || null; + } + + function selectCategory(key) { + selectedKey.value = String(key || ""); + } + + return { + categories, + loading, + error, + selectedKey, + treeData, + loadCategories, + findCategory, + selectCategory, + }; +} diff --git a/code/fms/fms-vue/src/components/fms-file/composables/useFileList.js b/code/fms/fms-vue/src/components/fms-file/composables/useFileList.js new file mode 100644 index 00000000..c98bf6c6 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/composables/useFileList.js @@ -0,0 +1,192 @@ +import { ref, computed, watch } from "vue"; +import { message } from "antdv-next"; +import { loadDataApi, pageDataApi, nextIdApi, saveObjectApi } from "@/services/api"; +import { upload as fileUpload, removeFile } from "@/services/fileService"; + +/** + * 文件列表逻辑 —— 分页加载 / 上传 / 删除 / 视图切换 + */ +export function useFileList({ moduleCode, father, cateId }) { + const files = ref([]); + const loading = ref(false); + const total = ref(0); + const currentPage = ref(1); + const pageSize = ref(20); + const viewMode = ref("grid"); // 'grid' | 'list' + const moduleId = ref(""); + const moduleResolved = ref(false); + const resolving = ref(false); + const selectedFileIds = ref([]); + const uploading = ref(false); + const deleting = ref(false); + + const hasFiles = computed(() => files.value.length > 0); + const hasSelection = computed(() => selectedFileIds.value.length > 0); + + /** 根据 moduleCode 解析 moduleId */ + async function resolveModuleId() { + const code = moduleCode?.value; + if (!code) { + moduleId.value = ""; + moduleResolved.value = true; + return; + } + resolving.value = true; + try { + const res = await loadDataApi( + "s_module", + `b_code = N'${String(code).replaceAll("'", "''")}' AND b_canuse = 1 AND b_module_type = 'data'`, + "", + ["b_id", "b_code"], + ); + if (res.data?.length) { + moduleId.value = String(res.data[0].b_id); + } else { + moduleId.value = ""; + } + } catch { + moduleId.value = ""; + } finally { + moduleResolved.value = true; + resolving.value = false; + } + } + + function buildSearchCondition() { + const f = father?.value; + if (!f) return "1 = 0"; + let cond = `[father] = N'${String(f).replaceAll("'", "''")}'`; + if (moduleId.value) { + cond += ` AND [mx_moduleid] = N'${moduleId.value}'`; + } + const cid = cateId?.value; + if (cid) { + cond += ` AND [mx_cate_id] = N'${String(cid).replaceAll("'", "''")}'`; + } + return cond; + } + + /** 分页加载文件列表 */ + async function loadFiles(page = 1) { + currentPage.value = page; + const f = father?.value; + if (!f) { + files.value = []; + total.value = 0; + return; + } + loading.value = true; + try { + const cond = buildSearchCondition(); + const res = await pageDataApi({ + viewName: "bf_files", + orderBy: "mx_xh ASC, subid DESC", + searchCondition: cond, + pageNo: currentPage.value, + pageSize: pageSize.value, + }); + files.value = (res.data?.rows || []).map(normalizeFile); + total.value = res.data?.total || 0; + } catch (e) { + message.error("加载文件列表失败:" + (e?.message || e)); + files.value = []; + total.value = 0; + } finally { + loading.value = false; + } + } + + function normalizeFile(row) { + return { + ...row, + subid: String(row.subid), + father: String(row.father), + mx_moduleid: row.mx_moduleid ? String(row.mx_moduleid) : "", + mx_cate_id: row.mx_cate_id ? String(row.mx_cate_id) : "", + mx_filesize: Number(row.mx_filesize || 0), + }; + } + + /** 上传文件 */ + async function uploadFiles(fileList) { + if (!fileList?.length) return; + const f = father?.value; + if (!f || !moduleId.value) { + message.warning("缺少业务单据 ID 或模块 ID,无法上传"); + return; + } + uploading.value = true; + try { + await fileUpload({ + files: Array.from(fileList), + father: String(f), + moduleId: moduleId.value, + cateId: cateId?.value || undefined, + }); + message.success(`成功上传 ${fileList.length} 个文件`); + await loadFiles(1); + } catch (e) { + message.error("上传失败:" + (e?.message || e)); + } finally { + uploading.value = false; + } + } + + /** 批量删除选中文件 */ + async function deleteSelected() { + const ids = selectedFileIds.value; + if (!ids.length) return; + deleting.value = true; + try { + for (const id of ids) { + await removeFile(id); + } + message.success(`已删除 ${ids.length} 个文件`); + selectedFileIds.value = []; + await loadFiles(currentPage.value); + } catch (e) { + message.error("删除失败:" + (e?.message || e)); + } finally { + deleting.value = false; + } + } + + function toggleView() { + viewMode.value = viewMode.value === "grid" ? "list" : "grid"; + } + + function onSelectionChange(keys) { + selectedFileIds.value = keys.map(String); + } + + // cateId 变化时重新加载 + watch( + cateId || (() => null), + () => { + if (father?.value) loadFiles(1); + }, + ); + + return { + files, + loading, + total, + currentPage, + pageSize, + viewMode, + moduleId, + moduleResolved, + resolving, + selectedFileIds, + uploading, + deleting, + hasFiles, + hasSelection, + resolveModuleId, + loadFiles, + uploadFiles, + deleteSelected, + toggleView, + onSelectionChange, + }; +} diff --git a/code/fms/fms-vue/src/components/fms-file/fileVisual.js b/code/fms/fms-vue/src/components/fms-file/fileVisual.js new file mode 100644 index 00000000..a3123ee6 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/fileVisual.js @@ -0,0 +1,62 @@ +// 文件视觉共享工具:扩展名语义配色、图片判断、大小格式化 +// 集中此处,避免 FileCard / FileListPanel / FilePreviewPanel 三处重复定义 + +// 扩展名 → 语义配色(ERP 附件管理可一眼辨类) +const EXT_COLOR = { + // 文档 + pdf: "#f5222d", + doc: "#1677ff", + docx: "#1677ff", + txt: "#8c8c8c", + md: "#8c8c8c", + // 表格 + xls: "#52c41a", + xlsx: "#52c41a", + csv: "#52c41a", + // 演示 + ppt: "#fa541c", + pptx: "#fa541c", + // 图片 + jpg: "#722ed1", + jpeg: "#722ed1", + png: "#722ed1", + gif: "#722ed1", + bmp: "#722ed1", + webp: "#722ed1", + svg: "#722ed1", + // 压缩 + zip: "#fa8c16", + rar: "#fa8c16", + "7z": "#fa8c16", + // 音视频 + mp3: "#13c2c2", + wav: "#13c2c2", + mp4: "#13c2c2", + mov: "#13c2c2", +}; + +const FALLBACK_COLOR = "#8c8c8c"; + +export function extFromName(name) { + const idx = (name || "").lastIndexOf("."); + return idx >= 0 ? name.substring(idx + 1).toLowerCase() : ""; +} + +export function extColor(name) { + const ext = extFromName(name); + return EXT_COLOR[ext] || FALLBACK_COLOR; +} + +export function isImageType(name) { + return ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes( + extFromName(name), + ); +} + +export function formatSize(bytes) { + const n = Number(bytes || 0); + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB"; + return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB"; +} diff --git a/code/fms/fms-vue/src/components/fms-file/useFileModal.js b/code/fms/fms-vue/src/components/fms-file/useFileModal.js new file mode 100644 index 00000000..317d800d --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/useFileModal.js @@ -0,0 +1,46 @@ +import { reactive } from "vue"; + +const state = reactive({ + open: false, + moduleCode: "", + father: "", + showTree: true, + readonly: false, + initialCateId: "", + key: 0, + // 事件回调 + onUploaded: null, + onDeleted: null, + onSelected: null, +}); + +let keyCounter = 0; + +/** + * 文件 Modal 命令式调用 composable。 + * + * 用法: + * const fileModal = useFileModal(); + * fileModal.open({ moduleCode: 'b_port', father: '123' }); + */ +export function useFileModal() { + function open(opts) { + keyCounter++; + state.key = keyCounter; + state.moduleCode = opts.moduleCode || ""; + state.father = String(opts.father ?? ""); + state.showTree = opts.showTree !== false; + state.readonly = opts.readonly || false; + state.initialCateId = opts.initialCateId || ""; + state.onUploaded = opts.onUploaded || null; + state.onDeleted = opts.onDeleted || null; + state.onSelected = opts.onSelected || null; + state.open = true; + } + + function close() { + state.open = false; + } + + return { open, close, state }; +} diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue index 04d6005c..de3850bd 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue @@ -13,7 +13,7 @@ import { import { useRoute } from "vue-router"; import { message } from "antdv-next"; import { Columns3, RefreshCw, Save } from "@lucide/vue"; -import { loadDataApi, pageDataApi, saveObjectApi } from "@/services/api"; +import { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from "@/services/api"; import { useAppStore } from "@/stores/app"; import { identifierRe, @@ -36,9 +36,22 @@ const props = defineProps({ expandable: { type: [Boolean, Object], default: false }, pageSize: { type: Number, default: 20 }, inlineActionLimit: { type: Number, default: 2 }, + // 可编辑表格模式:取编辑配置生成行内编辑器,支持保存/批量删除。 + editable: { type: Boolean, default: false }, + // 行拖拽排序:开启后显示拖拽手柄,拖拽按 orderField 重排并纳入脏保存。 + rowDraggable: { type: Boolean, default: false }, + // 行拖拽排序字段,仅 rowDraggable 时生效。 + orderField: { type: String, default: "b_xh" }, }); -const emit = defineEmits(["row-click", "cell-click", "row-action", "expand", "loaded"]); +const emit = defineEmits([ + "row-click", + "cell-click", + "row-action", + "expand", + "loaded", + "dirty-change", +]); const slots = useSlots(); const route = useRoute(); const appStore = useAppStore(); @@ -52,10 +65,16 @@ const dataModule = ref(null); const activeFields = shallowRef([]); const activeFieldById = shallowRef(new Map()); const listConfig = shallowRef([]); +const editConfigById = shallowRef(new Map()); const queryItems = shallowRef([]); const dataSource = shallowRef([]); const searchConditions = shallowRef([]); const keyField = ref(""); +// 可编辑表格:脏行追踪(rowKey -> Set<已修改字段>),仅在 editable 模式下使用。 +const dirtyRows = shallowRef(new Map()); +// 可编辑表格:未持久化的新增行 rowKey 集合(新增行保存时作为 inserts 提交)。 +const newRowKeys = shallowRef(new Set()); +const saving = ref(false); const configLoading = ref(false); const dataLoading = ref(false); const columnSaving = ref(false); @@ -163,9 +182,11 @@ async function loadModuleConfig() { } const dataId = requireModuleId(runtimeData.b_id); + // 列显示配置:普通模式取列表配置,可编辑模式取编辑配置,二者只取其一。 + const listTable = props.editable ? "s_module_field_edit" : "s_module_field_list"; const responses = await Promise.all([ loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"), - loadDataApi("s_module_field_list", `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"), + loadDataApi(listTable, `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"), loadDataApi( "s_module_field_query", `b_module_id = ${dataId} AND b_canuse = 1`, @@ -182,6 +203,16 @@ async function loadModuleConfig() { fieldById.set(String(field.b_id), field); } + // 可编辑模式下:记录编辑配置的 readonly/disabled,用于生成只读编辑器。 + const editConfigByIdMap = new Map(); + for (const item of props.editable ? responses[1].data || [] : []) { + if (item.b_visible !== 1) continue; + if (!fieldById.has(String(item.b_field_id))) continue; + editConfigByIdMap.set(String(item.b_field_id), item); + } + editConfigById.value = editConfigByIdMap; + + // 列表配置:普通模式为 s_module_field_list,可编辑模式为 s_module_field_edit。 const lists = []; for (const item of responses[1].data || []) { if (item.b_visible !== 1) continue; @@ -215,6 +246,8 @@ async function loadModuleConfig() { queryItems.value = queries; searchConditions.value = conditions; keyField.value = resolveKeyField(runtimeData, fields); + dirtyRows.value = new Map(); + newRowKeys.value = new Set(); return true; } catch (error) { if (getDataCode() === code) { @@ -301,9 +334,13 @@ async function initialize() { activeFields.value = []; activeFieldById.value = new Map(); listConfig.value = []; + editConfigById.value = new Map(); queryItems.value = []; searchConditions.value = []; keyField.value = ""; + dirtyRows.value = new Map(); + newRowKeys.value = new Set(); + saving.value = false; dataLoading.value = false; dataError.value = ""; pagination.current = 1; @@ -371,9 +408,11 @@ async function saveColumnSettings() { columnSaving.value = true; try { + // 可编辑表格的列来自编辑配置,栏位设置保存到 s_module_field_edit;普通模式保存到列表配置。 + const table = props.editable ? "s_module_field_edit" : "s_module_field_list"; await saveObjectApi([ { - table: "s_module_field_list", + table, key_field: "b_id", updates, }, @@ -405,6 +444,252 @@ function resolveRowKey(row) { return JSON.stringify(row); } +// ── 可编辑表格:脏行追踪 / 保存 / 批量删除 ── +// 主键与系统审计字段(由后端维护)在可编辑表格中一律只读。 +const SYSTEM_FIELDS = ["b_inputdatetime", "b_inputuser_id", "b_updatedatetime", "b_updateuser_id"]; +const readonlyFields = computed(() => { + const fields = new Set(SYSTEM_FIELDS); + if (keyField.value) fields.add(keyField.value); + return [...fields]; +}); + +function handleCellChange({ row, field }) { + if (!props.editable || !keyField.value) return; + const rowKey = String(resolveRowKey(row)); + const current = dirtyRows.value.get(rowKey); + if (current) { + current.add(field); + } else { + dirtyRows.value.set(rowKey, new Set([field])); + } + emit("dirty-change", { count: dirtyRows.value.size }); +} + +function resolveSaveTable() { + if (!dataModule.value) throw new Error("数据模块尚未加载"); + return String(dataModule.value.b_savetable || dataModule.value.b_viewtable || ""); +} + +function dirtyUpdateRows() { + if (!keyField.value) return []; + const updates = []; + const rowByKey = new Map( + dataSource.value.map((row) => [String(resolveRowKey(row)), row]), + ); + for (const [rowKey, fields] of dirtyRows.value) { + // 未持久化的新增行走 inserts 分支,避免对不存在的记录执行 UPDATE。 + if (newRowKeys.value.has(rowKey)) continue; + const row = rowByKey.get(rowKey); + if (!row) continue; + const update = { [keyField.value]: row[keyField.value] }; + for (const field of fields) { + if (field === keyField.value) continue; + update[field] = row[field]; + } + updates.push(update); + } + return updates; +} + +// 未持久化的新增行:仅收集有实际修改的行,作为 inserts 提交。 +function dirtyInsertRows() { + if (!keyField.value) return []; + const rowByKey = new Map( + dataSource.value.map((row) => [String(resolveRowKey(row)), row]), + ); + const inserts = []; + for (const rowKey of newRowKeys.value) { + const fields = dirtyRows.value.get(rowKey); + const row = rowByKey.get(rowKey); + if (!fields || !row) continue; + const insert = { [keyField.value]: row[keyField.value] }; + for (const field of fields) { + if (field === keyField.value) continue; + insert[field] = row[field]; + } + inserts.push(insert); + } + return inserts; +} + +// 找到第一个可编辑的列字段(按编辑配置顺序,跳过只读列、主键与 checkbox), +// 用于新增行后定位光标聚焦的单元格。 +function firstEditableField() { + const readonly = new Set(readonlyFields.value); + for (const config of listConfig.value) { + const field = activeFieldById.value.get(String(config.b_field_id)); + if (!field) continue; + if (readonly.has(field.b_field)) continue; + if (field.b_type === "checkbox") continue; + return field.b_field; + } + return null; +} + +// 可编辑模式下新增一行:生成雪花主键,追加到表尾并让第一个可编辑单元格进入编辑态并聚焦。 +async function addRow() { + if (!props.editable || saving.value || loading.value) return; + if (!keyField.value) { + message.error("该模块未配置主键,无法新增"); + return; + } + const idRes = await nextIdApi(); + const newId = String(idRes.data); + const newRow = { [keyField.value]: newId }; + // 排序字段取当前最大值 + 10;checkbox 字段默认 0。 + if (props.orderField && identifierRe.test(props.orderField)) { + let maxXh = 0; + for (const row of dataSource.value) { + maxXh = Math.max(maxXh, Number(row[props.orderField]) || 0); + } + newRow[props.orderField] = maxXh + 10; + } + for (const field of activeFields.value) { + if (field.b_field === keyField.value || field.b_field === props.orderField) continue; + if (newRow[field.b_field] !== undefined) continue; + if (field.b_type === "checkbox") newRow[field.b_field] = 0; + } + dataSource.value = [...dataSource.value, newRow]; + newRowKeys.value = new Set([...newRowKeys.value, newId]); + await nextTick(); + // 聚焦第一个可编辑字段,让用户可直接输入。 + tableRef.value?.editRow?.(newId, firstEditableField()); +} + +async function saveChanges() { + if (saving.value) return; + const table = resolveSaveTable(); + if (!table) { + message.error("该模块未配置保存表,无法保存"); + return; + } + const updates = dirtyUpdateRows(); + const inserts = dirtyInsertRows(); + if (!updates.length && !inserts.length) { + message.info("没有需要保存的修改"); + return; + } + saving.value = true; + try { + const operation = { table, key_field: keyField.value }; + if (updates.length) operation.updates = updates; + if (inserts.length) operation.inserts = inserts; + await saveObjectApi([operation]); + // 已提交的新增行不再视为未持久化。 + const insertedSet = new Set(inserts.map((row) => String(row[keyField.value]))); + newRowKeys.value = new Set( + [...newRowKeys.value].filter((key) => !insertedSet.has(key)), + ); + dirtyRows.value = new Map(); + // 保存成功后让正在编辑的行退出编辑态。 + tableRef.value?.clearEditing?.(); + message.success("保存成功"); + emit("dirty-change", { count: 0 }); + await fetchData(); + } catch (error) { + message.error("保存失败:" + (error?.message || error)); + } finally { + saving.value = false; + } +} + +async function deleteSelected(rowKeys) { + if (saving.value || !rowKeys?.length) return; + const table = resolveSaveTable(); + if (!table) { + message.error("该模块未配置保存表,无法删除"); + return; + } + if (!keyField.value) { + message.error("该模块未配置主键,无法删除"); + return; + } + // 未持久化的新增行直接本地移除,不调删除接口。 + const newSet = new Set(newRowKeys.value); + const pendingKeys = rowKeys.filter((key) => newSet.has(String(key))); + const persistedKeys = rowKeys.filter((key) => !newSet.has(String(key))); + if (pendingKeys.length) { + const pendingSet = new Set(pendingKeys.map((key) => String(key))); + dataSource.value = dataSource.value.filter( + (row) => !pendingSet.has(String(resolveRowKey(row))), + ); + const remaining = new Map(); + for (const [rowKey, fields] of dirtyRows.value) { + if (!pendingSet.has(rowKey)) remaining.set(rowKey, fields); + } + dirtyRows.value = remaining; + newRowKeys.value = new Set( + [...newRowKeys.value].filter((key) => !pendingSet.has(key)), + ); + tableRef.value?.clearEditing?.(); + emit("dirty-change", { count: dirtyRows.value.size }); + } + if (!persistedKeys.length) { + message.success(`已删除 ${rowKeys.length} 条记录`); + return; + } + saving.value = true; + try { + await saveObjectApi([ + { + table, + key_field: keyField.value, + deletes: persistedKeys.map((key) => ({ [keyField.value]: key })), + }, + ]); + // 清除被删行的脏记录 + const deletedSet = new Set(persistedKeys.map((key) => String(key))); + const remaining = new Map(); + for (const [rowKey, fields] of dirtyRows.value) { + if (!deletedSet.has(rowKey)) remaining.set(rowKey, fields); + } + dirtyRows.value = remaining; + // 删除成功后让正在编辑的行退出编辑态。 + tableRef.value?.clearEditing?.(); + message.success(`已删除 ${rowKeys.length} 条记录`); + await fetchData(); + } catch (error) { + message.error("删除失败:" + (error?.message || error)); + } finally { + saving.value = false; + } +} + +// 行拖拽排序:回灌排序结果,按 orderField 重新编号并标记为脏,随保存一起提交。 +function handleRowOrderChange(orderedRows) { + if (!props.rowDraggable || !props.orderField) return; + if (!identifierRe.test(props.orderField)) return; + const rowByOld = new Map( + dataSource.value.map((row) => [String(resolveRowKey(row)), row]), + ); + const dirtyFieldsByKey = new Map(); + const nextRows = []; + let order = 10; + for (const row of orderedRows) { + const key = String(resolveRowKey(row)); + const oldRow = rowByOld.get(key); + const oldValue = oldRow ? Number(oldRow[props.orderField]) || 0 : 0; + const copy = { ...row, [props.orderField]: order }; + nextRows.push(copy); + if (oldValue !== order) { + const fields = dirtyFieldsByKey.get(key) || new Set(); + fields.add(props.orderField); + dirtyFieldsByKey.set(key, fields); + } + order += 10; + } + dataSource.value = nextRows; + for (const [key, fields] of dirtyFieldsByKey) { + const current = dirtyRows.value.get(key); + if (current) { + fields.forEach((field) => current.add(field)); + } else { + dirtyRows.value.set(key, fields); + } + } + emit("dirty-change", { count: dirtyRows.value.size }); +} + async function handleMoreAction({ key }) { if (key === "refresh") { await reload(); @@ -458,6 +743,10 @@ defineExpose({ dataSource, dataModule, pagination, + saveChanges, + deleteSelected, + addRow, + dirtyRows, }); @@ -487,7 +776,16 @@ defineExpose({ @more-action="handleMoreAction" > @@ -497,6 +795,8 @@ defineExpose({ ref="tableRef" :fields="activeFields" :list-config="listConfig" + :edit-config-by-id="editConfigById" + :readonly-fields="readonlyFields" :data-source="dataSource" :row-key="resolveRowKey" :column-extensions="columnExtensions" @@ -505,10 +805,14 @@ defineExpose({ :row-class-name="rowClassName" :expandable="expandable" :inline-action-limit="inlineActionLimit" + :editable="editable" + :row-draggable="rowDraggable" @row-click="$emit('row-click', $event)" @cell-click="$emit('cell-click', $event)" @row-action="$emit('row-action', $event)" @expand="$emit('expand', $event)" + @cell-change="handleCellChange" + @row-order-change="handleRowOrderChange" >