Jelajahi Sumber

1. 实现了配套方案并入共享资料库,新增附件类型ACCOMPANYING_PLAN(配套方案/支撑成果/方案训考资料/典型案例四类),配套方案Tab的上传、挂载、解绑、拖动排序与解绑后留存语义同其余三类附件完全一致;
2. 实现了配套方案Tab挂载弹窗的类型软排序,挂载面板中配套方案排在前面(与页签类型匹配)、其他类型排在后面,共享资料库条目均携带类型字段供筛选与徽标展示;
3. 实现了管理员解挂操作位无条件下发(方案B),主案allowedActions对管理员恒含解除挂载,消除“仅挂载配套方案的主案管理员无法解挂”的倒挂,并取消主案列表逐行挂载计数查询;
4. 实现了存量子案无损并轨迁移(Alembic 0020与Flyway V5双引擎幂等),子案全部转为配套方案类型附件,保留原密级与挂载顺序,父子关系转为挂载关系并重算主案计数;
5. 实现了附件读路径密级前置校验,附件不再强制公开密级,密级不足时详情、预览、下载一律拒绝并提示密级原因,管理员同样不可绕过;
6. 实现了子案文档停止新建的接口约束,创建请求统一返回“并入共享资料库、以配套方案类型上传”的引导提示;
7. 实现了Java后端近期需求集全量同步(阶段10),补齐顶级分类类型显示名称、子案跨主案挂载、正文解析失败可视化与重新解析、登录背景图四个接口及V2~V4幂等迁移;
8. 实现了Java后端写路径按文档权限放行,编辑、删除、重排、关联、密码锁、权限配置与挂载等操作改为按角色矩阵操作位校验,普通用户获授权即可执行,无权时区分密级与权限两种拒绝原因;
9. 实现了Java侧管理菜单按用户实际文档权限动态显示,命中任一管理位的普通用户登录即可见后台管理入口,附件列表对普通用户全量可见以支撑全局检索;
10. 同步了Java侧分类树计数与PDF正文提取口径,括号计数改为按子树全量主体方案统计、不再按用户密级过滤,PDF提取按版面块识别段落并合并段内软换行;
11. 修复了Java侧挂载子案列表恒为空(挂载过滤误带父文档条件)与分类新建响应版本号为空两处缺陷;
12. 优化了全局文字可读性,面向年长用户将表单、列表、设置、按钮等非说明性文字统一提升至16px最小字号,解析文本等大段说明文字统一14px,分类树中档字号调整为16px;
13. 新增了统一风格的“是否”确认模态框,替换原生确认弹窗,与系统整体视觉保持一致;
14. 新增了登录界面自定义背景图的后端接口,background.jpg/png 放至运行目录即可自动加载并拉伸铺满;
15. 优化了审计页面扇形统计图,移除密集图例,改为鼠标悬停时显示每个扇形块的类别与数量;
16. 移除了角色“可访问部门”维度,权限矩阵由密级×3部门范围×7操作的15行收敛为密级×2部门范围×7操作的10行,“其他部门”涵盖所属部门外的全部部门(含无归属文档);同步下线了角色部门维护能力;
17. 实现了数据库切换达梦DM的程序改造,链接主机不变、端口5236、用户与库名DMS,Java引入DmJdbcDriver18驱动并支持dm+dmPython连接串自动转换为jdbc:dm://,Python默认URL同步切换,原MySQL链接在各配置中注释保留、可随时回切;
18. 更新了接口契约至1.16版本,补充配套方案并轨实施方案与阶段10、11验证记录(57项契约用例全绿)。

weisijie 1 bulan lalu
induk
melakukan
1f2611647e
100 mengubah file dengan 3548 tambahan dan 1659 penghapusan
  1. 6 0
      DMS_API_CONTRACT.md
  2. 5 3
      backend-java/.env.example
  3. 9 1
      backend-java/pom.xml
  4. 6 3
      backend-java/src/main/java/com/dms/api/v1/AttachmentsController.java
  5. 23 11
      backend-java/src/main/java/com/dms/api/v1/DocumentsController.java
  6. 127 0
      backend-java/src/main/java/com/dms/api/v1/LoginBackgroundController.java
  7. 27 4
      backend-java/src/main/java/com/dms/api/v1/MainPlansController.java
  8. 0 15
      backend-java/src/main/java/com/dms/api/v1/RolesController.java
  9. 2 1
      backend-java/src/main/java/com/dms/api/v1/UsersController.java
  10. 3 2
      backend-java/src/main/java/com/dms/common/DmsEnums.java
  11. 6 0
      backend-java/src/main/java/com/dms/common/Errors.java
  12. 15 0
      backend-java/src/main/java/com/dms/common/Payloads.java
  13. 12 2
      backend-java/src/main/java/com/dms/config/DataSourceConfig.java
  14. 28 15
      backend-java/src/main/java/com/dms/config/DmsProperties.java
  15. 1 1
      backend-java/src/main/java/com/dms/config/LauncherBootstrap.java
  16. 20 38
      backend-java/src/main/java/com/dms/mapper/CategoryMapper.java
  17. 45 2
      backend-java/src/main/java/com/dms/mapper/DocumentMapper.java
  18. 8 1
      backend-java/src/main/java/com/dms/mapper/OrganizationMapper.java
  19. 0 36
      backend-java/src/main/java/com/dms/mapper/RoleDepartmentMapper.java
  20. 54 0
      backend-java/src/main/java/com/dms/mapper/SubPlanBindingMapper.java
  21. 2 0
      backend-java/src/main/java/com/dms/model/CategoryEntity.java
  22. 2 0
      backend-java/src/main/java/com/dms/model/DocumentEntity.java
  23. 6 4
      backend-java/src/main/java/com/dms/model/SubPlanBindingEntity.java
  24. 3 0
      backend-java/src/main/java/com/dms/service/AttachmentBindingService.java
  25. 23 4
      backend-java/src/main/java/com/dms/service/AttachmentQueryService.java
  26. 2 1
      backend-java/src/main/java/com/dms/service/AuthService.java
  27. 158 20
      backend-java/src/main/java/com/dms/service/AuthorizationService.java
  28. 31 23
      backend-java/src/main/java/com/dms/service/CategoryService.java
  29. 342 8
      backend-java/src/main/java/com/dms/service/DocumentContentService.java
  30. 115 38
      backend-java/src/main/java/com/dms/service/DocumentMutationService.java
  31. 6 1
      backend-java/src/main/java/com/dms/service/DocumentPasswordService.java
  32. 75 16
      backend-java/src/main/java/com/dms/service/DocumentQueryService.java
  33. 1 0
      backend-java/src/main/java/com/dms/service/DocumentRelationService.java
  34. 9 1
      backend-java/src/main/java/com/dms/service/OrganizationService.java
  35. 8 1
      backend-java/src/main/java/com/dms/service/PermissionService.java
  36. 2 1
      backend-java/src/main/java/com/dms/service/PortalAuthService.java
  37. 5 94
      backend-java/src/main/java/com/dms/service/RoleService.java
  38. 283 0
      backend-java/src/main/java/com/dms/service/SubPlanBindingService.java
  39. 40 3
      backend-java/src/main/java/com/dms/service/UserSummaryService.java
  40. 6 2
      backend-java/src/main/resources/application.yml
  41. 16 0
      backend-java/src/main/resources/db/migration/V2__category_type_display_name.sql
  42. 24 0
      backend-java/src/main/resources/db/migration/V3__sub_plan_binding.sql
  43. 17 0
      backend-java/src/main/resources/db/migration/V4__content_extract_error.sql
  44. 140 0
      backend-java/src/main/resources/db/migration/V5__sub_plan_to_attachment.sql
  45. 37 0
      backend-java/src/main/resources/db/migration/V6__remove_assigned_scope.sql
  46. 4 2
      backend/.env.example
  47. 1 19
      backend/dms/api/v1/roles.py
  48. 3 1
      backend/dms/common/enums.py
  49. 15 7
      backend/dms/config.py
  50. 1 2
      backend/dms/models/__init__.py
  51. 5 46
      backend/dms/models/role.py
  52. 450 90
      backend/dms/resources/ui-dictionaries.zh-CN.json
  53. 21 40
      backend/dms/services/authorization_service.py
  54. 8 3
      backend/dms/services/document_mutation_service.py
  55. 17 0
      backend/dms/services/organization_service.py
  56. 13 104
      backend/dms/services/role_service.py
  57. 240 0
      backend/migrations/versions/0020_sub_plan_to_attachment.py
  58. 194 0
      backend/migrations/versions/0021_remove_assigned_scope.py
  59. 2 48
      backend/openapi/components/schemas.yaml
  60. 1 9
      backend/openapi/openapi.yaml
  61. 2 60
      backend/openapi/paths/roles.yaml
  62. 10 6
      backend/tests/dms/conftest.py
  63. 17 5
      backend/tests/dms/test_config.py
  64. 8 4
      backend/tests/dms/test_db_upgrade.py
  65. 0 1
      backend/tests/dms/test_http.py
  66. 22 1
      backend/tests/dms/test_migration.py
  67. 1 6
      backend/tests/dms/test_models.py
  68. 1 2
      backend/tests/dms/test_openapi_storage.py
  69. 1 1
      backend/tests/dms/test_q1b_ui_dictionaries.py
  70. 1 1
      backend/tests/dms/test_q2b_updated_range.py
  71. 1 1
      backend/tests/dms/test_q3b_business_content_preview.py
  72. 6 14
      backend/tests/dms/test_role_authorization.py
  73. 4 62
      backend/tests/dms/test_role_management.py
  74. 113 0
      docs/plans/SpringBoot转写验证记录.md
  75. 15 15
      docs/plans/角色管理与权限矩阵方案.md
  76. 81 0
      docs/plans/配套方案并入共享资料库方案.md
  77. 159 149
      frontend/src/app/App.tsx
  78. 44 58
      frontend/src/features/audit/AuditDashboard.tsx
  79. 41 26
      frontend/src/features/bindings/AttachmentBindingModal.tsx
  80. 16 16
      frontend/src/features/bindings/SubPlanBindingModal.tsx
  81. 4 4
      frontend/src/features/bindings/UnbindAttachmentModal.tsx
  82. 50 45
      frontend/src/features/directory/DirectoryPage.tsx
  83. 17 14
      frontend/src/features/directory/IpEntryMaintenancePanel.tsx
  84. 10 10
      frontend/src/features/directory/OrgIpAssignmentModal.tsx
  85. 32 214
      frontend/src/features/directory/RoleMaintenancePanel.tsx
  86. 0 22
      frontend/src/features/directory/__tests__/roleApi.test.ts
  87. 1 1
      frontend/src/features/directory/__tests__/roleTypes.test.ts
  88. 1 0
      frontend/src/features/directory/directoryTypes.ts
  89. 6 6
      frontend/src/features/directory/directoryUi.tsx
  90. 0 37
      frontend/src/features/directory/roleApi.ts
  91. 0 6
      frontend/src/features/directory/roleTypes.ts
  92. 6 6
      frontend/src/features/documents/DocumentSearchToolbar.tsx
  93. 6 6
      frontend/src/features/documents/GlobalSearchBar.tsx
  94. 2 1
      frontend/src/features/documents/__tests__/q2fScope.test.ts
  95. 23 23
      frontend/src/features/files/FilePreviewModal.tsx
  96. 2 2
      frontend/src/features/files/PdfViewer.tsx
  97. 5 2
      frontend/src/features/files/__tests__/q3fScope.test.ts
  98. 47 45
      frontend/src/features/mutations/MutationModals.tsx
  99. 44 40
      frontend/src/features/permissions/PermissionModal.tsx
  100. 24 24
      frontend/src/features/recycle-bin/RecycleBinModal.tsx

+ 6 - 0
DMS_API_CONTRACT.md

@@ -1460,6 +1460,12 @@ GET /api/v1/config/ui-dictionaries
 
 版本1.13经统领会话批准,新增公开接口 `GET /api/v1/config/login-background`(登录前无凭证访问):按序探测服务端运行目录下 `background.jpg`、`background.png`,命中即返回图片二进制(`image/jpeg`/`image/png`,含 `nosniff` 与 `public, max-age=60` 缓存头);均不存在时返回 `404 FILE_NOT_FOUND` 标准错误体,前端回退默认纯色背景。前端登录类界面(账号登录、门户跳转登录、认证状态页)自动探测该接口,有图时拉伸铺满(cover 居中不重复,保留纯色兜底),同一会话只探测一次。无数据库变更,OpenAPI同步升级为 `1.13.0`。
 
+版本1.14经统领会话批准:①附件类型扩展四值`ACCOMPANYING_PLAN`(配套方案,UI字典attachmentTypes居首),原SUB_PLAN文档停止新建(创建请求返回400 INVALID_ARGUMENT"配套方案已并入共享资料库,请以附件类型ACCOMPANYING_PLAN上传"),存量子案由迁移`0020_sub_plan_to_attachment`转为该类型附件(保留密级、置空父/根/分类、父子关系转为挂载关系);②主案`allowedActions`对ADMIN恒含`UNBIND_ATTACHMENT`(与BIND对称,移除"存在附件挂载"前置);③附件shape约束放宽(`security_level`不再强制PUBLIC),附件详情/预览/下载增加密级前置(密级不足返回403 SECURITY_LEVEL_FORBIDDEN,任何角色含ADMIN一致);④共享资料库条目均携带`attachmentType`四类之一,配套方案Tab的上传/挂载/解绑/排序与其余三类附件同构。OpenAPI同步升级为`1.14.0`。
+
+版本1.15经统领会话批准,机构维护增强:`PUT /api/v1/organizations/{id}`载荷新增必填`orgCode`(机构编码可编辑),保存时查重(排除自身、仅比对未删除机构),冲突返回409并提示"机构编码已存在";创建语义不变。OpenAPI同步升级为`1.15.0`。
+
+版本1.16经统领会话批准,移除角色"可访问部门"维度:①`GET/PUT /api/v1/roles/{id}/departments`两个接口下线,`sys_role_dept`表删除(迁移`0021_remove_assigned_scope` / Flyway `V6__remove_assigned_scope`);②`DeptScope`枚举收敛为`OWN`/`OTHER`,"其他部门"涵盖所属部门子树之外的全部部门(含无归属文档);③权限矩阵由每角色15行(5密级×3部门范围)收敛为10行(5密级×2部门范围),存量`dept_scope='ASSIGNED'`矩阵行物理删除;④UI字典`deptScopes`移除`ASSIGNED`选项。OpenAPI同步升级为`1.16.0`。
+
 ## 20. Q3-B 1.4接口补充
 
 1. 正式`RoleCode`仅为`ADMIN`、`USER`。四个审计查询接口与文档统计接口均要求ADMIN。历史AUDITOR不能登录,旧Token必须因authVersion或用户状态校验返回401。

+ 5 - 3
backend-java/.env.example

@@ -1,6 +1,8 @@
-# DMS MySQL 8 连接(与 backend/.env.example 同键同值可共用一份 .env)
-# 兼容两种格式:Python 版 mysql+pymysql:// 连接串会被自动转换为 JDBC URL
-DMS_DATABASE_URL=mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4
+# 数据库连接:达梦 DM(当前启用;主机不变,端口 5236,用户/库名 DMS)
+# 兼容两种格式:Python 版 dm+dmPython:// 连接串会被自动转换为 jdbc:dm://
+DMS_DATABASE_URL=dm+dmPython://DMS:change-me@127.0.0.1:5236
+# 原 MySQL 8 连接(保留备查,回切时取消注释并注释上方 DM 链接):
+# DMS_DATABASE_URL=mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4
 
 # 存储根目录:留空时使用工作目录下 dms-storage/。
 # 与 Python 版并行验证共用存储时,显式指向 backend/dms-storage

+ 9 - 1
backend-java/pom.xml

@@ -41,7 +41,7 @@
       <version>${mybatis-starter.version}</version>
     </dependency>
 
-    <!-- MySQL 8 驱动与迁移 -->
+    <!-- MySQL 8 驱动与迁移(切换达梦后链接已注释,回切 MySQL 时恢复 .env 链接即可) -->
     <dependency>
       <groupId>com.mysql</groupId>
       <artifactId>mysql-connector-j</artifactId>
@@ -56,6 +56,14 @@
       <artifactId>flyway-mysql</artifactId>
     </dependency>
 
+    <!-- 达梦 DM8 JDBC 驱动(当前数据库:DM,默认端口 5236) -->
+    <dependency>
+      <groupId>com.dameng</groupId>
+      <artifactId>DmJdbcDriver18</artifactId>
+      <version>8.1.3.62</version>
+      <scope>runtime</scope>
+    </dependency>
+
     <!-- JWT(HS256,与 Python 版 PyJWT 互通;java-jwt 不强制 256 位密钥,兼容存量密钥) -->
     <dependency>
       <groupId>com.auth0</groupId>

+ 6 - 3
backend-java/src/main/java/com/dms/api/v1/AttachmentsController.java

@@ -35,7 +35,8 @@ public class AttachmentsController {
         this.documentUploads = documentUploads;
     }
 
-    @AuthRequired(requireAdmin = true)
+    /** 列表全量可见(全局检索需要):仅认证,不要求管理员。 */
+    @AuthRequired
     @GetMapping
     public ApiResponse<Object> list(HttpServletRequest request) {
         return ApiResponse.ok(attachmentQueryService.listAttachments(
@@ -64,7 +65,8 @@ public class AttachmentsController {
                 Payloads.pathId(id), request));
     }
 
-    @AuthRequired(requireAdmin = true)
+    /** 编辑:服务内按文档权限(EDIT)校验,密级不足/权限不足分别拒绝。 */
+    @AuthRequired
     @PutMapping("/{id}")
     public ApiResponse<Object> update(@PathVariable String id, @RequestBody Object body,
                                       HttpServletRequest request) {
@@ -72,7 +74,8 @@ public class AttachmentsController {
                 Payloads.pathId(id), Payloads.jsonBody(body), request));
     }
 
-    @AuthRequired(requireAdmin = true)
+    /** 删除:服务内按文档权限(DELETE)校验。 */
+    @AuthRequired
     @DeleteMapping("/{id}")
     public ApiResponse<Object> delete(@PathVariable String id,
                                       @RequestParam(required = false) String rowVersion,

+ 23 - 11
backend-java/src/main/java/com/dms/api/v1/DocumentsController.java

@@ -85,7 +85,7 @@ public class DocumentsController {
                 Payloads.pathId(id)));
     }
 
-    @AuthRequired(requireAdmin = true)
+    @AuthRequired
     @PutMapping("/{id}/relations")
     public ApiResponse<Object> saveRelations(@PathVariable String id,
                                              @RequestBody Object body,
@@ -94,8 +94,8 @@ public class DocumentsController {
                 Payloads.pathId(id), Payloads.jsonBody(body), request));
     }
 
-    /** 管理员拖动重排主案/子方案。 */
-    @AuthRequired(requireAdmin = true)
+    /** 拖动重排=改排序字段:服务内对每个提交文档按 EDIT 校验。 */
+    @AuthRequired
     @PutMapping("/reorder")
     public ApiResponse<Object> reorder(@RequestBody Object body, HttpServletRequest request) {
         return ApiResponse.ok(documentMutationService.reorderDocuments(
@@ -124,7 +124,8 @@ public class DocumentsController {
                 Payloads.pathId(id), request));
     }
 
-    @AuthRequired(requireAdmin = true)
+    /** 编辑:服务内按文档权限(EDIT)校验。 */
+    @AuthRequired
     @PutMapping("/{id}")
     public ApiResponse<Object> update(@PathVariable String id, @RequestBody Object body,
                                       HttpServletRequest request) {
@@ -132,7 +133,8 @@ public class DocumentsController {
                 Payloads.pathId(id), Payloads.jsonBody(body), request));
     }
 
-    @AuthRequired(requireAdmin = true)
+    /** 维护"补充说明":服务内按文档权限(EDIT)校验。 */
+    @AuthRequired
     @PutMapping("/{id}/description")
     public ApiResponse<Object> updateDescription(@PathVariable String id,
                                                  @RequestBody Object body,
@@ -141,7 +143,8 @@ public class DocumentsController {
                 Payloads.pathId(id), Payloads.jsonBody(body), request));
     }
 
-    @AuthRequired(requireAdmin = true)
+    /** 删除:服务内按文档权限(DELETE)校验。 */
+    @AuthRequired
     @DeleteMapping("/{id}")
     public ApiResponse<Object> delete(@PathVariable String id,
                                       @RequestParam(required = false) String rowVersion,
@@ -150,6 +153,14 @@ public class DocumentsController {
                 Payloads.pathId(id), Payloads.deleteRowVersion(rowVersion), request));
     }
 
+    /** 管理员对存量文档重新提取正文(修复提取失败/空文本)。 */
+    @AuthRequired(requireAdmin = true)
+    @PostMapping("/{id}/re-extract")
+    public ApiResponse<Object> reExtract(@PathVariable String id, HttpServletRequest request) {
+        return ApiResponse.ok(documentMutationService.reExtractContent(
+                Payloads.pathId(id), request));
+    }
+
     @AuthRequired
     @GetMapping("/{id}/preview")
     public ResponseEntity<Resource> preview(@PathVariable String id,
@@ -164,7 +175,8 @@ public class DocumentsController {
         return fileReadService.downloadDocument(Payloads.pathId(id), request);
     }
 
-    @AuthRequired(requireAdmin = true)
+    /** 设密/改密/清密:服务内按文档权限(CONFIG_PERMISSION)校验。 */
+    @AuthRequired
     @PutMapping("/{id}/password")
     public ApiResponse<Object> savePassword(@PathVariable String id,
                                             @RequestBody Object body,
@@ -173,8 +185,8 @@ public class DocumentsController {
                 Payloads.pathId(id), Payloads.jsonBody(body), request));
     }
 
-    /** 主案 ACL 查询(子方案动态继承主案)。 */
-    @AuthRequired(requireAdmin = true)
+    /** 主案 ACL 查询(子方案动态继承主案):服务内按授权位/密码校验。 */
+    @AuthRequired
     @GetMapping("/{id}/permissions")
     public ApiResponse<Object> permissions(@PathVariable String id,
                                            HttpServletRequest request) {
@@ -182,8 +194,8 @@ public class DocumentsController {
                 Payloads.pathId(id), request));
     }
 
-    /** 主案 ACL 差异保存(子方案拒绝,附件无 ACL)。 */
-    @AuthRequired(requireAdmin = true)
+    /** 主案 ACL 差异保存(子方案拒绝,附件无 ACL):服务内按授权位校验。 */
+    @AuthRequired
     @PutMapping("/{id}/permissions")
     public ApiResponse<Object> savePermissions(@PathVariable String id,
                                                @RequestBody Object body,

+ 127 - 0
backend-java/src/main/java/com/dms/api/v1/LoginBackgroundController.java

@@ -0,0 +1,127 @@
+package com.dms.api.v1;
+
+import com.dms.common.DmsException;
+import com.dms.config.LauncherBootstrap;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.springframework.core.env.Environment;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 登录界面自定义背景图接口,对齐 backend/dms/api/v1/login_background.py。
+ *
+ * <p>用户将 background.jpg 或 background.png 放到服务端运行目录
+ * (发行包为 jar 同级目录,开发模式为工作目录)即可让登录界面自动展示
+ * 自定义背景;两个文件同时存在时优先 jpg。接口公开(登录前无凭证访问),
+ * 候选文件名为固定白名单,不接收路径参数,无目录穿越风险;
+ * 均不存在时返回 404 FILE_NOT_FOUND,前端回退默认纯色背景。</p>
+ */
+@RestController
+public class LoginBackgroundController {
+
+    /** 候选文件名(固定白名单):同时存在时按声明顺序优先。 */
+    private static final Map<String, String> BACKGROUND_FILES = new LinkedHashMap<>();
+
+    static {
+        BACKGROUND_FILES.put("background.jpg", "image/jpeg");
+        BACKGROUND_FILES.put("background.png", "image/png");
+    }
+
+    private static final DateTimeFormatter HTTP_DATE =
+            DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'").withZone(ZoneOffset.UTC);
+
+    private final Environment environment;
+
+    public LoginBackgroundController(Environment environment) {
+        this.environment = environment;
+    }
+
+    @GetMapping("/api/v1/config/login-background")
+    public ResponseEntity<byte[]> getLoginBackground() {
+        for (Map.Entry<String, String> entry : BACKGROUND_FILES.entrySet()) {
+            Path path = candidateRoot().resolve(entry.getKey());
+            if (!Files.isRegularFile(path)) {
+                continue;
+            }
+            try {
+                return respond(path, Files.readAllBytes(path), entry.getValue());
+            } catch (Exception ex) {
+                // 读取失败按不存在处理,回退下一候选/404
+            }
+        }
+        throw new DmsException("FILE_NOT_FOUND", 404,
+                "运行目录下不存在 background.jpg/png 背景图", null);
+    }
+
+    /** 服务端运行目录:DMS_LOGIN_BACKGROUND_DIR 覆盖(测试与特殊部署)。 */
+    private Path candidateRoot() {
+        String override = environment.getProperty("DMS_LOGIN_BACKGROUND_DIR", "").trim();
+        if (!override.isEmpty()) {
+            return Path.of(override);
+        }
+        return LauncherBootstrap.appRoot();
+    }
+
+    /** nosniff + 短缓存;等价 send_file(conditional=True) 的 If-None-Match/If-Modified-Since → 304。 */
+    private static ResponseEntity<byte[]> respond(Path path, byte[] body, String mimeType) {
+        try {
+            long size = Files.size(path);
+            long modified = Files.getLastModifiedTime(path).toMillis();
+            String etag = "W/\"dmsbg-" + size + "-" + modified + "\"";
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.parseMediaType(mimeType));
+            headers.set("X-Content-Type-Options", "nosniff");
+            // 背景图变更频率低,允许浏览器短缓存减少首屏请求
+            headers.setCacheControl("public, max-age=60");
+            headers.setETag(etag);
+            headers.setLastModified(Instant.ofEpochMilli(modified));
+            if (notModified(etag, modified)) {
+                return new ResponseEntity<>(headers, HttpStatus.NOT_MODIFIED);
+            }
+            return new ResponseEntity<>(body, headers, HttpStatus.OK);
+        } catch (Exception ex) {
+            throw new DmsException("FILE_NOT_FOUND", 404,
+                    "运行目录下不存在 background.jpg/png 背景图", null);
+        }
+    }
+
+    private static boolean notModified(String etag, long modified) {
+        var request = org.springframework.web.context.request.RequestContextHolder
+                .getRequestAttributes();
+        if (!(request instanceof org.springframework.web.context.request.ServletRequestAttributes
+                attributes)) {
+            return false;
+        }
+        var httpRequest = attributes.getRequest();
+        String ifNoneMatch = httpRequest.getHeader("If-None-Match");
+        if (ifNoneMatch != null) {
+            for (String candidate : ifNoneMatch.split(",")) {
+                if (candidate.trim().equals(etag)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+        String ifModifiedSince = httpRequest.getHeader("If-Modified-Since");
+        if (ifModifiedSince == null) {
+            return false;
+        }
+        try {
+            long since = ZonedDateTime.parse(ifModifiedSince, HTTP_DATE).toInstant().toEpochMilli();
+            return modified <= since;
+        } catch (Exception ex) {
+            return false;
+        }
+    }
+}

+ 27 - 4
backend-java/src/main/java/com/dms/api/v1/MainPlansController.java

@@ -7,6 +7,7 @@ import com.dms.security.AuthRequired;
 import com.dms.service.AttachmentBindingService;
 import com.dms.service.AttachmentQueryService;
 import com.dms.service.DocumentQueryService;
+import com.dms.service.SubPlanBindingService;
 import jakarta.servlet.http.HttpServletRequest;
 import java.util.Set;
 import org.springframework.web.bind.annotation.DeleteMapping;
@@ -26,13 +27,16 @@ public class MainPlansController {
     private final DocumentQueryService documentQueryService;
     private final AttachmentQueryService attachmentQueryService;
     private final AttachmentBindingService attachmentBindingService;
+    private final SubPlanBindingService subPlanBindingService;
 
     public MainPlansController(DocumentQueryService documentQueryService,
                                AttachmentQueryService attachmentQueryService,
-                               AttachmentBindingService attachmentBindingService) {
+                               AttachmentBindingService attachmentBindingService,
+                               SubPlanBindingService subPlanBindingService) {
         this.documentQueryService = documentQueryService;
         this.attachmentQueryService = attachmentQueryService;
         this.attachmentBindingService = attachmentBindingService;
+        this.subPlanBindingService = subPlanBindingService;
     }
 
     @AuthRequired
@@ -42,6 +46,25 @@ public class MainPlansController {
                 Payloads.pathId(id), Payloads.queryParams(request)));
     }
 
+    /** 挂载其他主案的子案到当前主案(建立展示关系,权限仍继承原主案)。 */
+    @AuthRequired
+    @PostMapping("/{id}/sub-plans/bind")
+    public ApiResponse<Object> bindSubPlans(@PathVariable String id, @RequestBody Object body,
+                                            HttpServletRequest request) {
+        return ApiResponse.ok(subPlanBindingService.bindSubPlans(
+                Payloads.pathId(id), Payloads.jsonBody(body), request));
+    }
+
+    @AuthRequired
+    @DeleteMapping("/{id}/sub-plans/{subPlanId}")
+    public ApiResponse<Object> unbindSubPlan(@PathVariable String id,
+                                             @PathVariable String subPlanId,
+                                             HttpServletRequest request) {
+        return ApiResponse.ok(subPlanBindingService.unbindSubPlan(
+                Payloads.pathId(id), Payloads.pathId(subPlanId, "subPlanId"),
+                mainPlanRowVersion(request), request));
+    }
+
     @AuthRequired
     @GetMapping("/{id}/attachments")
     public ApiResponse<Object> attachments(@PathVariable String id, HttpServletRequest request) {
@@ -49,7 +72,7 @@ public class MainPlansController {
                 Payloads.pathId(id), Payloads.queryParams(request)));
     }
 
-    @AuthRequired(requireAdmin = true)
+    @AuthRequired
     @PostMapping("/{id}/attachments/bind")
     public ApiResponse<Object> bind(@PathVariable String id, @RequestBody Object body,
                                     HttpServletRequest request) {
@@ -58,7 +81,7 @@ public class MainPlansController {
     }
 
     /** 调整主案下挂载附件的显示顺序(顺序存于挂载关系,各主案独立)。 */
-    @AuthRequired(requireAdmin = true)
+    @AuthRequired
     @PutMapping("/{id}/attachments/reorder")
     public ApiResponse<Object> reorder(@PathVariable String id, @RequestBody Object body,
                                        HttpServletRequest request) {
@@ -66,7 +89,7 @@ public class MainPlansController {
                 Payloads.pathId(id), Payloads.jsonBody(body), request));
     }
 
-    @AuthRequired(requireAdmin = true)
+    @AuthRequired
     @DeleteMapping("/{id}/attachments/{attachmentId}")
     public ApiResponse<Object> unbind(@PathVariable String id,
                                       @PathVariable String attachmentId,

+ 0 - 15
backend-java/src/main/java/com/dms/api/v1/RolesController.java

@@ -64,21 +64,6 @@ public class RolesController {
                 Payloads.pathId(id), Payloads.deleteRowVersion(rowVersion), request));
     }
 
-    @AuthRequired(requireAdmin = true)
-    @GetMapping("/{id}/departments")
-    public ApiResponse<Object> departments(@PathVariable String id) {
-        return ApiResponse.ok(roleService.listDepartments(Payloads.pathId(id)));
-    }
-
-    @AuthRequired(requireAdmin = true)
-    @PutMapping("/{id}/departments")
-    public ApiResponse<Object> updateDepartments(@PathVariable String id,
-                                                 @RequestBody Object body,
-                                                 HttpServletRequest request) {
-        return ApiResponse.ok(roleService.updateDepartments(
-                Payloads.pathId(id), body, request));
-    }
-
     @AuthRequired(requireAdmin = true)
     @GetMapping("/{id}/permissions")
     public ApiResponse<Object> permissions(@PathVariable String id) {

+ 2 - 1
backend-java/src/main/java/com/dms/api/v1/UsersController.java

@@ -47,7 +47,8 @@ public class UsersController {
         if (user == null) {
             throw Errors.tokenInvalid();
         }
-        return ApiResponse.ok(userSummaryService.userSummary(user));
+        return ApiResponse.ok(userSummaryService.userSummary(user,
+                userSummaryService.allowedModulesForUser(user)));
     }
 
     @AuthRequired(requireAdmin = true)

+ 3 - 2
backend-java/src/main/java/com/dms/common/DmsEnums.java

@@ -31,11 +31,12 @@ public final class DmsEnums {
             "ALL_AUTHENTICATED", "ORGANIZATION", "CUSTOM");
 
     public static final Set<String> ATTACHMENT_TYPES = Set.of(
-            "SUPPORT_ACHIEVEMENT", "TRAINING_MATERIAL", "TYPICAL_CASE");
+            "SUPPORT_ACHIEVEMENT", "TRAINING_MATERIAL", "TYPICAL_CASE",
+            "ACCOMPANYING_PLAN");
 
     public static final Set<String> SUBJECT_TYPES = Set.of("ORG", "USER");
 
-    public static final Set<String> DEPT_SCOPES = Set.of("OWN", "ASSIGNED", "OTHER");
+    public static final Set<String> DEPT_SCOPES = Set.of("OWN", "OTHER");
 
     public static final Set<String> ALLOWED_ACTIONS = Set.of(
             "VIEW", "DOWNLOAD", "EDIT", "CONFIG_PERMISSION", "DELETE",

+ 6 - 0
backend-java/src/main/java/com/dms/common/Errors.java

@@ -73,6 +73,12 @@ public final class Errors {
         return new DmsException("DOCUMENT_DOWNLOAD_FORBIDDEN", 403, "无权下载该文档");
     }
 
+    /** Python: DocumentActionForbiddenError (403):写路径按文档权限(矩阵/ACL)拒绝。 */
+    public static DmsException documentActionForbidden(String message) {
+        return new DmsException("DOCUMENT_ACTION_FORBIDDEN", 403,
+                message == null ? "无权对该文档执行此操作" : message);
+    }
+
     /** Python: ResourceNotFoundError (404)。 */
     public static DmsException resourceNotFound() {
         return new DmsException("RESOURCE_NOT_FOUND", 404, "请求的资源不存在");

+ 15 - 0
backend-java/src/main/java/com/dms/common/Payloads.java

@@ -62,6 +62,21 @@ public final class Payloads {
         return normalized.isEmpty() ? null : normalized;
     }
 
+    /** 对齐 _nullable_text:null 放行、strip 后空串归一为 null。 */
+    public static String nullableText(Object value, String field, int maxLength) {
+        if (value == null) {
+            return null;
+        }
+        if (!(value instanceof String text)) {
+            throw Errors.invalidArgument(field + "必须是字符串或null");
+        }
+        String normalized = text.strip();
+        if (normalized.length() > maxLength) {
+            throw Errors.invalidArgument(field + "长度不能超过" + maxLength);
+        }
+        return normalized.isEmpty() ? null : normalized;
+    }
+
     /** 对齐 _string_id:nullable 时 null 放行,否则必须为正整数字符串。 */
     public static Long stringId(Object value, String field, boolean nullable) {
         if (value == null) {

+ 12 - 2
backend-java/src/main/java/com/dms/config/DataSourceConfig.java

@@ -8,18 +8,28 @@ import org.springframework.context.annotation.Configuration;
 /**
  * 数据源:行为对齐 Python 版 SQLAlchemy engine 选项
  * (READ COMMITTED / 会话时区 UTC / 连接回收 1800s / 借出前活性校验)。
+ *
+ * <p>当前数据库为达梦 DM(jdbc:dm://,schema 由用户 DMS 决定);
+ * MySQL 专属的会话时区初始化(SET time_zone)按 URL 前缀条件设置,
+ * 回切 MySQL 时只需恢复 .env 中的 mysql 链接。</p>
  */
 @Configuration
 public class DataSourceConfig {
 
     @Bean
     public DataSource dataSource(DmsProperties properties) {
+        String jdbcUrl = properties.jdbcUrl();
         HikariDataSource dataSource = new HikariDataSource();
-        dataSource.setJdbcUrl(properties.jdbcUrl());
+        dataSource.setJdbcUrl(jdbcUrl);
+        if (jdbcUrl.startsWith("jdbc:dm://")) {
+            dataSource.setDriverClassName("dm.jdbc.driver.DmDriver");
+        }
         dataSource.setUsername(properties.dbUsername());
         dataSource.setPassword(properties.dbPassword());
         dataSource.setTransactionIsolation("TRANSACTION_READ_COMMITTED");
-        dataSource.setConnectionInitSql("SET time_zone = '+00:00'");
+        if (jdbcUrl.startsWith("jdbc:mysql://")) {
+            dataSource.setConnectionInitSql("SET time_zone = '+00:00'");
+        }
         dataSource.setMaxLifetime(1800000);
         return dataSource;
     }

+ 28 - 15
backend-java/src/main/java/com/dms/config/DmsProperties.java

@@ -20,7 +20,8 @@ import org.springframework.stereotype.Component;
 @Component
 public class DmsProperties {
 
-    /** 形如 mysql+pymysql://user:pass@host:port/db?charset=utf8mb4,亦接受 jdbc:mysql:// 开头。 */
+    /** 形如 dm+dmPython://DMS:pass@host:5236(达梦,当前默认),亦接受 jdbc:dm:// 开头;
+     *  历史支持 mysql+pymysql://user:pass@host:port/db?charset=utf8mb4 与 jdbc:mysql://。 */
     private final String databaseUrl;
     private final Path storageRoot;
     private final String jwtSecret;
@@ -38,7 +39,9 @@ public class DmsProperties {
 
     public DmsProperties(Environment environment) {
         this.databaseUrl = environment.getProperty("DMS_DATABASE_URL",
-                "mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4");
+                // 原 MySQL 默认链接(保留备查):
+                // "mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4"
+                "dm+dmPython://DMS:change-me@127.0.0.1:5236");
         String storageRootValue = environment.getProperty("DMS_STORAGE_ROOT", "");
         this.storageRoot = Path.of(storageRootValue.isBlank() ? "dms-storage" : storageRootValue)
                 .toAbsolutePath();
@@ -219,21 +222,29 @@ public class DmsProperties {
     /**
      * 将 Python 版 SQLAlchemy 连接串转换为 JDBC URL,保证同一份 .env 两版后端可用。
      *
-     * <p>会话时区对齐 Python 版 init_command(SET time_zone = '+00:00'),
-     * 由 application.yml 中 Hikari 的 connection-init-sql 完成。</p>
+     * <p>MySQL 会话时区对齐 Python 版 init_command(SET time_zone = '+00:00'),
+     * 由 DataSourceConfig 按 URL 前缀条件设置(达梦不支持该语法)。</p>
      */
     public String jdbcUrl() {
-        if (databaseUrl.startsWith("jdbc:mysql://")) {
+        if (databaseUrl.startsWith("jdbc:mysql://") || databaseUrl.startsWith("jdbc:dm://")) {
             return databaseUrl;
         }
-        Pattern pattern = Pattern.compile("^mysql\\+pymysql://([^@]+)@([^/]+)(/[^?]*)(\\?.*)?$");
-        Matcher matcher = pattern.matcher(databaseUrl.trim());
-        if (!matcher.matches()) {
-            throw new IllegalStateException("DMS_DATABASE_URL 格式无法识别: " + databaseUrl);
+        Matcher mysql = Pattern
+                .compile("^mysql\\+pymysql://([^@]+)@([^/]+)(/[^?]*)(\\?.*)?$")
+                .matcher(databaseUrl.trim());
+        if (mysql.matches()) {
+            return "jdbc:mysql://" + mysql.group(2) + mysql.group(3)
+                    + "?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true";
+        }
+        // 达梦:dm+dmPython://user:pass@host:port(无库路径,schema 由用户名决定,即 DMS)
+        Matcher dm = Pattern
+                .compile("^dm\\+dmPython://([^@]+)@([^/?]+)(\\?.*)?$")
+                .matcher(databaseUrl.trim());
+        if (dm.matches()) {
+            return "jdbc:dm://" + dm.group(2)
+                    + (dm.group(3) == null ? "" : dm.group(3));
         }
-        String path = matcher.group(3);
-        return "jdbc:mysql://" + matcher.group(2) + path
-                + "?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true";
+        throw new IllegalStateException("DMS_DATABASE_URL 格式无法识别: " + databaseUrl);
     }
 
     /** 从连接串提取用户名。 */
@@ -249,10 +260,12 @@ public class DmsProperties {
     }
 
     private String rawCredentials() {
-        if (databaseUrl.startsWith("jdbc:mysql://")) {
-            throw new IllegalStateException("jdbc:mysql 形式的 DMS_DATABASE_URL 不含可解析凭据,请直接提供 spring.datasource 配置");
+        if (databaseUrl.startsWith("jdbc:mysql://") || databaseUrl.startsWith("jdbc:dm://")) {
+            throw new IllegalStateException("jdbc: 形式的 DMS_DATABASE_URL 不含可解析凭据,请直接提供 spring.datasource 配置");
         }
-        Matcher matcher = Pattern.compile("^mysql\\+pymysql://([^@]+)@").matcher(databaseUrl.trim());
+        Matcher matcher = Pattern
+                .compile("^(?:mysql\\+pymysql|dm\\+dmPython)://([^@]+)@")
+                .matcher(databaseUrl.trim());
         if (!matcher.find()) {
             throw new IllegalStateException("DMS_DATABASE_URL 格式无法识别: " + databaseUrl);
         }

+ 1 - 1
backend-java/src/main/java/com/dms/config/LauncherBootstrap.java

@@ -22,7 +22,7 @@ public final class LauncherBootstrap {
     }
 
     /** 工作目录(发行包 run 脚本会先 cd 到 jar 同目录,等价 Python 的 exe 同目录)。 */
-    private static Path appRoot() {
+    public static Path appRoot() {
         return Path.of(System.getProperty("user.dir"));
     }
 

+ 20 - 38
backend-java/src/main/java/com/dms/mapper/CategoryMapper.java

@@ -39,15 +39,17 @@ public interface CategoryMapper {
 
     @Insert("INSERT INTO doc_category (category_code, category_name, category_type, "
             + "parent_id, category_path, sort_no, document_count, status, flat_mode, "
-            + "created_by, updated_by) VALUES (#{categoryCode}, #{categoryName}, "
-            + "#{categoryType}, #{parentId}, #{categoryPath}, #{sortNo}, #{documentCount}, "
-            + "#{status}, #{flatMode}, #{createdBy}, #{updatedBy})")
+            + "type_display_name, created_by, updated_by) VALUES (#{categoryCode}, "
+            + "#{categoryName}, #{categoryType}, #{parentId}, #{categoryPath}, #{sortNo}, "
+            + "#{documentCount}, #{status}, #{flatMode}, #{typeDisplayName}, "
+            + "#{createdBy}, #{updatedBy})")
     @Options(useGeneratedKeys = true, keyProperty = "id")
     int insert(CategoryEntity category);
 
     @Update("UPDATE doc_category SET category_name = #{categoryName}, "
             + "category_type = #{categoryType}, parent_id = #{parentId}, "
             + "category_path = #{categoryPath}, flat_mode = #{flatMode}, "
+            + "type_display_name = #{typeDisplayName}, "
             + "sort_no = #{sortNo}, updated_by = #{updatedBy}, updated_at = #{updatedAt}, "
             + "row_version = row_version + 1 WHERE id = #{id}")
     int update(CategoryEntity category);
@@ -81,44 +83,16 @@ public interface CategoryMapper {
             + "ORDER BY sort_no ASC, id ASC FOR UPDATE</script>")
     List<CategoryEntity> listSiblingsForUpdate(@Param("parentId") Long parentId);
 
-    // ------------------------------------------------------------ 分类树可见主案计数
-
-    /** ADMIN(或密级过滤版):按密级白名单聚合各类目主案数。 */
-    @Select("<script>SELECT category_id AS categoryId, COUNT(id) AS cnt FROM doc_document "
-            + "WHERE category_id IS NOT NULL AND document_type = 'MAIN' AND is_deleted = 0 "
-            + "AND security_level IN <foreach item='item' collection='levels' open='(' "
-            + "separator=',' close=')'>#{item}</foreach> GROUP BY category_id</script>")
-    List<Map<String, Object>> countVisibleMainByLevels(@Param("levels") List<String> levels);
+    // ------------------------------------------------------------ flat_mode 启用检查
 
     /**
-     * USER 版:在密级过滤之上叠加 PUBLISHED + 可见性判定
-     * (ALL_AUTHENTICATED / ORGANIZATION×组织ACL / CUSTOM×组织或用户ACL)。
-     * ancestorIds 为空时由服务层传 [-1] 对齐 Python `ancestor_ids or {-1}`。
+     * 阶段10同步(bad04bc 括号计数口径):本分类下有效主体方案(MAIN)计数,
+     * 不按密级/状态/可见性/授权过滤——与"列表全量可见、内容访问受控"一致。
      */
-    @Select("<script>SELECT category_id AS categoryId, COUNT(id) AS cnt FROM doc_document d "
-            + "WHERE d.category_id IS NOT NULL AND d.document_type = 'MAIN' AND d.is_deleted = 0 "
-            + "AND d.security_level IN <foreach item='item' collection='levels' open='(' "
-            + "separator=',' close=')'>#{item}</foreach> "
-            + "AND d.document_status = 'PUBLISHED' AND ("
-            + "d.visibility_type = 'ALL_AUTHENTICATED' "
-            + "OR (d.visibility_type = 'ORGANIZATION' AND EXISTS("
-            + "SELECT 1 FROM doc_permission p WHERE p.document_id = d.id AND p.is_deleted = 0 "
-            + "AND p.can_view = 1 AND p.subject_type = 'ORG' AND p.subject_id IN "
-            + "<foreach item='item' collection='ancestorIds' open='(' separator=',' close=')'>"
-            + "#{item}</foreach>)) "
-            + "OR (d.visibility_type = 'CUSTOM' AND (EXISTS("
-            + "SELECT 1 FROM doc_permission p2 WHERE p2.document_id = d.id AND p2.is_deleted = 0 "
-            + "AND p2.can_view = 1 AND p2.subject_type = 'ORG' AND p2.subject_id IN "
-            + "<foreach item='item' collection='ancestorIds' open='(' separator=',' close=')'>"
-            + "#{item}</foreach>) OR EXISTS("
-            + "SELECT 1 FROM doc_permission p3 WHERE p3.document_id = d.id AND p3.is_deleted = 0 "
-            + "AND p3.can_view = 1 AND p3.subject_type = 'USER' AND p3.subject_id = #{userId})))"
-            + ") GROUP BY d.category_id</script>")
-    List<Map<String, Object>> countVisibleMainForUser(@Param("levels") List<String> levels,
-                                                      @Param("ancestorIds") List<Long> ancestorIds,
-                                                      @Param("userId") long userId);
-
-    // ------------------------------------------------------------ flat_mode 启用检查
+    @Select("SELECT category_id AS categoryId, COUNT(id) AS cnt FROM doc_document "
+            + "WHERE category_id IS NOT NULL AND document_type = 'MAIN' AND is_deleted = 0 "
+            + "GROUP BY category_id")
+    List<Map<String, Object>> countActiveMainGroupByCategory();
 
     /** 候选分类(含后代)下有效主案是否存在子方案。 */
     @Select("<script>SELECT EXISTS(SELECT 1 FROM doc_document WHERE document_type = 'SUB_PLAN' "
@@ -136,6 +110,14 @@ public interface CategoryMapper {
             + "separator=',' close=')'>#{item}</foreach>))</script>")
     boolean existsBindingUnderCategories(@Param("categoryIds") List<Long> categoryIds);
 
+    /** 候选分类下有效主案是否存在子案跨主案挂载(doc_sub_plan_binding,阶段10)。 */
+    @Select("<script>SELECT EXISTS(SELECT 1 FROM doc_sub_plan_binding WHERE is_deleted = 0 "
+            + "AND main_document_id IN ("
+            + "SELECT id FROM doc_document WHERE document_type = 'MAIN' AND is_deleted = 0 "
+            + "AND category_id IN <foreach item='item' collection='categoryIds' open='(' "
+            + "separator=',' close=')'>#{item}</foreach>))</script>")
+    boolean existsSubPlanMountUnderCategories(@Param("categoryIds") List<Long> categoryIds);
+
     // ------------------------------------------------------------ 文档域(阶段5.5)
 
     /** 对齐 _category:有效启用分类(无锁,上传/编辑路径解析用)。 */

+ 45 - 2
backend-java/src/main/java/com/dms/mapper/DocumentMapper.java

@@ -138,14 +138,15 @@ public interface DocumentMapper {
             + "organization_name, parent_document_id, root_document_id, tags, "
             + "original_file_name, file_relative_path, file_extension, mime_type, "
             + "file_size, file_hash, sort_no, content_text, content_extract_status, "
-            + "content_extracted_at, search_text, created_by, updated_by, "
+            + "content_extract_error, content_extracted_at, search_text, created_by, updated_by, "
             + "created_by_name, updated_by_name) VALUES (#{documentName}, #{summary}, "
             + "#{documentType}, #{documentStatus}, #{securityLevel}, #{visibilityType}, "
             + "#{attachmentType}, #{categoryId}, #{categoryName}, #{categoryPath}, "
             + "#{organizationId}, #{organizationName}, #{parentDocumentId}, "
             + "#{rootDocumentId}, #{tags}, #{originalFileName}, #{fileRelativePath}, "
             + "#{fileExtension}, #{mimeType}, #{fileSize}, #{fileHash}, #{sortNo}, "
-            + "#{contentText}, #{contentExtractStatus}, #{contentExtractedAt}, "
+            + "#{contentText}, #{contentExtractStatus}, #{contentExtractError}, "
+            + "#{contentExtractedAt}, "
             + "#{searchText}, #{createdBy}, #{updatedBy}, #{createdByName}, "
             + "#{updatedByName})")
     @Options(useGeneratedKeys = true, keyProperty = "id")
@@ -168,6 +169,7 @@ public interface DocumentMapper {
             + "file_relative_path = #{fileRelativePath}, file_extension = #{fileExtension}, "
             + "mime_type = #{mimeType}, file_size = #{fileSize}, file_hash = #{fileHash}, "
             + "content_text = #{contentText}, content_extract_status = #{contentExtractStatus}, "
+            + "content_extract_error = #{contentExtractError}, "
             + "content_extracted_at = #{contentExtractedAt}, search_text = #{searchText}, "
             + "updated_by = #{updatedBy}, updated_by_name = #{updatedByName}, "
             + "updated_at = #{updatedAt}, row_version = row_version + 1 WHERE id = #{id}")
@@ -232,6 +234,18 @@ public interface DocumentMapper {
             + "updated_at = #{updatedAt}, row_version = row_version + 1 WHERE id = #{id}")
     int updateDescription(DocumentEntity document);
 
+    /**
+     * re_extract_content:覆盖写回提取结果(含失败原因)并同步重建 search_text,
+     * touch updated_by/name/at 与 rv+1。
+     */
+    @Update("UPDATE doc_document SET content_text = #{contentText}, "
+            + "content_extract_status = #{contentExtractStatus}, "
+            + "content_extract_error = #{contentExtractError}, "
+            + "content_extracted_at = #{contentExtractedAt}, search_text = #{searchText}, "
+            + "updated_by = #{updatedBy}, updated_by_name = #{updatedByName}, "
+            + "updated_at = #{updatedAt}, row_version = row_version + 1 WHERE id = #{id}")
+    int updateContentExtraction(DocumentEntity document);
+
     /** update_document 锁定行(MAIN/SUB_PLAN)。 */
     @Select("SELECT * FROM doc_document WHERE id = #{id} "
             + "AND document_type IN ('MAIN', 'SUB_PLAN') AND is_deleted = 0 FOR UPDATE")
@@ -334,6 +348,35 @@ public interface DocumentMapper {
                  @Param("updatedByName") String updatedByName,
                  @Param("updatedAt") LocalDateTime updatedAt);
 
+    /**
+     * has_any_manage_action:任一"已发布主案"命中(密级×部门范围×查看权三来源)。
+     * scope 为 OWN/OTHER;集合为空时调用方传 [-1] 占位(对齐 Python
+     * in_(空集)恒假、not_in(空集)恒真语义);canView=1 时矩阵行自身即查看来源。
+     */
+    @Select("<script>SELECT EXISTS(SELECT 1 FROM doc_document d "
+            + "WHERE d.is_deleted = 0 AND d.document_status = 'PUBLISHED' "
+            + "AND d.document_type = 'MAIN' AND d.security_level = #{securityLevel} "
+            + "<choose>"
+            + "<when test='scope == \"OWN\"'>AND d.organization_id IN "
+            + "<foreach item='i' collection='ownIds' open='(' separator=',' close=')'>#{i}</foreach></when>"
+            + "<otherwise>AND (d.organization_id IS NULL OR d.organization_id NOT IN "
+            + "<foreach item='i' collection='ownIds' open='(' separator=',' close=')'>#{i}</foreach>)</otherwise>"
+            + "</choose>"
+            + "AND (d.visibility_type = 'ALL_AUTHENTICATED' "
+            + "OR EXISTS(SELECT 1 FROM doc_permission p WHERE p.document_id = d.id "
+            + "AND p.is_deleted = 0 AND p.can_view = 1 AND ("
+            + "(p.subject_type = 'ORG' AND p.subject_id IN "
+            + "<foreach item='i' collection='ancestorIds' open='(' separator=',' close=')'>#{i}</foreach>) "
+            + "OR (p.subject_type = 'USER' AND p.subject_id = #{userId} "
+            + "AND d.visibility_type = 'CUSTOM')))"
+            + "<if test='canView'>OR TRUE</if>))</script>")
+    boolean existsManageHit(@Param("securityLevel") String securityLevel,
+                            @Param("scope") String scope,
+                            @Param("ownIds") List<Long> ownIds,
+                            @Param("ancestorIds") List<Long> ancestorIds,
+                            @Param("userId") long userId,
+                            @Param("canView") boolean canView);
+
     /** _current_version:挂载唯一键冲突兜底的当前版本读取。 */
     @Select("SELECT row_version FROM doc_document WHERE id = #{id} AND is_deleted = 0")
     Integer currentRowVersion(@Param("id") long id);

+ 8 - 1
backend-java/src/main/java/com/dms/mapper/OrganizationMapper.java

@@ -48,6 +48,12 @@ public interface OrganizationMapper {
     @Select("SELECT id FROM sys_organization WHERE org_code = #{orgCode} AND is_deleted = 0 LIMIT 1")
     Long findIdByOrgCode(@Param("orgCode") String orgCode);
 
+    /** 更新时编码查重:排除自身,仅比对未删除机构。 */
+    @Select("SELECT id FROM sys_organization WHERE org_code = #{orgCode} "
+            + "AND id != #{excludeId} AND is_deleted = 0 LIMIT 1")
+    Long findIdByOrgCodeExclude(@Param("orgCode") String orgCode,
+                                @Param("excludeId") long excludeId);
+
     // ------------------------------------------------------------ 写操作
 
     @Insert("INSERT INTO sys_organization (org_code, org_name, parent_id, org_path, sort_no, "
@@ -56,7 +62,8 @@ public interface OrganizationMapper {
     @Options(useGeneratedKeys = true, keyProperty = "id")
     int insert(OrganizationEntity organization);
 
-    @Update("UPDATE sys_organization SET org_name = #{orgName}, parent_id = #{parentId}, "
+    @Update("UPDATE sys_organization SET org_code = #{orgCode}, "
+            + "org_name = #{orgName}, parent_id = #{parentId}, "
             + "org_path = #{orgPath}, sort_no = #{sortNo}, status = #{status}, "
             + "updated_by = #{updatedBy}, updated_at = #{updatedAt}, "
             + "row_version = row_version + 1 WHERE id = #{id}")

+ 0 - 36
backend-java/src/main/java/com/dms/mapper/RoleDepartmentMapper.java

@@ -1,36 +0,0 @@
-package com.dms.mapper;
-
-import com.dms.model.RoleDepartmentEntity;
-import java.time.LocalDateTime;
-import org.apache.ibatis.annotations.Insert;
-import org.apache.ibatis.annotations.Param;
-import org.apache.ibatis.annotations.Select;
-import org.apache.ibatis.annotations.Update;
-
-public interface RoleDepartmentMapper {
-
-    @Select("SELECT * FROM sys_role_dept WHERE role_id = #{roleId} AND is_deleted = 0")
-    java.util.List<RoleDepartmentEntity> listActiveByRole(@Param("roleId") long roleId);
-
-    /** 部门范围判定:角色是否分配了文档归属组织(对齐 _dept_scope ASSIGNED 分支)。 */
-    @Select("SELECT id FROM sys_role_dept WHERE role_id = #{roleId} "
-            + "AND organization_id = #{organizationId} AND is_deleted = 0 LIMIT 1")
-    Long findIdByRoleAndOrganization(@Param("roleId") long roleId,
-                                     @Param("organizationId") long organizationId);
-
-    /** 角色删除时配置行级联软删。 */
-    @Update("UPDATE sys_role_dept SET is_deleted = 1, deleted_at = #{now}, "
-            + "updated_by = #{actorId}, updated_at = #{now} "
-            + "WHERE role_id = #{roleId} AND is_deleted = 0")
-    int softDeleteByRole(@Param("roleId") long roleId, @Param("now") LocalDateTime now,
-                         @Param("actorId") Long actorId);
-
-    @Update("UPDATE sys_role_dept SET is_deleted = 1, deleted_at = #{now}, "
-            + "updated_by = #{actorId}, updated_at = #{now} WHERE id = #{id}")
-    int softDelete(@Param("id") long id, @Param("now") LocalDateTime now,
-                   @Param("actorId") Long actorId);
-
-    @Insert("INSERT INTO sys_role_dept (role_id, organization_id, created_by, updated_by) "
-            + "VALUES (#{roleId}, #{organizationId}, #{createdBy}, #{updatedBy})")
-    int insert(RoleDepartmentEntity row);
-}

+ 54 - 0
backend-java/src/main/java/com/dms/mapper/SubPlanBindingMapper.java

@@ -0,0 +1,54 @@
+package com.dms.mapper;
+
+import com.dms.model.SubPlanBindingEntity;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+/**
+ * doc_sub_plan_binding 表访问(阶段10,对齐 sub_plan_binding_service.py)。
+ *
+ * <p>主案对其他主案子案的展示挂载关系:软删 + 生成列唯一约束
+ * (main, sub, active_marker) 保证同一主案对同一子案至多一条有效挂载;
+ * 主案自有子案不允许建立挂载,由服务层校验。</p>
+ */
+public interface SubPlanBindingMapper {
+
+    /** bind:主案全部挂载行(含软删)按 id 升序锁定。 */
+    @Select("SELECT * FROM doc_sub_plan_binding WHERE main_document_id = #{mainDocumentId} "
+            + "ORDER BY id ASC FOR UPDATE")
+    List<SubPlanBindingEntity> listByMainForUpdate(@Param("mainDocumentId") long mainDocumentId);
+
+    /** unbind:唯一定位一条有效挂载并锁定。 */
+    @Select("SELECT * FROM doc_sub_plan_binding WHERE main_document_id = #{mainDocumentId} "
+            + "AND sub_document_id = #{subDocumentId} AND is_deleted = 0 "
+            + "FOR UPDATE")
+    SubPlanBindingEntity findActiveForUpdate(@Param("mainDocumentId") long mainDocumentId,
+                                             @Param("subDocumentId") long subDocumentId);
+
+    /** list_sub_plans:有效挂载按 sort_no、id 升序(无锁读)。 */
+    @Select("SELECT * FROM doc_sub_plan_binding WHERE main_document_id = #{mainDocumentId} "
+            + "AND is_deleted = 0 ORDER BY sort_no ASC, id ASC")
+    List<SubPlanBindingEntity> listActiveByMain(@Param("mainDocumentId") long mainDocumentId);
+
+    /** 有效挂载数量。 */
+    @Select("SELECT COUNT(*) FROM doc_sub_plan_binding "
+            + "WHERE main_document_id = #{mainDocumentId} AND is_deleted = 0")
+    long countActiveByMain(@Param("mainDocumentId") long mainDocumentId);
+
+    /** 新建挂载(active_marker 为生成列,禁止显式插入;其余默认值由库承担)。 */
+    @Insert("INSERT INTO doc_sub_plan_binding (main_document_id, sub_document_id, "
+            + "sort_no, created_by, updated_by) VALUES (#{mainDocumentId}, "
+            + "#{subDocumentId}, #{sortNo}, #{createdBy}, #{updatedBy})")
+    int insert(SubPlanBindingEntity binding);
+
+    /** 解挂:软删 + deleted_at/updated_* + rv+1。 */
+    @Update("UPDATE doc_sub_plan_binding SET is_deleted = 1, deleted_at = #{now}, "
+            + "updated_at = #{now}, updated_by = #{updatedBy}, "
+            + "row_version = row_version + 1 WHERE id = #{id}")
+    int softDelete(@Param("id") long id, @Param("now") LocalDateTime now,
+                   @Param("updatedBy") Long updatedBy);
+}

+ 2 - 0
backend-java/src/main/java/com/dms/model/CategoryEntity.java

@@ -24,5 +24,7 @@ public class CategoryEntity {
     private String status;
     /** 扁平模式:仅一级分类有效,true 时前端隐藏子方案/配套资料挂载入口。 */
     private Boolean flatMode;
+    /** 类型显示名称:仅一级分类有效,检索结果中该分类主案类型徽标显示名。 */
+    private String typeDisplayName;
     private Integer activeMarker;
 }

+ 2 - 0
backend-java/src/main/java/com/dms/model/DocumentEntity.java

@@ -45,6 +45,8 @@ public class DocumentEntity {
     private String searchText;
     private String contentText;
     private String contentExtractStatus;
+    /** 正文提取失败的脱敏原因(异常类型+首行信息),供管理员排障。 */
+    private String contentExtractError;
     private LocalDateTime contentExtractedAt;
     private String createdByName;
     private String updatedByName;

+ 6 - 4
backend-java/src/main/java/com/dms/model/RoleDepartmentEntity.java → backend-java/src/main/java/com/dms/model/SubPlanBindingEntity.java

@@ -3,9 +3,9 @@ package com.dms.model;
 import java.time.LocalDateTime;
 import lombok.Data;
 
-/** sys_role_dept 表实体(角色可访问部门)。 */
+/** doc_sub_plan_binding 表实体(对齐 backend/dms/models/sub_plan_binding.py)。 */
 @Data
-public class RoleDepartmentEntity {
+public class SubPlanBindingEntity {
     private Long id;
     private Long createdBy;
     private Long updatedBy;
@@ -14,6 +14,8 @@ public class RoleDepartmentEntity {
     private LocalDateTime deletedAt;
     private Boolean isDeleted;
     private Integer rowVersion;
-    private Long roleId;
-    private Long organizationId;
+    private Long mainDocumentId;
+    private Long subDocumentId;
+    private Integer sortNo;
+    private Integer activeMarker;
 }

+ 3 - 0
backend-java/src/main/java/com/dms/service/AttachmentBindingService.java

@@ -66,6 +66,7 @@ public class AttachmentBindingService {
         try {
             return deadlockRetry.execute(() -> {
                 DocumentEntity main = lockedMain(mainDocumentId, context);
+                authorizationService.requirePlanAction(main, context, "BIND_ATTACHMENT");
                 DocumentPasswordService.requireDocumentPassword(main, "BIND_ATTACHMENT",
                         DocumentPasswordService.requestPassword(request), context);
                 checkVersion(main, expectedVersion);
@@ -146,6 +147,7 @@ public class AttachmentBindingService {
         AuthContext context = AuthContext.get();
         return deadlockRetry.execute(() -> {
             DocumentEntity main = lockedMain(mainDocumentId, context);
+            authorizationService.requirePlanAction(main, context, "UNBIND_ATTACHMENT");
             DocumentPasswordService.requireDocumentPassword(main, "UNBIND_ATTACHMENT",
                     DocumentPasswordService.requestPassword(request), context);
             checkVersion(main, expected);
@@ -186,6 +188,7 @@ public class AttachmentBindingService {
         AuthContext context = AuthContext.get();
         return deadlockRetry.execute(() -> {
             DocumentEntity main = lockedMain(mainDocumentId, context);
+            authorizationService.requirePlanAction(main, context, "BIND_ATTACHMENT");
             checkVersion(main, expectedVersion);
             List<AttachmentBindingEntity> bindings =
                     attachmentBindingMapper.listActiveByMainForUpdate(main.getId());

+ 23 - 4
backend-java/src/main/java/com/dms/service/AttachmentQueryService.java

@@ -31,9 +31,10 @@ import org.springframework.stereotype.Service;
 @Service
 public class AttachmentQueryService {
 
-    /** 对齐 AttachmentType 枚举全集。 */
+    /** 对齐 AttachmentType 枚举全集(阶段11新增 ACCOMPANYING_PLAN 配套方案)。 */
     public static final Set<String> ATTACHMENT_TYPES = Set.of(
-            "SUPPORT_ACHIEVEMENT", "TRAINING_MATERIAL", "TYPICAL_CASE");
+            "SUPPORT_ACHIEVEMENT", "TRAINING_MATERIAL", "TYPICAL_CASE",
+            "ACCOMPANYING_PLAN");
 
     private final AttachmentBindingMapper attachmentBindingMapper;
     private final DocumentMapper documentMapper;
@@ -112,14 +113,27 @@ public class AttachmentQueryService {
         permissionSummary.put("inheritedFromMainPlan", false);
         result.put("permissionSummary", permissionSummary);
         result.put("contentText", attachment.getContentText());
+        // 提取状态/失败原因仅供管理员排障("编辑"界面提示与重新解析)
+        if ("ADMIN".equals(context.roleCode())) {
+            result.put("contentExtractStatus", attachment.getContentExtractStatus());
+            result.put("contentExtractError", attachment.getContentExtractError());
+            result.put("contentExtractedAt", attachment.getContentExtractedAt() == null
+                    ? null : DocumentQueryService.iso(attachment.getContentExtractedAt()));
+        }
         return result;
     }
 
     // ------------------------------------------------------------ 查询入口
 
-    /** 对齐 list_attachments(ADMIN 路由)。 */
+    /**
+     * 对齐 list_attachments:列表全量可见(全局检索需要)——
+     * 普通用户同样返回全部附件标题,但 allowedActions 为空集,
+     * 且不返回详细说明与正文片段;内容访问由详情/预览/下载接口
+     * 按挂载主案权限独立拦截。
+     */
     public Map<String, Object> listAttachments(Map<String, String> params) {
         AuthContext context = AuthContext.get();
+        boolean isAdmin = "ADMIN".equals(context.roleCode());
         Page.Params page = Page.of(
                 DocumentQueryService.positiveIntParam(params, "page", 1),
                 DocumentQueryService.positiveIntParam(params, "pageSize", 20));
@@ -160,7 +174,12 @@ public class AttachmentQueryService {
         int toIndex = Math.min(page.offset() + page.pageSize(), attachments.size());
         List<Map<String, Object>> items = new ArrayList<>();
         for (DocumentEntity item : attachments.subList(fromIndex, toIndex)) {
-            items.add(attachmentSummary(item, context, null, params.get("contentKeyword")));
+            Map<String, Object> summary = attachmentSummary(item, context, null,
+                    isAdmin ? params.get("contentKeyword") : null);
+            if (!isAdmin) {
+                summary.put("description", null);
+            }
+            items.add(summary);
         }
         return Page.result(items, page, total);
     }

+ 2 - 1
backend-java/src/main/java/com/dms/service/AuthService.java

@@ -76,7 +76,8 @@ public class AuthService {
         data.put("accessToken", issued.token());
         data.put("tokenType", "Bearer");
         data.put("expiresIn", issued.expiresInSeconds());
-        data.put("user", userSummaryService.userSummary(user));
+        data.put("user", userSummaryService.userSummary(user,
+                userSummaryService.allowedModulesForUser(user)));
         return data;
     }
 

+ 158 - 20
backend-java/src/main/java/com/dms/service/AuthorizationService.java

@@ -5,7 +5,6 @@ import com.dms.mapper.AttachmentBindingMapper;
 import com.dms.mapper.DocumentMapper;
 import com.dms.mapper.OrganizationMapper;
 import com.dms.mapper.PermissionMapper;
-import com.dms.mapper.RoleDepartmentMapper;
 import com.dms.mapper.RoleMapper;
 import com.dms.mapper.RolePermissionMapper;
 import com.dms.mapper.UserMapper;
@@ -17,6 +16,7 @@ import com.dms.model.RolePermissionEntity;
 import com.dms.security.AuthContext;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -76,7 +76,6 @@ public class AuthorizationService {
     private final UserMapper userMapper;
     private final RoleMapper roleMapper;
     private final RolePermissionMapper rolePermissionMapper;
-    private final RoleDepartmentMapper roleDepartmentMapper;
 
     public AuthorizationService(DocumentMapper documentMapper,
                                 PermissionMapper permissionMapper,
@@ -84,8 +83,7 @@ public class AuthorizationService {
                                 OrganizationMapper organizationMapper,
                                 UserMapper userMapper,
                                 RoleMapper roleMapper,
-                                RolePermissionMapper rolePermissionMapper,
-                                RoleDepartmentMapper roleDepartmentMapper) {
+                                RolePermissionMapper rolePermissionMapper) {
         this.documentMapper = documentMapper;
         this.permissionMapper = permissionMapper;
         this.attachmentBindingMapper = attachmentBindingMapper;
@@ -93,7 +91,6 @@ public class AuthorizationService {
         this.userMapper = userMapper;
         this.roleMapper = roleMapper;
         this.rolePermissionMapper = rolePermissionMapper;
-        this.roleDepartmentMapper = roleDepartmentMapper;
     }
 
     // ------------------------------------------------------------ 密级前置
@@ -113,6 +110,53 @@ public class AuthorizationService {
         return SECURITY_ORDER.getOrDefault(securityLevel, 0);
     }
 
+    // ------------------------------------------------------------ 写路径集中鉴权(阶段10)
+
+    /** 对齐 ACTION_LABELS:错误消息中的操作中文名。 */
+    private static final Map<String, String> ACTION_LABELS = Map.of(
+            "VIEW", "查看",
+            "DOWNLOAD", "下载",
+            "EDIT", "编辑",
+            "DELETE", "删除",
+            "CONFIG_PERMISSION", "授权",
+            "BIND_ATTACHMENT", "挂载",
+            "UNBIND_ATTACHMENT", "解除挂载");
+
+    /**
+     * 对齐 require_plan_action:密级前置 + 文档访问 + 操作位校验,
+     * 与读路径/前端按钮同源(evaluatePlanAccess + planAllowedActions),
+     * 保证"按钮可点 ⟺ 后端放行":
+     *
+     * <ul>
+     * <li>密级不足:任何角色(含 ADMIN)一律拒绝;</li>
+     * <li>共享附件:矩阵不授予编辑/删除/授权/挂载位,仅 ADMIN 可维护;</li>
+     * <li>主案/子方案:ADMIN 恒过(密级通过后);USER 由矩阵操作位判定,
+     *     子方案自动过滤授权/挂载/解除挂载位(继承自主案)。</li>
+     * </ul>
+     */
+    public PlanAccess requirePlanAction(DocumentEntity document, AuthContext context,
+                                        String action) {
+        if (!securityAllowed(document, context)) {
+            throw Errors.securityLevelForbidden();
+        }
+        String label = ACTION_LABELS.getOrDefault(action, "执行该操作");
+        if ("ATTACHMENT".equals(document.getDocumentType())) {
+            if (!"ADMIN".equals(context.roleCode())) {
+                throw Errors.documentActionForbidden(
+                        "无权" + label + "该共享附件:仅管理员可维护共享附件");
+            }
+            return null;
+        }
+        PlanAccess access = evaluatePlanAccess(document, context);
+        if (!access.allowed()) {
+            throw Errors.documentActionForbidden("无权" + label + "该文档");
+        }
+        if (!planAllowedActions(document, context, access).contains(action)) {
+            throw Errors.documentActionForbidden("无权" + label + "该文档:角色权限未授予该操作");
+        }
+        return access;
+    }
+
     // ------------------------------------------------------------ 组织祖先
 
     /** 用户组织的有效祖先集合(含自身;链上节点失效则整体为空)。 */
@@ -159,7 +203,7 @@ public class AuthorizationService {
     }
 
     /** USER 体系生效角色:显式优先(停用不回退),无显式回退默认角色。 */
-    private RoleEntity effectiveRole(AuthContext context) {
+    public RoleEntity effectiveRole(AuthContext context) {
         Long roleId = userMapper.findRoleIdActive(context.userId());
         if (roleId != null) {
             return roleMapper.findActiveEnabledById(roleId);
@@ -167,8 +211,8 @@ public class AuthorizationService {
         return roleMapper.findDefaultActive();
     }
 
-    /** 按文档归属机构划分 OWN / ASSIGNED / OTHER。 */
-    private String deptScope(DocumentEntity source, AuthContext context, RoleEntity role) {
+    /** 按文档归属机构划分 OWN / OTHER(OTHER 涵盖所属部门子树外全部部门)。 */
+    private String deptScope(DocumentEntity source, AuthContext context) {
         if (source.getOrganizationId() == null) {
             return "OTHER";
         }
@@ -186,10 +230,6 @@ public class AuthorizationService {
                 }
             }
         }
-        if (role != null && roleDepartmentMapper.findIdByRoleAndOrganization(
-                role.getId(), source.getOrganizationId()) != null) {
-            return "ASSIGNED";
-        }
         return "OTHER";
     }
 
@@ -199,7 +239,7 @@ public class AuthorizationService {
         if (role == null) {
             return Set.of();
         }
-        String scope = deptScope(source, context, role);
+        String scope = deptScope(source, context);
         RolePermissionEntity row = rolePermissionMapper.findMatrixRow(
                 role.getId(), source.getSecurityLevel(), scope);
         if (row == null) {
@@ -320,12 +360,12 @@ public class AuthorizationService {
         if ("SUB_PLAN".equals(document.getDocumentType())) {
             return List.of("VIEW", "DOWNLOAD", "EDIT", "DELETE");
         }
-        List<String> actions = new ArrayList<>(List.of(
-                "VIEW", "DOWNLOAD", "EDIT", "CONFIG_PERMISSION", "DELETE", "BIND_ATTACHMENT"));
-        if (attachmentBindingMapper.countActiveByMain(document.getId()) > 0) {
-            actions.add("UNBIND_ATTACHMENT");
-        }
-        return actions;
+        // 阶段11方案B:ADMIN恒含UNBIND(与BIND对称)。原"存在附件挂载才有位"
+        // 是UI显影残留——附件表零挂载不渲染、子案挂载(0018)复用该位后反而出现
+        // "仅有子案挂载的主案管理员无法解挂"的倒挂,故移除条件(顺带消除
+        // 主案列表每行一次的count查询)。
+        return List.of("VIEW", "DOWNLOAD", "EDIT", "CONFIG_PERMISSION", "DELETE",
+                "BIND_ATTACHMENT", "UNBIND_ATTACHMENT");
     }
 
     /** 共享附件可用操作位(对齐 attachment_allowed_actions)。 */
@@ -345,10 +385,18 @@ public class AuthorizationService {
 
     // ------------------------------------------------------------ 附件访问
 
-    /** USER 只能通过至少一个有权有效主案访问共享附件。 */
+    /**
+     * USER 只能通过至少一个有权有效主案访问共享附件。
+     *
+     * <p>阶段11:附件允许携带任意密级(V5放宽shape约束,存量子案保留原密级),
+     * 读路径密级前置与主案/子方案同口径——任何角色(含ADMIN)密级不足即拒绝。</p>
+     */
     @Transactional(readOnly = true)
     public AttachmentAccess evaluateAttachmentAccess(DocumentEntity attachment,
                                                      AuthContext context) {
+        if (!securityAllowed(attachment, context)) {
+            return new AttachmentAccess(false, false);
+        }
         if ("ADMIN".equals(context.roleCode())) {
             return new AttachmentAccess(true, true);
         }
@@ -366,6 +414,10 @@ public class AuthorizationService {
     /** 附件访问前置(download=true 校验下载位,否则校验查看位)。 */
     public AttachmentAccess requireAttachmentAccess(DocumentEntity attachment,
                                                     AuthContext context, boolean download) {
+        // 密级不足优先按密级拒绝(前端据此提示"密级不足"而非"权限不足")
+        if (!securityAllowed(attachment, context)) {
+            throw Errors.securityLevelForbidden();
+        }
         AttachmentAccess access = evaluateAttachmentAccess(attachment, context);
         if (download && !access.canDownload()) {
             throw Errors.documentDownloadForbidden();
@@ -376,6 +428,92 @@ public class AuthorizationService {
         return access;
     }
 
+    // ------------------------------------------------------------ 管理菜单动态计算(阶段10)
+
+    /** 管理类操作位(对齐 MANAGE_MATRIX_COLUMNS):命中任一即视为有管理能力。 */
+    private static final List<String> MANAGE_MATRIX_COLUMNS = List.of(
+            "canEdit", "canDelete", "canConfigPermission",
+            "canBindAttachment", "canUnbindAttachment");
+
+    /**
+     * 对齐 _organization_subtree_ids:用户机构的下属子树(含自身),
+     * 与 deptScope 的 OWN 判定同口径(路径前缀,启用且未删)。
+     */
+    @Transactional(readOnly = true)
+    public Set<Long> organizationSubtreeIds(Long organizationId) {
+        if (organizationId == null) {
+            return Set.of();
+        }
+        Map<Long, OrganizationEntity> byId = new HashMap<>();
+        for (OrganizationEntity organization : organizationMapper.listAllNotDeleted()) {
+            if ("ENABLED".equals(organization.getStatus())) {
+                byId.put(organization.getId(), organization);
+            }
+        }
+        OrganizationEntity root = byId.get(organizationId);
+        if (root == null) {
+            return Set.of();
+        }
+        String prefix = root.getOrgPath().endsWith("/")
+                ? root.getOrgPath() : root.getOrgPath() + "/";
+        Set<Long> result = new HashSet<>();
+        for (OrganizationEntity organization : byId.values()) {
+            if (organization.getId().equals(organizationId)
+                    || (organization.getOrgPath() != null
+                    && organization.getOrgPath().startsWith(prefix))) {
+                result.add(organization.getId());
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 对齐 has_any_manage_action:用户是否对任一已发布主案具备管理类操作。
+     *
+     * <p>与 planAllowedActions 同源:ADMIN 恒真;USER 由角色权限矩阵推导——
+     * 矩阵行命中(密级×部门范围)且存在"可查看"的对应主案时管理位才生效。
+     * 用于登录/会话接口动态计算 allowedModules 中的 BACKEND_MANAGEMENT,
+     * 替代"管理员固定可见"的角色硬编码。</p>
+     */
+    @Transactional(readOnly = true)
+    public boolean hasAnyManageAction(AuthContext context) {
+        if ("ADMIN".equals(context.roleCode())) {
+            return true;
+        }
+        RoleEntity role = effectiveRole(context);
+        if (role == null) {
+            return false;
+        }
+        List<RolePermissionEntity> candidates = new ArrayList<>();
+        for (RolePermissionEntity row : rolePermissionMapper.listActiveByRole(role.getId())) {
+            for (String column : MANAGE_MATRIX_COLUMNS) {
+                if (flag(row, column)) {
+                    candidates.add(row);
+                    break;
+                }
+            }
+        }
+        if (candidates.isEmpty()) {
+            return false;
+        }
+        Set<Long> ownIds = organizationSubtreeIds(context.organizationId());
+        Set<Long> ancestorIds = activeOrganizationAncestorIds(context.organizationId());
+        for (RolePermissionEntity row : candidates) {
+            if (documentMapper.existsManageHit(row.getSecurityLevel(), row.getDeptScope(),
+                    placeholder(ownIds),
+                    ancestorPlaceholder(ancestorIds), context.userId(),
+                    Boolean.TRUE.equals(row.getCanView()))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 空集合以 -1 占位(对齐 Python in_(空集) 恒假 / not_in(空集) 恒真)。 */
+    private static List<Long> placeholder(Set<Long> ids) {
+        return ids == null || ids.isEmpty() ? List.of(-1L) : new ArrayList<>(ids);
+    }
+
     /** 密级白名单(含自身及以下,对齐 category_tree 的 allowed_security_levels)。 */
     public static List<String> allowedSecurityLevels(String securityLevel) {
         int rank = order(securityLevel);

+ 31 - 23
backend-java/src/main/java/com/dms/service/CategoryService.java

@@ -31,10 +31,12 @@ import org.springframework.transaction.annotation.Transactional;
 @Service
 public class CategoryService {
 
-    private static final Set<String> CREATE_FIELDS =
-            Set.of("categoryCode", "categoryName", "categoryType", "parentId", "sortNo", "flatMode");
+    private static final Set<String> CREATE_FIELDS = Set.of(
+            "categoryCode", "categoryName", "categoryType", "parentId", "sortNo",
+            "flatMode", "typeDisplayName");
     private static final Set<String> UPDATE_FIELDS = Set.of(
-            "categoryName", "categoryType", "parentId", "sortNo", "rowVersion", "flatMode");
+            "categoryName", "categoryType", "parentId", "sortNo", "rowVersion",
+            "flatMode", "typeDisplayName");
     private static final Set<String> REORDER_FIELDS = Set.of("parentId", "orderedIds");
 
     private static final Set<String> CATEGORY_TYPES =
@@ -91,6 +93,7 @@ public class CategoryService {
         result.put("documentCount", documentCountOverride != null
                 ? documentCountOverride : category.getDocumentCount());
         result.put("flatMode", Boolean.TRUE.equals(category.getFlatMode()));
+        result.put("typeDisplayName", category.getTypeDisplayName());
         result.put("rowVersion", category.getRowVersion());
         result.put("children", new ArrayList<Map<String, Object>>());
         return result;
@@ -142,24 +145,14 @@ public class CategoryService {
         String normalizedStatus = status == null || status.isEmpty()
                 ? "ENABLED" : Payloads.statusValue(status);
         List<CategoryEntity> categories = categoryMapper.listActiveOrdered();
-        AuthContext context = AuthContext.get();
 
-        List<String> levels =
-                AuthorizationService.allowedSecurityLevels(context.securityLevel());
+        // 阶段10同步(bad04bc 括号计数口径):本分类与全部后代分类下的所有
+        // 有效主体方案数,不按密级/状态/可见性/授权过滤——括号数字反映分类
+        // 真实挂载规模,无权限方案由列表/详情接口单独控制。
         Map<Long, Long> directMainCounts = new HashMap<>();
-        if ("USER".equals(context.roleCode())) {
-            List<Long> ancestors = AuthorizationService.ancestorPlaceholder(
-                    authorizationService.activeOrganizationAncestorIds(context.organizationId()));
-            for (Map<String, Object> row : categoryMapper.countVisibleMainForUser(
-                    levels, ancestors, context.userId())) {
-                directMainCounts.put(((Number) row.get("categoryId")).longValue(),
-                        ((Number) row.get("cnt")).longValue());
-            }
-        } else {
-            for (Map<String, Object> row : categoryMapper.countVisibleMainByLevels(levels)) {
-                directMainCounts.put(((Number) row.get("categoryId")).longValue(),
-                        ((Number) row.get("cnt")).longValue());
-            }
+        for (Map<String, Object> row : categoryMapper.countActiveMainGroupByCategory()) {
+            directMainCounts.put(((Number) row.get("categoryId")).longValue(),
+                    ((Number) row.get("cnt")).longValue());
         }
 
         Map<Long, CategoryEntity> byId = new HashMap<>();
@@ -202,11 +195,10 @@ public class CategoryService {
             includedIds = eligibleIds;
         }
 
+        // 沿父链向上累加时穿过停用分类节点,保证父分类计数 = 子树全部主案数。
         Map<Long, Long> subtreeCounts = new HashMap<>();
         for (CategoryEntity category : categories) {
-            if ("ENABLED".equals(category.getStatus())) {
-                subtreeCounts.put(category.getId(), 0L);
-            }
+            subtreeCounts.put(category.getId(), 0L);
         }
         for (Map.Entry<Long, Long> entry : directMainCounts.entrySet()) {
             Long currentId = entry.getKey();
@@ -282,6 +274,10 @@ public class CategoryService {
         boolean flatModeInput = Payloads.booleanValue(body.get("flatMode"), "flatMode");
         // flat_mode 仅一级分类有效;子分类强制 false
         boolean effectiveFlatMode = parentId == null ? flatModeInput : false;
+        // type_display_name 仅一级分类有效;子分类强制 null(与 flat_mode 口径一致)
+        String typeDisplayNameInput = Payloads.nullableText(
+                body.get("typeDisplayName"), "typeDisplayName", 128);
+        String effectiveTypeDisplayName = parentId == null ? typeDisplayNameInput : null;
         AuthContext context = AuthContext.get();
 
         CategoryEntity category;
@@ -298,8 +294,11 @@ public class CategoryService {
             category.setCategoryPath(childPath(parent, categoryName));
             category.setSortNo(sortNo);
             category.setDocumentCount(0);
+            // 对齐 Python 事务提交后按库值刷新的响应口径(server_default=0)
+            category.setRowVersion(0);
             category.setStatus("ENABLED");
             category.setFlatMode(effectiveFlatMode);
+            category.setTypeDisplayName(effectiveTypeDisplayName);
             category.setCreatedBy(context.userId());
             category.setUpdatedBy(context.userId());
             categoryMapper.insert(category);
@@ -322,6 +321,7 @@ public class CategoryService {
         detail.put("categoryPath", category.getCategoryPath());
         detail.put("sortNo", category.getSortNo());
         detail.put("flatMode", Boolean.TRUE.equals(category.getFlatMode()));
+        detail.put("typeDisplayName", category.getTypeDisplayName());
         return auditService.toJson(detail);
     }
 
@@ -337,6 +337,9 @@ public class CategoryService {
         Long parentId = Payloads.stringId(body.get("parentId"), "parentId", true);
         int sortNo = Payloads.sortNo(body.get("sortNo"));
         boolean flatModeInput = Payloads.booleanValue(body.get("flatMode"), "flatMode");
+        // type_display_name 仅一级分类有效;子分类强制 null(与 flat_mode 口径一致)
+        String typeDisplayNameInput = Payloads.nullableText(
+                body.get("typeDisplayName"), "typeDisplayName", 128);
         int expectedVersion = Payloads.rowVersion(body.get("rowVersion"));
         AuthContext context = AuthContext.get();
         LocalDateTime now = LocalDateTime.now();
@@ -386,17 +389,20 @@ public class CategoryService {
 
         // flat_mode 切换保护:仅一级分类可启用;false→true 时拒绝已存在挂载数据
         boolean newFlatMode = category.getParentId() != null ? false : flatModeInput;
+        String newTypeDisplayName = category.getParentId() != null ? null : typeDisplayNameInput;
         if (newFlatMode && !Boolean.TRUE.equals(category.getFlatMode())) {
             Set<Long> checkCategoryIds = new HashSet<>(descendantIds);
             checkCategoryIds.add(category.getId());
             List<Long> checkIds = new ArrayList<>(checkCategoryIds);
             boolean hasSubPlan = categoryMapper.existsSubPlanUnderCategories(checkIds);
             boolean hasAttachment = categoryMapper.existsBindingUnderCategories(checkIds);
-            if (hasSubPlan || hasAttachment) {
+            boolean hasSubPlanMount = categoryMapper.existsSubPlanMountUnderCategories(checkIds);
+            if (hasSubPlan || hasAttachment || hasSubPlanMount) {
                 Map<String, Object> details = new LinkedHashMap<>();
                 details.put("categoryId", String.valueOf(category.getId()));
                 details.put("hasSubPlan", hasSubPlan);
                 details.put("hasAttachment", hasAttachment);
+                details.put("hasSubPlanMount", hasSubPlanMount);
                 throw new DmsException("CATEGORY_FLAT_MODE_CONFLICT", 409,
                         "该一级分类下已存在子方案或配套资料挂载,请先解绑后再切换为扁平模式", details);
             }
@@ -411,6 +417,7 @@ public class CategoryService {
         category.setParentId(parentId);
         category.setCategoryPath(childPath(parent, categoryName));
         category.setFlatMode(newFlatMode);
+        category.setTypeDisplayName(newTypeDisplayName);
         category.setSortNo(sortNo);
         category.setUpdatedBy(context.userId());
         category.setUpdatedAt(now);
@@ -467,6 +474,7 @@ public class CategoryService {
         snapshot.put("categoryPath", category.getCategoryPath());
         snapshot.put("sortNo", category.getSortNo());
         snapshot.put("flatMode", Boolean.TRUE.equals(category.getFlatMode()));
+        snapshot.put("typeDisplayName", category.getTypeDisplayName());
         snapshot.put("rowVersion", category.getRowVersion());
         return snapshot;
     }

+ 342 - 8
backend-java/src/main/java/com/dms/service/DocumentContentService.java

@@ -8,9 +8,12 @@ import java.time.ZoneOffset;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.apache.pdfbox.Loader;
 import org.apache.pdfbox.pdmodel.PDDocument;
 import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.pdfbox.text.TextPosition;
 import org.apache.poi.xwpf.usermodel.XWPFDocument;
 import org.apache.poi.xwpf.usermodel.XWPFParagraph;
 import org.apache.poi.xwpf.usermodel.XWPFTable;
@@ -24,14 +27,48 @@ import org.springframework.stereotype.Service;
  * 不依赖AI或Office进程的PDF/DOCX正文提取与检索文本构建,
  * 对齐 backend/dms/services/document_content_service.py
  * (Python fitz/PyMuPDF → PDFBox,python-docx → POI)。
+ *
+ * <p>阶段10同步:① PDF正文按"版面块"识别段落(块边界即段落边界,块内合并
+ * 段内软换行,相邻行主字号突变亦视为段落边界);② 整页为逐行等距同字号的
+ * 退化网格(扫描件文本层典型特征)时回退纯文本启发式合并;③ 提取结果携带
+ * 脱敏失败原因(content_extract_error),供管理员"编辑"界面排障与重新解析。</p>
  */
 @Service
 public class DocumentContentService {
 
     private static final Logger logger = LoggerFactory.getLogger(DocumentContentService.class);
 
-    /** 提取结果(对齐 ContentExtraction dataclass)。 */
-    public record ContentExtraction(String text, String status, LocalDateTime extractedAt) {
+    /** 提取结果(对齐 ContentExtraction dataclass,error 缺省 null)。 */
+    public record ContentExtraction(String text, String status, LocalDateTime extractedAt,
+                                    String error) {
+
+        public ContentExtraction(String text, String status, LocalDateTime extractedAt) {
+            this(text, status, extractedAt, null);
+        }
+    }
+
+    // ------------------------------------------------------------ 提取失败原因脱敏
+
+    private static final int ERROR_MAX_LENGTH = 480;
+    /** Windows 盘符绝对路径(C:\... 或 C:/...)。 */
+    private static final Pattern WINDOWS_PATH = Pattern.compile("[A-Za-z]:[\\/][^\\s,;]*");
+    /** POSIX 根路径(前一个字符非单词字符,避免误伤 URL/比例线)。 */
+    private static final Pattern POSIX_PATH = Pattern.compile("(?<![\\w])/[A-Za-z0-9_./-]+");
+
+    /** 对齐 sanitize_extract_error:异常类型+首行信息,绝对路径替换为 &lt;path&gt;。 */
+    public static String sanitizeExtractError(Throwable ex) {
+        String raw = String.valueOf(ex.getMessage());
+        String message = "";
+        if (!raw.isBlank()) {
+            int lineBreak = raw.indexOf('\n');
+            message = lineBreak >= 0 ? raw.substring(0, lineBreak) : raw;
+        }
+        message = WINDOWS_PATH.matcher(message).replaceAll("<path>");
+        message = POSIX_PATH.matcher(message).replaceAll("<path>");
+        String text = message.isEmpty()
+                ? ex.getClass().getSimpleName()
+                : ex.getClass().getSimpleName() + ": " + message;
+        return text.length() > ERROR_MAX_LENGTH ? text.substring(0, ERROR_MAX_LENGTH) : text;
     }
 
     /** 对齐 build_search_text:非空部分以空格连接后 strip。 */
@@ -57,6 +94,8 @@ public class DocumentContentService {
         return String.join(" ", parts).strip();
     }
 
+    // ------------------------------------------------------------ DOCX 提取
+
     /** 对齐 _word_text:段落 → 表格递归单元格 → 各节页眉页脚。 */
     private static String wordText(Path path) throws IOException {
         try (XWPFDocument document = new XWPFDocument(Files.newInputStream(path))) {
@@ -96,20 +135,314 @@ public class DocumentContentService {
         }
     }
 
-    /** 对齐 _pdf_text:逐页提取文本后以换行连接。 */
+    // ------------------------------------------------------------ PDF 提取(版面块段落化)
+
+    /**
+     * 块内相邻行主字号相对差达到该比例时视为段落边界(标题与正文同块的兜底)。
+     * 注:PDFBox 的字形高度(getHeightDir)较 fitz 的字号度量压缩了相对差异
+     * (实测16pt/11pt标题正文 ≈ 0.195),故取 0.15 以对齐 Python 版 0.2 的效果。
+     */
+    private static final double FONT_SIZE_SPLIT_RATIO = 0.15;
+
+    /** 退化版面判定:逐行等距网格的间距容差(pt)与字号容差(pt)。 */
+    private static final double DEGENERATE_GAP_TOLERANCE = 2.0;
+    private static final double DEGENERATE_SIZE_TOLERANCE = 0.5;
+
+    /**
+     * 块切分阈值:行距(top-to-top)超出中位行距该倍数视为版面块边界——
+     * 段间距显著大于行间距,标题/落款因前后留白自然独立成块;
+     * 以自适应中位数为基准可兼容不同字体度量(PDFBox 高度 ≠ 字号)。
+     */
+    private static final double BLOCK_PITCH_RATIO = 1.4;
+    /** 阈值下限附加量(pt):抑制极小行距页面的比例失真。 */
+    private static final double BLOCK_PITCH_FLOOR = 2.0;
+
+    /** 句末标点:一行以此结尾视为自然段落结束,不再与下一行合并。 */
+    private static final Pattern SENTENCE_END = Pattern.compile(
+            "[。!?!?;;::…..\u201d\u2019」』))\\]}>》]+$");
+
+    /** 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章")。 */
+    private static final Pattern LIST_START = Pattern.compile(
+            "^(?:\\d{1,3}[.、))]"
+                    + "|[((][一二三四五六七八九十百\\d]{1,4}[))]"
+                    + "|[一二三四五六七八九十]{1,3}、"
+                    + "|第[一二三四五六七八九十百千\\d]{1,5}[章节条部分篇])");
+
+    /** 版面行:(文本, 主字号, bbox顶, bbox底)。 */
+    private record LineItem(String text, double mainSize, double top, double bottom) {
+    }
+
+    /** 版面块:(行列表, bbox顶, bbox底)。 */
+    private record TextBlock(List<LineItem> items, double top, double bottom) {
+    }
+
+    /**
+     * 逐字符收集版面行(对齐 fitz page.get_text("dict") 的 lines 结构):
+     * PDFTextStripper 按 sortByPosition 顺序回调 writeString,
+     * writeLineSeparator 处收行;行主字号取行内非空白字符的最大高度。
+     */
+    private static final class LineCollector extends PDFTextStripper {
+
+        private final List<LineItem> lines = new ArrayList<>();
+        private final StringBuilder current = new StringBuilder();
+        private final List<Double> sizes = new ArrayList<>();
+        private double top = Double.MAX_VALUE;
+        private double bottom = -Double.MAX_VALUE;
+
+        LineCollector() throws IOException {
+            setSortByPosition(true);
+        }
+
+        @Override
+        protected void writeString(String text, List<TextPosition> positions) {
+            for (TextPosition position : positions) {
+                String unicode = position.getUnicode();
+                if (unicode == null) {
+                    continue;
+                }
+                current.append(unicode);
+                if (!unicode.isBlank()) {
+                    sizes.add((double) position.getHeightDir());
+                }
+                double y = position.getYDirAdj();
+                double height = position.getHeightDir();
+                top = Math.min(top, y);
+                bottom = Math.max(bottom, y + height);
+            }
+        }
+
+        @Override
+        protected void writeLineSeparator() {
+            closeLine();
+        }
+
+        @Override
+        public String getText(PDDocument document) throws IOException {
+            String result = super.getText(document);
+            closeLine();
+            return result;
+        }
+
+        private void closeLine() {
+            String text = current.toString().strip();
+            if (!text.isEmpty()) {
+                double mainSize = 0.0;
+                for (double size : sizes) {
+                    mainSize = Math.max(mainSize, size);
+                }
+                lines.add(new LineItem(text, mainSize, top, bottom));
+            }
+            current.setLength(0);
+            sizes.clear();
+            top = Double.MAX_VALUE;
+            bottom = -Double.MAX_VALUE;
+        }
+
+        List<LineItem> drainLines() {
+            List<LineItem> drained = new ArrayList<>(lines);
+            lines.clear();
+            return drained;
+        }
+    }
+
+    /** 对齐 _pdf_page_text:按版面块识别段落;退化网格回退纯文本启发式。 */
+    private static String pdfPageText(PDDocument document, int pageNumber)
+            throws IOException {
+        LineCollector collector = new LineCollector();
+        collector.setStartPage(pageNumber);
+        collector.setEndPage(pageNumber);
+        collector.getText(document);
+        List<LineItem> lines = collector.drainLines();
+        if (lines.isEmpty()) {
+            return "";
+        }
+        List<TextBlock> blocks = groupBlocks(lines);
+        if (isDegenerateLineGrid(blocks, lines.size())) {
+            PDFTextStripper plain = new PDFTextStripper();
+            plain.setStartPage(pageNumber);
+            plain.setEndPage(pageNumber);
+            return mergeWrappedLines(plain.getText(document));
+        }
+        List<String> paragraphs = new ArrayList<>();
+        for (TextBlock block : blocks) {
+            paragraphs.addAll(mergeLineItems(block.items()));
+        }
+        return String.join("\n", paragraphs);
+    }
+
+    /**
+     * 版面块分组(近似 fitz 按邻近度聚块):行距超过"中位行距×
+     * BLOCK_PITCH_RATIO(且至少多 BLOCK_PITCH_FLOOR pt)"时视为块边界。
+     * 行数不足(无中位样本)时不切分,整页视为一个块,
+     * 由 mergeLineItems 的字号/标点规则兜底分段。
+     */
+    private static List<TextBlock> groupBlocks(List<LineItem> lines) {
+        List<Double> pitches = new ArrayList<>();
+        for (int index = 0; index + 1 < lines.size(); index++) {
+            pitches.add(lines.get(index + 1).top() - lines.get(index).top());
+        }
+        double threshold = Double.MAX_VALUE;
+        if (pitches.size() >= 2) {
+            List<Double> sorted = new ArrayList<>(pitches);
+            java.util.Collections.sort(sorted);
+            double median = sorted.get(sorted.size() / 2);
+            threshold = Math.max(median * BLOCK_PITCH_RATIO,
+                    median + BLOCK_PITCH_FLOOR);
+        }
+        List<TextBlock> blocks = new ArrayList<>();
+        List<LineItem> current = new ArrayList<>();
+        for (int index = 0; index < lines.size(); index++) {
+            LineItem line = lines.get(index);
+            if (!current.isEmpty() && index > 0) {
+                double pitch = line.top() - lines.get(index - 1).top();
+                if (pitch > threshold) {
+                    blocks.add(closeBlock(current));
+                    current = new ArrayList<>();
+                }
+            }
+            current.add(line);
+        }
+        if (!current.isEmpty()) {
+            blocks.add(closeBlock(current));
+        }
+        return blocks;
+    }
+
+    private static TextBlock closeBlock(List<LineItem> items) {
+        double top = Double.MAX_VALUE;
+        double bottom = -Double.MAX_VALUE;
+        for (LineItem item : items) {
+            top = Math.min(top, item.top());
+            bottom = Math.max(bottom, item.bottom());
+        }
+        return new TextBlock(List.copyOf(items), top, bottom);
+    }
+
+    /**
+     * 对齐 _is_degenerate_line_grid:整页"每行一块、等距同字号"的退化网格
+     * (扫描件文本层逐行定位的典型特征)时块结构无分组价值。
+     */
+    private static boolean isDegenerateLineGrid(List<TextBlock> blocks, int lineCount) {
+        if (blocks.size() < 3 || blocks.size() < lineCount) {
+            return false;
+        }
+        for (TextBlock block : blocks) {
+            if (block.items().size() != 1) {
+                return false;
+            }
+        }
+        double minGap = Double.MAX_VALUE;
+        double maxGap = -Double.MAX_VALUE;
+        for (int index = 0; index + 1 < blocks.size(); index++) {
+            double gap = blocks.get(index + 1).top() - blocks.get(index).top();
+            minGap = Math.min(minGap, gap);
+            maxGap = Math.max(maxGap, gap);
+        }
+        if (maxGap - minGap > DEGENERATE_GAP_TOLERANCE) {
+            return false;
+        }
+        double minSize = Double.MAX_VALUE;
+        double maxSize = -Double.MAX_VALUE;
+        for (TextBlock block : blocks) {
+            double size = block.items().get(0).mainSize();
+            minSize = Math.min(minSize, size);
+            maxSize = Math.max(maxSize, size);
+        }
+        return maxSize - minSize <= DEGENERATE_SIZE_TOLERANCE;
+    }
+
+    /** 对齐 _merge_line_items:条目编号/字号突变/句末标点切分,其余按软换行合并。 */
+    private static List<String> mergeLineItems(List<LineItem> items) {
+        List<String> paragraphs = new ArrayList<>();
+        StringBuilder buffer = new StringBuilder();
+        double previousSize = 0.0;
+        for (LineItem item : items) {
+            String text = item.text();
+            double size = item.mainSize();
+            boolean split = false;
+            if (!buffer.isEmpty() && LIST_START.matcher(text).find()) {
+                split = true;
+            } else if (!buffer.isEmpty() && previousSize > 0 && size > 0
+                    && Math.abs(size - previousSize) / Math.max(size, previousSize)
+                    >= FONT_SIZE_SPLIT_RATIO) {
+                split = true;
+            }
+            if (buffer.isEmpty()) {
+                buffer.append(text);
+            } else if (split) {
+                paragraphs.add(buffer.toString());
+                buffer.setLength(0);
+                buffer.append(text);
+            } else {
+                joinLines(buffer, text);
+            }
+            previousSize = size;
+            Matcher endMatcher = SENTENCE_END.matcher(buffer);
+            if (!buffer.isEmpty() && endMatcher.find()) {
+                paragraphs.add(buffer.toString());
+                buffer.setLength(0);
+            }
+        }
+        if (!buffer.isEmpty()) {
+            paragraphs.add(buffer.toString());
+        }
+        return paragraphs;
+    }
+
+    /** 对齐 _join_lines:两侧均为 ASCII 字母数字时补空格,其余直接相连。 */
+    private static void joinLines(StringBuilder previous, String following) {
+        if (previous.isEmpty() || following.isEmpty()) {
+            previous.append(following);
+            return;
+        }
+        char left = previous.charAt(previous.length() - 1);
+        char right = following.charAt(0);
+        if (left < 128 && Character.isLetterOrDigit(left)
+                && right < 128 && Character.isLetterOrDigit(right)) {
+            previous.append(' ').append(following);
+            return;
+        }
+        previous.append(following);
+    }
+
+    /** 对齐 _merge_wrapped_lines:纯文本兜底,空行/句末标点/条目编号视为段落边界。 */
+    private static String mergeWrappedLines(String pageText) {
+        List<String> paragraphs = new ArrayList<>();
+        List<LineItem> group = new ArrayList<>();
+        for (String rawLine : pageText.split("\n")) {
+            String line = rawLine.strip();
+            if (line.isEmpty()) {
+                paragraphs.addAll(mergeLineItems(group));
+                group = new ArrayList<>();
+            } else {
+                group.add(new LineItem(line, 0.0, 0.0, 0.0));
+            }
+        }
+        paragraphs.addAll(mergeLineItems(group));
+        return String.join("\n", paragraphs);
+    }
+
+    /** 对齐 _pdf_text:逐页版面段落化,页与页之间以空行分隔(页边界即段落边界)。 */
     private static String pdfText(Path path) throws IOException {
         try (PDDocument document = Loader.loadPDF(Files.readAllBytes(path))) {
-            return new PDFTextStripper().getText(document).strip();
+            List<String> pages = new ArrayList<>();
+            for (int index = 1; index <= document.getNumberOfPages(); index++) {
+                pages.add(pdfPageText(document, index));
+            }
+            List<String> nonEmpty = pages.stream().filter(page -> !page.isEmpty()).toList();
+            return String.join("\n\n", nonEmpty).strip();
         }
     }
 
-    /** 对齐 extract_document_content:仅 pdf/docx,异常 FAILED、空文本 EMPTY。 */
+    // ------------------------------------------------------------ 提取入口
+
+    /** 对齐 extract_document_content:仅 pdf/docx,异常 FAILED(含脱敏原因)。 */
     public ContentExtraction extractDocumentContent(Path path, String extension) {
         String normalized = extension == null
                 ? ""
                 : extension.toLowerCase(Locale.ROOT).replaceFirst("^\\.+", "");
         if (!"pdf".equals(normalized) && !"docx".equals(normalized)) {
-            return new ContentExtraction(null, "UNSUPPORTED", null);
+            return new ContentExtraction(null, "UNSUPPORTED", null, null);
         }
         LocalDateTime extractedAt = LocalDateTime.now(ZoneOffset.UTC);
         String text;
@@ -117,12 +450,13 @@ public class DocumentContentService {
             text = "pdf".equals(normalized) ? pdfText(path) : wordText(path);
         } catch (Exception ex) {
             logger.error("文档正文提取失败:extension={}", normalized, ex);
-            return new ContentExtraction(null, "FAILED", extractedAt);
+            return new ContentExtraction(null, "FAILED", extractedAt, sanitizeExtractError(ex));
         }
         boolean hasText = text != null && !text.isEmpty();
         return new ContentExtraction(
                 hasText ? text : null,
                 hasText ? "SUCCESS" : "EMPTY",
-                extractedAt);
+                extractedAt,
+                null);
     }
 }

+ 115 - 38
backend-java/src/main/java/com/dms/service/DocumentMutationService.java

@@ -175,11 +175,17 @@ public class DocumentMutationService {
             visibility = "ALL_AUTHENTICATED";
         } else {
             Object rawType = body.get("documentType");
+            // 阶段11:配套方案并入共享资料库(0020),停止新建SUB_PLAN文档;
+            // 历史子案已迁移为ACCOMPANYING_PLAN附件,编辑/挂载走附件链路。
+            if ("SUB_PLAN".equals(rawType)) {
+                throw Errors.invalidArgument(
+                        "配套方案已并入共享资料库,请以附件类型ACCOMPANYING_PLAN上传");
+            }
             if (!(rawType instanceof String typeText) || !DOCUMENT_TYPES.contains(typeText)) {
-                throw Errors.invalidArgument("documentType只允许MAIN或SUB_PLAN");
+                throw Errors.invalidArgument("documentType必须是MAIN");
             }
-            if (!"MAIN".equals(typeText) && !"SUB_PLAN".equals(typeText)) {
-                throw Errors.invalidArgument("documentType只允许MAIN或SUB_PLAN");
+            if (!"MAIN".equals(typeText)) {
+                throw Errors.invalidArgument("documentType必须是MAIN");
             }
             docType = typeText;
             attType = null;
@@ -213,41 +219,10 @@ public class DocumentMutationService {
         final String summary = text(body.get("summary"), "summary", 20000, true);
         final List<String> tags = tagsValue(body.get("tags"));
 
-        // 正文提取:doc/wps/xls/xlsx/et 走转换器(不可用/异常 → FAILED),其余按扩展名直提。
-        final DocumentContentService.ContentExtraction extraction;
-        if ("doc".equals(upload.extension()) || "wps".equals(upload.extension())
-                || "xls".equals(upload.extension()) || "xlsx".equals(upload.extension())
-                || "et".equals(upload.extension())) {
-            Path tempPdf = null;
-            DocumentContentService.ContentExtraction result;
-            try {
-                DocumentToPdfConverter converter = converterProvider.getIfAvailable();
-                if (converter == null || !converter.isAvailable()) {
-                    result = new DocumentContentService.ContentExtraction(null, "FAILED", now());
-                } else {
-                    byte[] pdfBytes =
-                            converter.convertToPdf(upload.temporaryPath(), upload.fileHash());
-                    tempPdf = Files.createTempFile("dms-convert-", ".pdf");
-                    Files.write(tempPdf, pdfBytes);
-                    result = documentContentService.extractDocumentContent(tempPdf, "pdf");
-                }
-            } catch (Exception ex) {
-                logger.error("DOC/WPS/XLS/XLSX/ET转换PDF提取正文失败", ex);
-                result = new DocumentContentService.ContentExtraction(null, "FAILED", now());
-            } finally {
-                if (tempPdf != null) {
-                    try {
-                        Files.deleteIfExists(tempPdf);
-                    } catch (IOException ignored) {
-                        // 对齐 unlink(missing_ok=True) 容忍语义
-                    }
-                }
-            }
-            extraction = result;
-        } else {
-            extraction = documentContentService.extractDocumentContent(
-                    upload.temporaryPath(), upload.extension());
-        }
+        // 正文提取:doc/wps/xls/xlsx/et 走转换器(不可用/异常 → FAILED+脱敏原因),
+        // 其余按扩展名直提;与重新解析(reExtractContent)共用同一口径。
+        final DocumentContentService.ContentExtraction extraction = extractFileContent(
+                upload.temporaryPath(), upload.extension(), upload.fileHash());
 
         final boolean[] finalCreated = {false};
         final Path[] oldFilePath = {null};
@@ -321,6 +296,7 @@ public class DocumentMutationService {
                     duplicate.setFileHash(upload.fileHash());
                     duplicate.setContentText(extraction.text());
                     duplicate.setContentExtractStatus(extraction.status());
+                    duplicate.setContentExtractError(extraction.error());
                     duplicate.setContentExtractedAt(extraction.extractedAt());
                     duplicate.setSearchText(DocumentContentService.buildSearchText(
                             name, summary, tags, extraction.text()));
@@ -380,6 +356,7 @@ public class DocumentMutationService {
                     created.setSortNo(nextSortNo);
                     created.setContentText(extraction.text());
                     created.setContentExtractStatus(extraction.status());
+                    created.setContentExtractError(extraction.error());
                     created.setContentExtractedAt(extraction.extractedAt());
                     created.setSearchText(DocumentContentService.buildSearchText(
                             name, summary, tags, extraction.text()));
@@ -450,6 +427,97 @@ public class DocumentMutationService {
         }
     }
 
+    // ------------------------------------------------------------ 重新解析(阶段10)
+
+    /**
+     * 对齐 re_extract_content:管理员对存量文档重新提取正文,
+     * 按当前存储文件重新解析并覆盖写回提取结果(含失败原因),
+     * search_text 同步重建,审计复用 EDIT_DOCUMENT(scope=reExtractContent)。
+     * 主案/子案/共享附件通用。
+     */
+    public Map<String, Object> reExtractContent(long documentId, HttpServletRequest request) {
+        AuthContext context = AuthContext.get();
+        DocumentEntity document = transactionTemplate.execute(status -> {
+            DocumentEntity locked = documentMapper.findNotDeletedForUpdate(documentId);
+            if (locked == null) {
+                throw notFound("文档不存在");
+            }
+            Path filePath;
+            try {
+                filePath = storagePaths.resolveStoragePath(locked.getFileRelativePath());
+            } catch (StoragePaths.UnsafeStoragePathException ex) {
+                throw Errors.filePathInvalid();
+            }
+            if (!Files.isRegularFile(filePath)) {
+                throw Errors.fileNotFound();
+            }
+            DocumentContentService.ContentExtraction extraction = extractFileContent(
+                    filePath, locked.getFileExtension(), locked.getFileHash());
+            locked.setContentText(extraction.text());
+            locked.setContentExtractStatus(extraction.status());
+            locked.setContentExtractError(extraction.error());
+            locked.setContentExtractedAt(extraction.extractedAt());
+            locked.setSearchText(DocumentContentService.buildSearchText(
+                    locked.getDocumentName(), locked.getSummary(),
+                    documentQueryService.tagsOf(locked.getTags()), extraction.text()));
+            locked.setUpdatedBy(context.userId());
+            locked.setUpdatedByName(context.realName());
+            locked.setUpdatedAt(now());
+            locked.setRowVersion(locked.getRowVersion() + 1);
+            documentMapper.updateContentExtraction(locked);
+            Map<String, Object> detail = Payloads.orderedMap();
+            detail.put("scope", "reExtractContent");
+            detail.put("contentExtractStatus", extraction.status());
+            detail.put("rowVersion", locked.getRowVersion());
+            String targetType = "ATTACHMENT".equals(locked.getDocumentType())
+                    ? "ATTACHMENT" : "DOCUMENT";
+            auditService.insert(auditService.businessAudit(request,
+                    AuditService.Action.EDIT_DOCUMENT, targetType,
+                    locked.getId(), locked.getDocumentName(), auditService.toJson(detail)));
+            return locked;
+        });
+        return serialize(document, request);
+    }
+
+    /**
+     * 对齐 _extract_file_content:上传与重新解析共用的提取口径。
+     * doc/wps/xls/xlsx/et 经 LibreOffice 转 PDF 后提取(转换器不可用或异常
+     * 时 FAILED 并携带脱敏原因),其余按扩展名直提。
+     */
+    private DocumentContentService.ContentExtraction extractFileContent(
+            Path filePath, String extension, String fileHash) {
+        if (!"doc".equals(extension) && !"wps".equals(extension)
+                && !"xls".equals(extension) && !"xlsx".equals(extension)
+                && !"et".equals(extension)) {
+            return documentContentService.extractDocumentContent(filePath, extension);
+        }
+        Path tempPdf = null;
+        try {
+            DocumentToPdfConverter converter = converterProvider.getIfAvailable();
+            if (converter == null || !converter.isAvailable()) {
+                return new DocumentContentService.ContentExtraction(
+                        null, "FAILED", now(), "LibreOffice转换器不可用,无法解析Office文档");
+            }
+            byte[] pdfBytes = converter.convertToPdf(filePath, fileHash);
+            tempPdf = Files.createTempFile("dms-convert-", ".pdf");
+            Files.write(tempPdf, pdfBytes);
+            return documentContentService.extractDocumentContent(tempPdf, "pdf");
+        } catch (Exception ex) {
+            logger.error("DOC/WPS/XLS/XLSX/ET转换PDF提取正文失败", ex);
+            return new DocumentContentService.ContentExtraction(
+                    null, "FAILED", now(),
+                    DocumentContentService.sanitizeExtractError(ex));
+        } finally {
+            if (tempPdf != null) {
+                try {
+                    Files.deleteIfExists(tempPdf);
+                } catch (IOException ignored) {
+                    // 对齐 unlink(missing_ok=True) 容忍语义
+                }
+            }
+        }
+    }
+
     // ------------------------------------------------------------ 拖动重排
 
     /** 对齐 reorder_documents:DeadlockRetry 包裹,槽位置换后按步长10重编号。 */
@@ -469,6 +537,10 @@ public class DocumentMutationService {
                     throw notFound("待排序方案不存在或已删除");
                 }
             }
+            // 重排=改排序字段:对每个提交文档要求EDIT(密级+矩阵/ACL同源校验)
+            for (DocumentEntity document : submitted) {
+                authorizationService.requirePlanAction(document, context, "EDIT");
+            }
             DocumentEntity first = byId.get(orderedIds.get(0));
             String documentType = first.getDocumentType();
             Long parentId;
@@ -547,6 +619,7 @@ public class DocumentMutationService {
             if (locked == null) {
                 throw notFound("方案文档不存在或类型不匹配");
             }
+            authorizationService.requirePlanAction(locked, context, "EDIT");
             DocumentPasswordService.requireDocumentPassword(locked, "EDIT",
                     DocumentPasswordService.requestPassword(request), context);
             boolean isMain = "MAIN".equals(locked.getDocumentType());
@@ -632,6 +705,7 @@ public class DocumentMutationService {
             if (locked == null) {
                 throw notFound("文档不存在或已删除");
             }
+            authorizationService.requirePlanAction(locked, context, "EDIT");
             DocumentPasswordService.requireDocumentPassword(locked, "EDIT",
                     DocumentPasswordService.requestPassword(request), context);
             Map<String, Object> before = Payloads.orderedMap();
@@ -666,6 +740,7 @@ public class DocumentMutationService {
             if (locked == null) {
                 throw notFound("共享附件不存在或类型不匹配");
             }
+            authorizationService.requirePlanAction(locked, context, "EDIT");
             exactFields(payload, ATTACHMENT_EDIT_FIELDS);
             checkVersion(locked, Payloads.rowVersion(payload.get("rowVersion")));
             Org organization = resolveOrganization(organizationIdInput, context,
@@ -710,6 +785,7 @@ public class DocumentMutationService {
             if (document == null) {
                 throw notFound("方案文档不存在或类型不匹配");
             }
+            authorizationService.requirePlanAction(document, context, "DELETE");
             DocumentPasswordService.requireDocumentPassword(document, "DELETE",
                     DocumentPasswordService.requestPassword(request), context);
             checkVersion(document, rowVersion);
@@ -761,6 +837,7 @@ public class DocumentMutationService {
             if (document == null) {
                 throw notFound("共享附件不存在或类型不匹配");
             }
+            authorizationService.requirePlanAction(document, context, "DELETE");
             checkVersion(document, rowVersion);
             List<DocumentEntity> mains =
                     attachmentBindingMapper.listActiveMainsByAttachment(document.getId());

+ 6 - 1
backend-java/src/main/java/com/dms/service/DocumentPasswordService.java

@@ -44,10 +44,13 @@ public class DocumentPasswordService {
 
     private final DocumentMapper documentMapper;
     private final AuditService auditService;
+    private final AuthorizationService authorizationService;
 
-    public DocumentPasswordService(DocumentMapper documentMapper, AuditService auditService) {
+    public DocumentPasswordService(DocumentMapper documentMapper, AuditService auditService,
+                                   AuthorizationService authorizationService) {
         this.documentMapper = documentMapper;
         this.auditService = auditService;
+        this.authorizationService = authorizationService;
     }
 
     // ------------------------------------------------------------ 校验与判定
@@ -253,6 +256,8 @@ public class DocumentPasswordService {
         if (document == null) {
             throw new DmsException("RESOURCE_NOT_FOUND", 404, "主案不存在或不可设置密码锁", null);
         }
+        // 密码锁是文档级安全配置:密级+授权位(CONFIG_PERMISSION)前置校验
+        authorizationService.requirePlanAction(document, context, "CONFIG_PERMISSION");
         if (document.getRowVersion() != rowVersion) {
             Map<String, Object> details = new LinkedHashMap<>();
             details.put("currentRowVersion", document.getRowVersion());

+ 75 - 16
backend-java/src/main/java/com/dms/service/DocumentQueryService.java

@@ -9,9 +9,11 @@ import com.dms.mapper.AttachmentBindingMapper;
 import com.dms.mapper.CategoryMapper;
 import com.dms.mapper.DocumentMapper;
 import com.dms.mapper.PermissionMapper;
+import com.dms.mapper.SubPlanBindingMapper;
 import com.dms.model.CategoryEntity;
 import com.dms.model.DocumentEntity;
 import com.dms.model.PermissionEntity;
+import com.dms.model.SubPlanBindingEntity;
 import com.dms.security.AuthContext;
 import com.dms.service.AuthorizationService.PlanAccess;
 import com.fasterxml.jackson.databind.JsonNode;
@@ -82,6 +84,7 @@ public class DocumentQueryService {
     private final PermissionMapper permissionMapper;
     private final AttachmentBindingMapper attachmentBindingMapper;
     private final CategoryMapper categoryMapper;
+    private final SubPlanBindingMapper subPlanBindingMapper;
     private final AuthorizationService authorizationService;
     private final AuditService auditService;
     private final ObjectMapper objectMapper;
@@ -91,6 +94,7 @@ public class DocumentQueryService {
                                 PermissionMapper permissionMapper,
                                 AttachmentBindingMapper attachmentBindingMapper,
                                 CategoryMapper categoryMapper,
+                                SubPlanBindingMapper subPlanBindingMapper,
                                 AuthorizationService authorizationService,
                                 AuditService auditService,
                                 ObjectMapper objectMapper,
@@ -99,6 +103,7 @@ public class DocumentQueryService {
         this.permissionMapper = permissionMapper;
         this.attachmentBindingMapper = attachmentBindingMapper;
         this.categoryMapper = categoryMapper;
+        this.subPlanBindingMapper = subPlanBindingMapper;
         this.authorizationService = authorizationService;
         this.auditService = auditService;
         this.objectMapper = objectMapper;
@@ -371,6 +376,13 @@ public class DocumentQueryService {
         result.put("permissionSummary", view.summary());
         result.put("viewPasswordVerified", verified);
         result.put("contentText", verified ? document.getContentText() : null);
+        // 提取状态/失败原因仅供管理员排障("编辑"界面提示与重新解析)
+        if ("ADMIN".equals(context.roleCode())) {
+            result.put("contentExtractStatus", document.getContentExtractStatus());
+            result.put("contentExtractError", document.getContentExtractError());
+            result.put("contentExtractedAt", document.getContentExtractedAt() == null
+                    ? null : iso(document.getContentExtractedAt()));
+        }
         return result;
     }
 
@@ -500,12 +512,75 @@ public class DocumentQueryService {
         if (main == null) {
             throw Errors.mainPlanNotFound();
         }
+        // 主案无权限时也返回子案列表(列表全量可见,与主案列表语义一致);
+        // 子案权限继承自原主案(root_document_id 不变),allowedActions 自然为
+        // 空集,内容由预览/详情接口拦截。
         authorizationService.evaluatePlanAccess(main, context);
         Page.Params page = Page.of(positiveIntParam(params, "page", 1),
                 positiveIntParam(params, "pageSize", 20));
         Map<String, Object> filters = Payloads.orderedMap();
         filters.put("types", List.of("SUB_PLAN"));
         filters.put("parentDocumentId", main.getId());
+        applySubPlanFilters(filters, params);
+
+        // 自有子案:parent_document_id 指向该主案,按请求排序(默认 sortNo)。
+        Map<String, Object> ordered = new LinkedHashMap<>(filters);
+        ordered.put("orderBy", orderByParam(params));
+        List<DocAccess> listed = new ArrayList<>();
+        for (DocumentEntity child : documentMapper.listByFilters(ordered)) {
+            listed.add(new DocAccess(child,
+                    authorizationService.evaluatePlanAccess(child, context)));
+        }
+        // 挂载子案:其他主案的子案经 doc_sub_plan_binding 建立展示关系,
+        // 按挂载顺序(binding.sort_no)固定追加在自有子案之后,不参与拖动排序。
+        List<SubPlanBindingEntity> bindings =
+                subPlanBindingMapper.listActiveByMain(main.getId());
+        List<Long> mountedIds = new ArrayList<>();
+        for (SubPlanBindingEntity binding : bindings) {
+            mountedIds.add(binding.getSubDocumentId());
+        }
+        if (!mountedIds.isEmpty()) {
+            // 挂载子案过滤:去掉 parent_document_id 限制,仅按 ids+公共过滤条件
+            Map<String, Object> mountedFilters = new LinkedHashMap<>(filters);
+            mountedFilters.remove("parentDocumentId");
+            mountedFilters.put("ids", mountedIds);
+            Map<Long, DocumentEntity> byId = new HashMap<>();
+            for (DocumentEntity document : documentMapper.listByFilters(mountedFilters)) {
+                byId.put(document.getId(), document);
+            }
+            for (SubPlanBindingEntity binding : bindings) {
+                DocumentEntity mounted = byId.get(binding.getSubDocumentId());
+                if (mounted == null) {
+                    continue;  // 挂载子案已被删或未命中过滤条件
+                }
+                listed.add(new DocAccess(mounted,
+                        authorizationService.evaluatePlanAccess(mounted, context)));
+            }
+        }
+        long total = listed.size();
+        int fromIndex = Math.min(page.offset(), listed.size());
+        int toIndex = Math.min(page.offset() + page.pageSize(), listed.size());
+        // mountId 标记:挂载关系ID(自有子案不在挂载表内,自然无 mountId)
+        Map<Long, Long> mountIdBySubId = new HashMap<>();
+        for (SubPlanBindingEntity binding : bindings) {
+            mountIdBySubId.put(binding.getSubDocumentId(), binding.getId());
+        }
+        List<Map<String, Object>> items = new ArrayList<>();
+        for (DocAccess item : listed.subList(fromIndex, toIndex)) {
+            Map<String, Object> summary = documentSummary(item.document(), context, item.access(),
+                    item.access().allowed() ? params.get("contentKeyword") : null);
+            Long mountId = mountIdBySubId.get(item.document().getId());
+            if (mountId != null) {
+                summary.put("mountId", serializeId(mountId));
+            }
+            items.add(summary);
+        }
+        return Page.result(items, page, total);
+    }
+
+    /** 子案列表公共过滤(keyword/高级检索/status/更新时间范围)。 */
+    private static void applySubPlanFilters(Map<String, Object> filters,
+                                            Map<String, String> params) {
         String keyword = params.get("keyword");
         if (keyword != null && !keyword.strip().isEmpty()) {
             filters.put("titlePattern", "%" + escapeLike(keyword) + "%");
@@ -522,22 +597,6 @@ public class DocumentQueryService {
         if (range.to() != null) {
             filters.put("updatedTo", range.to());
         }
-        filters.put("orderBy", orderByParam(params));
-
-        List<DocAccess> listed = new ArrayList<>();
-        for (DocumentEntity child : documentMapper.listByFilters(filters)) {
-            listed.add(new DocAccess(child,
-                    authorizationService.evaluatePlanAccess(child, context)));
-        }
-        long total = listed.size();
-        int fromIndex = Math.min(page.offset(), listed.size());
-        int toIndex = Math.min(page.offset() + page.pageSize(), listed.size());
-        List<Map<String, Object>> items = new ArrayList<>();
-        for (DocAccess item : listed.subList(fromIndex, toIndex)) {
-            items.add(documentSummary(item.document(), context, item.access(),
-                    item.access().allowed() ? params.get("contentKeyword") : null));
-        }
-        return Page.result(items, page, total);
     }
 
     /** 对齐 list_main_plan_tags:聚合当前用户可见主案标签(Unicode 升序)。 */

+ 1 - 0
backend-java/src/main/java/com/dms/service/DocumentRelationService.java

@@ -199,6 +199,7 @@ public class DocumentRelationService {
                 if (document == null) {
                     throw new DmsException("RESOURCE_NOT_FOUND", 404, "方案文档不存在", null);
                 }
+                authorizationService.requirePlanAction(document, context, "EDIT");
                 authorizationService.requireSecurityClearance(document, context);
                 DocumentPasswordService.requireDocumentPassword(document, "EDIT",
                         DocumentPasswordService.requestPassword(request), context);

+ 9 - 1
backend-java/src/main/java/com/dms/service/OrganizationService.java

@@ -32,7 +32,7 @@ public class OrganizationService {
     private static final Set<String> CREATE_FIELDS = Set.of(
             "orgCode", "orgName", "parentId", "sortNo", "status");
     private static final Set<String> UPDATE_FIELDS = Set.of(
-            "orgName", "parentId", "sortNo", "status", "rowVersion");
+            "orgCode", "orgName", "parentId", "sortNo", "status", "rowVersion");
 
     private final OrganizationMapper organizationMapper;
     private final DocumentMapper documentMapper;
@@ -163,6 +163,7 @@ public class OrganizationService {
     public Map<String, Object> update(long organizationId, Object payload,
                                       HttpServletRequest request) {
         Map<String, Object> body = Payloads.exactPayload(payload, UPDATE_FIELDS);
+        String orgCode = Payloads.requiredText(body.get("orgCode"), "orgCode", 64);
         String orgName = Payloads.requiredText(body.get("orgName"), "orgName", 128);
         Long parentId = Payloads.stringId(body.get("parentId"), "parentId", true);
         int sortNo = Payloads.sortNo(body.get("sortNo"));
@@ -203,10 +204,16 @@ public class OrganizationService {
             }
         }
 
+        // 机构编码可编辑:保存时查重(排除自身,口径与创建一致——仅比对未删除机构)
+        Long codeOwner = organizationMapper.findIdByOrgCodeExclude(orgCode, organizationId);
+        if (codeOwner != null) {
+            throw new DmsException("DATA_VERSION_CONFLICT", 409, "机构编码已存在", null);
+        }
         Map<String, Object> before = organizationSnapshot(organization);
         boolean pathChanged = !orgName.equals(organization.getOrgName())
                 || !Objects.equals(parentId, organization.getParentId());
 
+        organization.setOrgCode(orgCode);
         organization.setOrgName(orgName);
         organization.setParentId(parentId);
         organization.setOrgPath(buildPath(parent == null ? null : parent.getOrgPath(), orgName));
@@ -393,6 +400,7 @@ public class OrganizationService {
     /** update 审计快照(before/after 同构)。 */
     private static Map<String, Object> organizationSnapshot(OrganizationEntity organization) {
         Map<String, Object> snapshot = Payloads.orderedMap();
+        snapshot.put("orgCode", organization.getOrgCode());
         snapshot.put("orgName", organization.getOrgName());
         snapshot.put("parentId", serializeId(organization.getParentId()));
         snapshot.put("orgPath", organization.getOrgPath());

+ 8 - 1
backend-java/src/main/java/com/dms/service/PermissionService.java

@@ -117,6 +117,11 @@ public class PermissionService {
                 if ("SUB_PLAN".equals(document.getDocumentType())) {
                     throw Errors.subPlanPermissionInherited();
                 }
+                authorizationService.requirePlanAction(document, context,
+                        "CONFIG_PERMISSION");
+                // 设密主案的授权操作仍受密码锁保护(与前端弹窗口径一致)
+                DocumentPasswordService.requireDocumentPassword(document, "CONFIG_PERMISSION",
+                        DocumentPasswordService.requestPassword(request), context);
                 if (!"MAIN".equals(document.getDocumentType())) {
                     throw new DmsException("RESOURCE_NOT_FOUND", 404,
                             "主案不存在或类型不匹配", null);
@@ -229,7 +234,7 @@ public class PermissionService {
         return document;
     }
 
-    /** 对齐 _permission_source(无锁):附件拒绝、密级校验、子方案回源主案 + 密码锁。 */
+    /** 对齐 _permission_source(无锁):附件拒绝、密级校验、主案授权位、子方案回源主案 + 密码锁。 */
     private DocumentEntity permissionSource(DocumentEntity document, AuthContext context,
                                             HttpServletRequest request) {
         if ("ATTACHMENT".equals(document.getDocumentType())) {
@@ -237,6 +242,8 @@ public class PermissionService {
         }
         authorizationService.requireSecurityClearance(document, context);
         if ("MAIN".equals(document.getDocumentType())) {
+            // 读取与写入同口径:仅授予授权位(或管理员)可见授权清单
+            authorizationService.requirePlanAction(document, context, "CONFIG_PERMISSION");
             return document;
         }
         DocumentEntity source = document.getRootDocumentId() == null ? null

+ 2 - 1
backend-java/src/main/java/com/dms/service/PortalAuthService.java

@@ -203,7 +203,8 @@ public class PortalAuthService {
         data.put("accessToken", issued.token());
         data.put("tokenType", "Bearer");
         data.put("expiresIn", issued.expiresInSeconds());
-        data.put("user", userSummaryService.userSummary(user));
+        data.put("user", userSummaryService.userSummary(user,
+                userSummaryService.allowedModulesForUser(user)));
         return data;
     }
 

+ 5 - 94
backend-java/src/main/java/com/dms/service/RoleService.java

@@ -4,12 +4,9 @@ import com.dms.common.DmsException;
 import com.dms.common.Errors;
 import com.dms.common.Page;
 import com.dms.common.Payloads;
-import com.dms.mapper.OrganizationMapper;
-import com.dms.mapper.RoleDepartmentMapper;
 import com.dms.mapper.RoleMapper;
 import com.dms.mapper.RolePermissionMapper;
 import com.dms.mapper.UserMapper;
-import com.dms.model.RoleDepartmentEntity;
 import com.dms.model.RoleEntity;
 import com.dms.model.RolePermissionEntity;
 import com.dms.model.UserEntity;
@@ -30,7 +27,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 /**
- * 角色 CRUD、可访问部门、权限矩阵与用户关联维护,
+ * 角色 CRUD、权限矩阵与用户关联维护,
  * 对齐 backend/dms/services/role_service.py。
  */
 @Service
@@ -40,7 +37,6 @@ public class RoleService {
             Set.of("roleName", "sortNo", "status", "remark", "isDefault");
     private static final Set<String> ROLE_UPDATE_FIELDS = Set.of(
             "roleName", "sortNo", "status", "remark", "isDefault", "rowVersion");
-    private static final Set<String> ROLE_DEPARTMENT_FIELDS = Set.of("organizationIds", "rowVersion");
     private static final Set<String> ROLE_PERMISSION_FIELDS = Set.of("matrix", "rowVersion");
     private static final Set<String> ROLE_USER_FIELDS = Set.of("userIds", "rowVersion");
 
@@ -51,25 +47,21 @@ public class RoleService {
 
     private static final List<String> SECURITY_LEVELS = List.of(
             "PUBLIC", "INTERNAL", "SECRET", "CONFIDENTIAL", "TOP_SECRET");
-    private static final List<String> DEPT_SCOPES = List.of("OWN", "ASSIGNED", "OTHER");
+    private static final List<String> DEPT_SCOPES = List.of("OWN", "OTHER");
 
     private static final Set<MatrixKey> MATRIX_KEYS = buildMatrixKeys();
 
     private final RoleMapper roleMapper;
-    private final RoleDepartmentMapper roleDepartmentMapper;
     private final RolePermissionMapper rolePermissionMapper;
     private final UserMapper userMapper;
-    private final OrganizationMapper organizationMapper;
     private final AuditService auditService;
 
-    public RoleService(RoleMapper roleMapper, RoleDepartmentMapper roleDepartmentMapper,
+    public RoleService(RoleMapper roleMapper,
                        RolePermissionMapper rolePermissionMapper, UserMapper userMapper,
-                       OrganizationMapper organizationMapper, AuditService auditService) {
+                       AuditService auditService) {
         this.roleMapper = roleMapper;
-        this.roleDepartmentMapper = roleDepartmentMapper;
         this.rolePermissionMapper = rolePermissionMapper;
         this.userMapper = userMapper;
-        this.organizationMapper = organizationMapper;
         this.auditService = auditService;
     }
 
@@ -187,7 +179,7 @@ public class RoleService {
         return roleSummary(role, memberCount);
     }
 
-    /** 对齐 delete_role:占用检查 + 部门/矩阵行级联软删。 */
+    /** 对齐 delete_role:占用检查 + 矩阵行级联软删。 */
     @Transactional
     public Map<String, Object> delete(long roleId, int rowVersion, HttpServletRequest request) {
         Long actorId = AuthContext.get().userId();
@@ -206,7 +198,6 @@ public class RoleService {
 
         String targetName = role.getRoleName();
         roleMapper.softDelete(roleId, now, actorId);
-        roleDepartmentMapper.softDeleteByRole(roleId, now, actorId);
         rolePermissionMapper.softDeleteByRole(roleId, now, actorId);
         role.setRowVersion(role.getRowVersion() + 1);
 
@@ -221,86 +212,6 @@ public class RoleService {
         return result;
     }
 
-    // ------------------------------------------------------------ 可访问部门
-
-    @Transactional(readOnly = true)
-    public List<String> listDepartments(long roleId) {
-        requireRole(roleId);
-        return roleDepartmentMapper.listActiveByRole(roleId).stream()
-                .map(RoleDepartmentEntity::getOrganizationId)
-                .filter(Objects::nonNull)
-                .sorted()
-                .map(id -> String.valueOf(id))
-                .toList();
-    }
-
-    /** 对齐 update_role_departments:整体替换语义。 */
-    @Transactional
-    public Map<String, Object> updateDepartments(long roleId, Object payload,
-                                                 HttpServletRequest request) {
-        Map<String, Object> body = Payloads.exactPayload(payload, ROLE_DEPARTMENT_FIELDS);
-        List<Long> organizationIds = Payloads.idList(body.get("organizationIds"), "organizationIds");
-        int expectedVersion = Payloads.rowVersion(body.get("rowVersion"));
-        Long actorId = AuthContext.get().userId();
-        LocalDateTime now = LocalDateTime.now();
-
-        RoleEntity role = roleMapper.findActiveByIdForUpdate(roleId);
-        if (role == null) {
-            throw notFound("角色不存在");
-        }
-        checkVersion(role, expectedVersion);
-        Set<Long> targetIds = new HashSet<>(organizationIds);
-        if (!targetIds.isEmpty()) {
-            List<Long> validIds = organizationMapper.listValidIds(targetIds);
-            Set<Long> invalid = new HashSet<>(targetIds);
-            validIds.forEach(invalid::remove);
-            if (!invalid.isEmpty()) {
-                throw Errors.invalidArgument("organizationIds包含无效机构");
-            }
-        }
-
-        List<RoleDepartmentEntity> existingRows = roleDepartmentMapper.listActiveByRole(roleId);
-        Map<Long, RoleDepartmentEntity> existingMap = new HashMap<>();
-        for (RoleDepartmentEntity row : existingRows) {
-            existingMap.put(row.getOrganizationId(), row);
-        }
-        List<Long> removed = new ArrayList<>();
-        List<Long> added = new ArrayList<>();
-        for (Map.Entry<Long, RoleDepartmentEntity> entry : existingMap.entrySet()) {
-            if (!targetIds.contains(entry.getKey())) {
-                roleDepartmentMapper.softDelete(entry.getValue().getId(), now, actorId);
-                removed.add(entry.getKey());
-            }
-        }
-        for (Long orgId : new TreeSet<>(targetIds)) {
-            if (!existingMap.containsKey(orgId)) {
-                RoleDepartmentEntity row = new RoleDepartmentEntity();
-                row.setRoleId(roleId);
-                row.setOrganizationId(orgId);
-                row.setCreatedBy(actorId);
-                row.setUpdatedBy(actorId);
-                roleDepartmentMapper.insert(row);
-                added.add(orgId);
-            }
-        }
-
-        roleMapper.touch(roleId, now, actorId);
-        role.setRowVersion(role.getRowVersion() + 1);
-
-        Map<String, Object> detail = Payloads.orderedMap();
-        detail.put("scope", "departments");
-        detail.put("addedOrganizationIds", serializeIds(added));
-        detail.put("removedOrganizationIds", serializeIds(removed));
-        audit(request, AuditService.Action.CHANGE_ROLE_PERMISSION,
-                roleId, role.getRoleName(), detail);
-
-        Map<String, Object> result = Payloads.orderedMap();
-        result.put("id", serializeId(roleId));
-        result.put("organizationIds", serializeIds(new TreeSet<>(targetIds)));
-        result.put("rowVersion", role.getRowVersion());
-        return result;
-    }
-
     // ------------------------------------------------------------ 权限矩阵
 
     @Transactional(readOnly = true)

+ 283 - 0
backend-java/src/main/java/com/dms/service/SubPlanBindingService.java

@@ -0,0 +1,283 @@
+package com.dms.service;
+
+import com.dms.common.DeadlockRetry;
+import com.dms.common.DmsException;
+import com.dms.common.Errors;
+import com.dms.common.Payloads;
+import com.dms.mapper.DocumentMapper;
+import com.dms.mapper.SubPlanBindingMapper;
+import com.dms.model.DocumentEntity;
+import com.dms.model.SubPlanBindingEntity;
+import com.dms.security.AuthContext;
+import jakarta.servlet.http.HttpServletRequest;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+/**
+ * 子案跨主案挂载与解除挂载事务,
+ * 对齐 backend/dms/services/sub_plan_binding_service.py
+ * (与附件挂载同范式:主案行锁 + 密码动作 + 乐观锁 + 步长10排序 + 审计,
+ * detail 标注 scope=subPlanMount;子案权限仍继承其原主案,挂载仅建立展示关系)。
+ */
+@Service
+public class SubPlanBindingService {
+
+    private static final Set<String> BIND_SUB_FIELDS = Set.of(
+            "subPlanIds", "mainPlanRowVersion");
+
+    private final DocumentMapper documentMapper;
+    private final SubPlanBindingMapper subPlanBindingMapper;
+    private final AuthorizationService authorizationService;
+    private final AuditService auditService;
+    private final DeadlockRetry deadlockRetry;
+
+    public SubPlanBindingService(DocumentMapper documentMapper,
+                                 SubPlanBindingMapper subPlanBindingMapper,
+                                 AuthorizationService authorizationService,
+                                 AuditService auditService,
+                                 DeadlockRetry deadlockRetry) {
+        this.documentMapper = documentMapper;
+        this.subPlanBindingMapper = subPlanBindingMapper;
+        this.authorizationService = authorizationService;
+        this.auditService = auditService;
+        this.deadlockRetry = deadlockRetry;
+    }
+
+    /** 对齐 bind_sub_plans:去重后批量挂载其他主案的子案;已挂载的计入 existing。 */
+    public Map<String, Object> bindSubPlans(long mainDocumentId, Object payload,
+                                            HttpServletRequest request) {
+        Object[] parsed = bindPayload(payload);
+        List<Long> subPlanIds = (List<Long>) parsed[0];
+        long expectedVersion = (Long) parsed[1];
+        AuthContext context = AuthContext.get();
+        try {
+            return deadlockRetry.execute(() -> {
+                DocumentEntity main = lockedMain(mainDocumentId, context);
+                authorizationService.requirePlanAction(main, context, "BIND_ATTACHMENT");
+                DocumentPasswordService.requireDocumentPassword(main, "BIND_ATTACHMENT",
+                        DocumentPasswordService.requestPassword(request), context);
+                checkVersion(main, expectedVersion);
+                Map<Long, DocumentEntity> mountable = lockedSubPlans(main.getId(), subPlanIds);
+
+                List<SubPlanBindingEntity> bindings =
+                        subPlanBindingMapper.listByMainForUpdate(main.getId());
+                Set<Long> activeSubIds = new HashSet<>();
+                for (SubPlanBindingEntity binding : bindings) {
+                    if (!Boolean.TRUE.equals(binding.getIsDeleted())) {
+                        activeSubIds.add(binding.getSubDocumentId());
+                    }
+                }
+                List<Long> existingIds = new ArrayList<>();
+                List<Long> createdIds = new ArrayList<>();
+                for (Long subPlanId : subPlanIds) {
+                    if (!mountable.containsKey(subPlanId)) {
+                        continue;  // 属于本主案的自有子案:直接剔除(无需挂载)
+                    }
+                    if (activeSubIds.contains(subPlanId)) {
+                        existingIds.add(subPlanId);
+                    } else {
+                        createdIds.add(subPlanId);
+                    }
+                }
+                if (createdIds.isEmpty()) {
+                    Map<String, Object> result = Payloads.orderedMap();
+                    result.put("createdCount", 0);
+                    result.put("existingCount", existingIds.size());
+                    result.put("subPlanCount", activeSubIds.size());
+                    result.put("mainPlanRowVersion", main.getRowVersion());
+                    return result;
+                }
+                int maxSortNo = 0;
+                for (SubPlanBindingEntity binding : bindings) {
+                    if (!Boolean.TRUE.equals(binding.getIsDeleted())
+                            && binding.getSortNo() != null) {
+                        maxSortNo = Math.max(maxSortNo, binding.getSortNo());
+                    }
+                }
+                for (int offset = 1; offset <= createdIds.size(); offset++) {
+                    SubPlanBindingEntity entity = new SubPlanBindingEntity();
+                    entity.setMainDocumentId(main.getId());
+                    entity.setSubDocumentId(createdIds.get(offset - 1));
+                    entity.setSortNo(maxSortNo + offset * 10);
+                    entity.setCreatedBy(context.userId());
+                    entity.setUpdatedBy(context.userId());
+                    subPlanBindingMapper.insert(entity);
+                }
+                LocalDateTime now = now();
+                documentMapper.bumpMeta(main.getId(), context.userId(),
+                        context.realName(), now);
+                long newVersion = main.getRowVersion() + 1L;
+                Map<String, Object> detail = Payloads.orderedMap();
+                detail.put("scope", "subPlanMount");
+                detail.put("mainPlanId", String.valueOf(main.getId()));
+                detail.put("requestedSubPlanIds", stringIds(subPlanIds));
+                detail.put("createdSubPlanIds", stringIds(createdIds));
+                detail.put("existingSubPlanIds", stringIds(existingIds));
+                detail.put("createdCount", createdIds.size());
+                detail.put("existingCount", existingIds.size());
+                detail.put("mainPlanRowVersion", newVersion);
+                auditService.insert(auditService.businessAudit(request,
+                        AuditService.Action.BIND_ATTACHMENT, "ATTACHMENT_BINDING",
+                        main.getId(), main.getDocumentName(), auditService.toJson(detail)));
+                Map<String, Object> result = Payloads.orderedMap();
+                result.put("createdCount", createdIds.size());
+                result.put("existingCount", existingIds.size());
+                result.put("subPlanCount", activeSubIds.size() + createdIds.size());
+                result.put("mainPlanRowVersion", newVersion);
+                return result;
+            });
+        } catch (DuplicateKeyException ex) {
+            Map<String, Object> details = Payloads.orderedMap();
+            details.put("currentRowVersion", documentMapper.currentRowVersion(mainDocumentId));
+            throw new DmsException("DATA_VERSION_CONFLICT", 409,
+                    "子案挂载关系已被并发修改,请刷新后重试", details);
+        }
+    }
+
+    /** 对齐 unbind_sub_plan:逻辑删除一条有效子案挂载关系。 */
+    public Map<String, Object> unbindSubPlan(long mainDocumentId, long subDocumentId,
+                                             int expectedVersion, HttpServletRequest request) {
+        long expected = version(expectedVersion, "mainPlanRowVersion");
+        AuthContext context = AuthContext.get();
+        return deadlockRetry.execute(() -> {
+            DocumentEntity main = lockedMain(mainDocumentId, context);
+            authorizationService.requirePlanAction(main, context, "UNBIND_ATTACHMENT");
+            DocumentPasswordService.requireDocumentPassword(main, "UNBIND_ATTACHMENT",
+                    DocumentPasswordService.requestPassword(request), context);
+            checkVersion(main, expected);
+            SubPlanBindingEntity binding = subPlanBindingMapper.findActiveForUpdate(
+                    main.getId(), subDocumentId);
+            if (binding == null) {
+                throw new DmsException("RESOURCE_NOT_FOUND", 404,
+                        "有效子案挂载关系不存在", null);
+            }
+            LocalDateTime now = now();
+            subPlanBindingMapper.softDelete(binding.getId(), now, context.userId());
+            long remaining = subPlanBindingMapper.countActiveByMain(main.getId());
+            documentMapper.bumpMeta(main.getId(), context.userId(),
+                    context.realName(), now);
+            long newVersion = main.getRowVersion() + 1L;
+            Map<String, Object> detail = Payloads.orderedMap();
+            detail.put("scope", "subPlanMount");
+            detail.put("bindingId", String.valueOf(binding.getId()));
+            detail.put("mainPlanId", String.valueOf(main.getId()));
+            detail.put("subPlanId", String.valueOf(subDocumentId));
+            detail.put("subPlanCount", remaining);
+            detail.put("mainPlanRowVersion", newVersion);
+            auditService.insert(auditService.businessAudit(request,
+                    AuditService.Action.UNBIND_ATTACHMENT, "ATTACHMENT_BINDING",
+                    binding.getId(), main.getDocumentName(), auditService.toJson(detail)));
+            Map<String, Object> result = Payloads.orderedMap();
+            result.put("subPlanCount", remaining);
+            result.put("mainPlanRowVersion", newVersion);
+            return result;
+        });
+    }
+
+    // ------------------------------------------------------------ 内部实现
+
+    private static LocalDateTime now() {
+        return LocalDateTime.now(ZoneOffset.UTC);
+    }
+
+    /** 对齐 _locked_main:有效 MAIN 行锁定 + 密级前置。 */
+    private DocumentEntity lockedMain(long mainDocumentId, AuthContext context) {
+        DocumentEntity main = documentMapper.findActiveMainForUpdate(mainDocumentId);
+        if (main == null) {
+            throw new DmsException("RESOURCE_NOT_FOUND", 404, "主案不存在或类型不匹配", null);
+        }
+        authorizationService.requireSecurityClearance(main, context);
+        return main;
+    }
+
+    private static void checkVersion(DocumentEntity main, long expectedVersion) {
+        if (main.getRowVersion() == null || main.getRowVersion() != expectedVersion) {
+            Map<String, Object> details = Payloads.orderedMap();
+            details.put("currentRowVersion", main.getRowVersion());
+            throw new DmsException("DATA_VERSION_CONFLICT", 409,
+                    "数据已被其他用户修改,请刷新后重试", details);
+        }
+    }
+
+    /** 对齐 _locked_sub_plans:校验目标均为有效子案;自有子案剔除。 */
+    private Map<Long, DocumentEntity> lockedSubPlans(long mainId, List<Long> subPlanIds) {
+        List<Long> sorted = new ArrayList<>(subPlanIds);
+        Collections.sort(sorted);
+        Map<Long, DocumentEntity> byId = new HashMap<>();
+        for (DocumentEntity document : documentMapper.listForUpdateByIds(sorted)) {
+            byId.put(document.getId(), document);
+        }
+        for (Long subPlanId : subPlanIds) {
+            DocumentEntity document = byId.get(subPlanId);
+            if (document == null || Boolean.TRUE.equals(document.getIsDeleted())
+                    || !"SUB_PLAN".equals(document.getDocumentType())) {
+                throw new DmsException("RESOURCE_NOT_FOUND", 404,
+                        "子案不存在或类型不匹配", null);
+            }
+        }
+        Map<Long, DocumentEntity> mountable = new HashMap<>();
+        for (Long subPlanId : subPlanIds) {
+            DocumentEntity document = byId.get(subPlanId);
+            if (document.getParentDocumentId() == null
+                    || document.getParentDocumentId() != mainId) {
+                mountable.put(subPlanId, document);
+            }
+        }
+        return mountable;
+    }
+
+    /** 对齐 _bind_payload。 */
+    private static Object[] bindPayload(Object payload) {
+        if (!(payload instanceof Map<?, ?> map) || !map.keySet().equals(BIND_SUB_FIELDS)) {
+            throw Errors.invalidArgument("请求字段必须且只能包含subPlanIds和mainPlanRowVersion");
+        }
+        Object rawIds = map.get("subPlanIds");
+        if (!(rawIds instanceof List<?> rawList) || rawList.isEmpty()) {
+            throw Errors.invalidArgument("subPlanIds必须是非空数组");
+        }
+        List<Long> subPlanIds = new ArrayList<>();
+        Set<Long> seen = new HashSet<>();
+        for (int index = 0; index < rawList.size(); index++) {
+            Long subPlanId = Payloads.stringId(rawList.get(index),
+                    "subPlanIds[" + index + "]", false);
+            if (seen.add(subPlanId)) {
+                subPlanIds.add(subPlanId);
+            }
+        }
+        return new Object[]{subPlanIds,
+                version(map.get("mainPlanRowVersion"), "mainPlanRowVersion")};
+    }
+
+    /** 对齐 _version:非负整数(布尔/浮点拒绝)。 */
+    private static long version(Object value, String name) {
+        if (value instanceof Integer number) {
+            return number >= 0 ? number : rejectVersion(name);
+        }
+        if (value instanceof Long number) {
+            return number >= 0 ? number : rejectVersion(name);
+        }
+        return rejectVersion(name);
+    }
+
+    private static long rejectVersion(String name) {
+        throw Errors.invalidArgument(name + "必须是非负整数");
+    }
+
+    /** 对齐 serialize_id 列表。 */
+    private static List<String> stringIds(List<Long> ids) {
+        List<String> values = new ArrayList<>();
+        for (Long id : ids) {
+            values.add(String.valueOf(id));
+        }
+        return values;
+    }
+}

+ 40 - 3
backend-java/src/main/java/com/dms/service/UserSummaryService.java

@@ -5,9 +5,11 @@ import com.dms.mapper.RoleMapper;
 import com.dms.model.OrganizationEntity;
 import com.dms.model.RoleEntity;
 import com.dms.model.UserEntity;
+import com.dms.security.AuthContext;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
 import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -15,7 +17,8 @@ import org.springframework.stereotype.Service;
 
 /**
  * 用户摘要视图,对齐 backend/dms/services/user_service.py
- * user_summary / effective_organization / effective_role / _format_utc / ALLOWED_MODULES。
+ * user_summary / effective_organization / effective_role / _format_utc / ALLOWED_MODULES
+ * / allowed_modules_for_user(阶段10:管理菜单按用户文档操作权限动态计算)。
  */
 @Service
 public class UserSummaryService {
@@ -31,13 +34,21 @@ public class UserSummaryService {
 
     private final OrganizationMapper organizationMapper;
     private final RoleMapper roleMapper;
+    private final AuthorizationService authorizationService;
 
-    public UserSummaryService(OrganizationMapper organizationMapper, RoleMapper roleMapper) {
+    public UserSummaryService(OrganizationMapper organizationMapper, RoleMapper roleMapper,
+                              AuthorizationService authorizationService) {
         this.organizationMapper = organizationMapper;
         this.roleMapper = roleMapper;
+        this.authorizationService = authorizationService;
     }
 
     public Map<String, Object> userSummary(UserEntity user) {
+        return userSummary(user, null);
+    }
+
+    /** allowedModules 可覆盖(登录/会话接口按实际文档权限动态计算)。 */
+    public Map<String, Object> userSummary(UserEntity user, List<String> allowedModules) {
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("id", serializeId(user.getId()));
         result.put("username", user.getUsername());
@@ -53,10 +64,36 @@ public class UserSummaryService {
         result.put("rowVersion", user.getRowVersion());
         result.put("lastLoginAt", formatUtc(user.getLastLoginAt()));
         result.put("lastLoginIp", user.getLastLoginIp());
-        result.put("allowedModules", allowedModules(user.getRoleCode()));
+        result.put("allowedModules",
+                allowedModules != null ? allowedModules : this.allowedModules(user.getRoleCode()));
         return result;
     }
 
+    /**
+     * 对齐 allowed_modules_for_user:按"用户对文档的实际权限"计算可见模块。
+     *
+     * <ul>
+     * <li>浏览:登录即有;</li>
+     * <li>后台管理:管理员恒可见;普通用户在角色矩阵对任一已发布主案命中
+     *     管理位(编辑/删除/授权/挂载/解除挂载)时可见;</li>
+     * <li>审计 / 机构用户管理:仅管理员。</li>
+     * </ul>
+     */
+    public List<String> allowedModulesForUser(UserEntity user) {
+        if ("ADMIN".equals(user.getRoleCode())) {
+            return allowedModules(user.getRoleCode());
+        }
+        List<String> modules = new ArrayList<>(allowedModules(user.getRoleCode()));
+        AuthContext context = new AuthContext(
+                user.getId(), user.getUsername(), user.getRealName(),
+                user.getOrganizationId(), user.getOrganizationName(),
+                user.getRoleCode(), user.getSecurityLevel(), user.getAuthVersion());
+        if (authorizationService.hasAnyManageAction(context)) {
+            modules.add("BACKEND_MANAGEMENT");
+        }
+        return modules;
+    }
+
     public static String serializeId(Long id) {
         return id == null ? null : String.valueOf(id);
     }

+ 6 - 2
backend-java/src/main/resources/application.yml

@@ -6,8 +6,12 @@ spring:
   application:
     name: dms
   flyway:
-    enabled: true
-    # schema 已由 Alembic 建至 0016,V1 仅作 baseline 占位;此后演进仅走 Flyway
+    # 达梦(DM)不在 Flyway 社区版支持列表,切换 DM 后默认关闭内置迁移,
+    # schema 由外部数据迁移(dts/手工)负责;可用 DMS_FLYWAY_ENABLED=true 强制开启。
+    # 回切 MySQL 时改回 enabled: true 即可恢复 Flyway 自动迁移。
+    enabled: ${DMS_FLYWAY_ENABLED:false}
+    # V1 为 Alembic 0016 基线占位;V2~V6 对齐 Python 版 Alembic 0017~0021
+    # (均为幂等脚本,兼容已由 Alembic 升级的库);此后演进仅走 Flyway。
     baseline-on-migrate: true
     baseline-version: 0
     locations: classpath:db/migration

+ 16 - 0
backend-java/src/main/resources/db/migration/V2__category_type_display_name.sql

@@ -0,0 +1,16 @@
+-- 0017_category_type_display_name 的 Flyway 等价迁移(Java 版阶段10同步)。
+-- doc_category 新增 type_display_name VARCHAR(128) NULL:仅一级分类有意义,
+-- 检索结果列表中该分类下主体方案的类型徽标显示该名称(未设置回退分类名称)。
+-- 幂等:并行期内库可能已由 Python 版 Alembic 0017 加列,先探测再决定是否 ALTER。
+SET @column_exists = (
+    SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'doc_category'
+      AND COLUMN_NAME = 'type_display_name'
+);
+SET @ddl = IF(@column_exists = 0,
+    'ALTER TABLE doc_category ADD COLUMN type_display_name VARCHAR(128) NULL',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 24 - 0
backend-java/src/main/resources/db/migration/V3__sub_plan_binding.sql

@@ -0,0 +1,24 @@
+-- 0018_sub_plan_binding 的 Flyway 等价迁移(Java 版阶段10同步)。
+-- 子案跨主案挂载关系表:主案可挂载其他主案的子案(仅展示关系,
+-- 子案权限仍继承其原主案 root_document_id)。
+-- 幂等:CREATE TABLE IF NOT EXISTS,已由 Python 版 Alembic 0018 建表时整段跳过。
+CREATE TABLE IF NOT EXISTS doc_sub_plan_binding (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  created_by BIGINT DEFAULT NULL,
+  updated_by BIGINT DEFAULT NULL,
+  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+  updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+  deleted_at DATETIME(3) DEFAULT NULL,
+  is_deleted TINYINT(1) NOT NULL DEFAULT '0',
+  row_version INT NOT NULL DEFAULT '0',
+  main_document_id BIGINT NOT NULL,
+  sub_document_id BIGINT NOT NULL,
+  sort_no INT NOT NULL DEFAULT '0',
+  active_marker INT GENERATED ALWAYS AS (if((is_deleted = 0), 1, NULL)) VIRTUAL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uq_doc_sub_plan_binding_active (main_document_id, sub_document_id, active_marker),
+  KEY ix_doc_sub_plan_binding_main_active_sort (main_document_id, is_deleted, sort_no),
+  KEY ix_doc_sub_plan_binding_sub_active (sub_document_id, is_deleted),
+  CONSTRAINT fk_doc_sub_plan_binding_main_id_doc_document FOREIGN KEY (main_document_id) REFERENCES doc_document (id) ON DELETE RESTRICT,
+  CONSTRAINT fk_doc_sub_plan_binding_sub_id_doc_document FOREIGN KEY (sub_document_id) REFERENCES doc_document (id) ON DELETE RESTRICT
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

+ 17 - 0
backend-java/src/main/resources/db/migration/V4__content_extract_error.sql

@@ -0,0 +1,17 @@
+-- 0019_content_extract_error 的 Flyway 等价迁移(Java 版阶段10同步)。
+-- doc_document 新增 content_extract_error VARCHAR(512) NULL:
+-- 正文提取失败的脱敏原因(异常类型+首行信息,去除绝对路径),
+-- 供管理员在"编辑"界面排障并触发"重新解析";成功/空文本/不支持时为 NULL。
+-- 幂等:并行期内库可能已由 Python 版 Alembic 0019 加列,先探测再决定是否 ALTER。
+SET @column_exists = (
+    SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'doc_document'
+      AND COLUMN_NAME = 'content_extract_error'
+);
+SET @ddl = IF(@column_exists = 0,
+    'ALTER TABLE doc_document ADD COLUMN content_extract_error VARCHAR(512) NULL',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 140 - 0
backend-java/src/main/resources/db/migration/V5__sub_plan_to_attachment.sql

@@ -0,0 +1,140 @@
+-- 0020_sub_plan_to_attachment 的 Flyway 等价迁移(Java 版阶段11同步)。
+-- 配套方案并入共享资料库:SUB_PLAN → ATTACHMENT(ACCOMPANYING_PLAN)。
+-- 全步骤按名/按数据态幂等:并行期内库可能已由 Python 版 Alembic 0020 完成转换,
+-- 重跑均为 no-op;两侧以相同的约束名(旧删新加)保证收敛一致。
+-- 注:附件shape约束同步放宽(允许任意密级,存量子案密级保留),
+--     附件读路径由服务层密级前置拦截(对齐 Python evaluate_attachment_access)。
+
+-- 1) 附件类型 CHECK 扩四值:旧约束 DROP IF EXISTS(按名探测 + PREPARE)
+SET @old_check_exists = (
+    SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_document'
+      AND CONSTRAINT_NAME = 'ck_doc_document_document_attachment_type'
+);
+SET @ddl = IF(@old_check_exists = 1,
+    'ALTER TABLE doc_document DROP CHECK ck_doc_document_document_attachment_type',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 新约束 ADD IF NOT EXISTS(按名探测 + PREPARE)
+SET @new_check_exists = (
+    SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_document'
+      AND CONSTRAINT_NAME = 'ck_doc_document_attachment_type_v2'
+);
+SET @ddl = IF(@new_check_exists = 0,
+    'ALTER TABLE doc_document ADD CONSTRAINT ck_doc_document_attachment_type_v2 CHECK (attachment_type IS NULL OR attachment_type IN (''SUPPORT_ACHIEVEMENT'',''TRAINING_MATERIAL'',''TYPICAL_CASE'',''ACCOMPANYING_PLAN''))',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 1b) shape 约束放宽:附件允许任意密级(旧约束 DROP IF EXISTS)
+SET @old_shape_exists = (
+    SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_document'
+      AND CONSTRAINT_NAME = 'ck_doc_document_document_type_shape'
+);
+SET @ddl = IF(@old_shape_exists = 1,
+    'ALTER TABLE doc_document DROP CHECK ck_doc_document_document_type_shape',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @new_shape_exists = (
+    SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_document'
+      AND CONSTRAINT_NAME = 'ck_doc_document_type_shape_v2'
+);
+SET @ddl = IF(@new_shape_exists = 0,
+    'ALTER TABLE doc_document ADD CONSTRAINT ck_doc_document_type_shape_v2 CHECK (((document_type = ''MAIN'') AND (parent_document_id IS NULL) AND (category_id IS NOT NULL) AND (attachment_type IS NULL)) OR ((document_type = ''SUB_PLAN'') AND (parent_document_id IS NOT NULL) AND (root_document_id IS NOT NULL) AND (category_id IS NOT NULL) AND (attachment_type IS NULL)) OR ((document_type = ''ATTACHMENT'') AND (parent_document_id IS NULL) AND (root_document_id IS NULL) AND (category_id IS NULL) AND (attachment_type IS NOT NULL) AND (visibility_type = ''ALL_AUTHENTICATED'')))',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 2) 同名消歧(仅存活行;候选id先落临时表,规避 MySQL 1093)
+DROP TEMPORARY TABLE IF EXISTS tmp_v5_sub_dedupe;
+CREATE TEMPORARY TABLE tmp_v5_sub_dedupe (id BIGINT PRIMARY KEY);
+INSERT INTO tmp_v5_sub_dedupe (id)
+SELECT a.id FROM doc_document a
+WHERE a.document_type = 'SUB_PLAN' AND a.is_deleted = 0 AND (
+    EXISTS (
+        SELECT 1 FROM doc_document b
+        WHERE b.is_deleted = 0 AND b.document_type = 'ATTACHMENT'
+          AND b.document_name = a.document_name
+    )
+    OR EXISTS (
+        SELECT 1 FROM doc_document c
+        WHERE c.document_type = 'SUB_PLAN'
+          AND c.document_name = a.document_name AND c.id < a.id
+    )
+);
+UPDATE doc_document d JOIN tmp_v5_sub_dedupe t ON d.id = t.id
+SET d.document_name = CONCAT(d.document_name, '(', d.id, ')');
+DROP TEMPORARY TABLE IF EXISTS tmp_v5_sub_dedupe;
+
+-- 3) 自有子案关系先转挂载(需 parent_document_id,父主案存活的存活子案)
+INSERT INTO doc_attachment_binding
+    (main_document_id, attachment_document_id, sort_no, created_by, updated_by)
+SELECT d.parent_document_id, d.id, d.sort_no, d.created_by, d.updated_by
+FROM doc_document d
+WHERE d.document_type = 'SUB_PLAN'
+  AND d.is_deleted = 0
+  AND d.parent_document_id IS NOT NULL
+  AND EXISTS (
+      SELECT 1 FROM doc_document m
+      WHERE m.id = d.parent_document_id AND m.is_deleted = 0
+  )
+  AND NOT EXISTS (
+      SELECT 1 FROM doc_attachment_binding b
+      WHERE b.main_document_id = d.parent_document_id
+        AND b.attachment_document_id = d.id AND b.is_deleted = 0
+  );
+
+-- 4) 子案跨主案挂载关系转附件挂载后软删(防重跑双转)
+INSERT INTO doc_attachment_binding
+    (main_document_id, attachment_document_id, sort_no, created_by, updated_by)
+SELECT s.main_document_id, s.sub_document_id, s.sort_no, s.created_by, s.updated_by
+FROM doc_sub_plan_binding s
+WHERE s.is_deleted = 0
+  AND NOT EXISTS (
+      SELECT 1 FROM doc_attachment_binding b
+      WHERE b.main_document_id = s.main_document_id
+        AND b.attachment_document_id = s.sub_document_id AND b.is_deleted = 0
+  );
+
+UPDATE doc_sub_plan_binding
+SET is_deleted = 1, deleted_at = NOW(3), updated_at = NOW(3),
+    row_version = row_version + 1
+WHERE is_deleted = 0;
+
+-- 5) 类型转换(置空父/根/分类满足附件shape,保留密级;重跑自然无目标行)
+UPDATE doc_document
+SET document_type = 'ATTACHMENT',
+    attachment_type = 'ACCOMPANYING_PLAN',
+    visibility_type = 'ALL_AUTHENTICATED',
+    parent_document_id = NULL,
+    root_document_id = NULL,
+    category_id = NULL,
+    category_name = NULL,
+    category_path = NULL
+WHERE document_type = 'SUB_PLAN';
+
+-- 6) 计数重算与清零
+UPDATE doc_document m
+SET m.attachment_count = (
+    SELECT COUNT(*) FROM doc_attachment_binding b
+    WHERE b.main_document_id = m.id AND b.is_deleted = 0
+)
+WHERE m.document_type = 'MAIN'
+  AND m.attachment_count <> (
+    SELECT COUNT(*) FROM doc_attachment_binding b
+    WHERE b.main_document_id = m.id AND b.is_deleted = 0
+  );
+
+UPDATE doc_document SET child_count = 0
+WHERE document_type = 'MAIN' AND child_count <> 0;

+ 37 - 0
backend-java/src/main/resources/db/migration/V6__remove_assigned_scope.sql

@@ -0,0 +1,37 @@
+-- 0021_remove_assigned_scope 的 Flyway 等价迁移(Java 版同步)。
+-- 移除"可访问部门"维度:部门范围 OWN/ASSIGNED/OTHER 收敛为 OWN/OTHER,
+-- "其他部门"涵盖所属部门子树之外的全部部门(含无归属文档)。
+-- 全步骤按名/按数据态幂等:并行期内库可能已由 Python 版 Alembic 0021 完成,
+-- 重跑均为 no-op;两侧以相同的约束名(旧删新加)保证收敛一致。
+
+-- 1) 存量 ASSIGNED 权限行物理删除(含软删行;新 CHECK 前置条件,重跑无目标行)
+DELETE FROM sys_role_permission WHERE dept_scope = 'ASSIGNED';
+
+-- 2) dept_scope CHECK 收敛为 OWN/OTHER:旧约束 DROP IF EXISTS(按名探测 + PREPARE)
+SET @old_check_exists = (
+    SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_role_permission'
+      AND CONSTRAINT_NAME = 'ck_sys_role_permission_role_permission_dept_scope'
+);
+SET @ddl = IF(@old_check_exists = 1,
+    'ALTER TABLE sys_role_permission DROP CHECK ck_sys_role_permission_role_permission_dept_scope',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 新约束 ADD IF NOT EXISTS(按名探测 + PREPARE)
+SET @new_check_exists = (
+    SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_role_permission'
+      AND CONSTRAINT_NAME = 'ck_sys_role_permission_dept_scope_v2'
+);
+SET @ddl = IF(@new_check_exists = 0,
+    'ALTER TABLE sys_role_permission ADD CONSTRAINT ck_sys_role_permission_dept_scope_v2 CHECK (dept_scope IN (''OWN'', ''OTHER''))',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 3) 角色可访问部门表下线
+DROP TABLE IF EXISTS sys_role_dept;

+ 4 - 2
backend/.env.example

@@ -1,5 +1,7 @@
-# DMS MySQL 8连接(示例值,不包含真实凭据)
-DMS_DATABASE_URL=mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4
+# 数据库连接:达梦 DM(当前启用;主机不变,端口 5236,用户/库名 DMS)
+DMS_DATABASE_URL=dm+dmPython://DMS:change-me@127.0.0.1:5236
+# 原 MySQL 8连接(保留备查,回切时取消注释并注释上方 DM 链接):
+# DMS_DATABASE_URL=mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4
 
 # 存储根目录:留空(推荐)由启动器自动选择默认位置。
 #   - 开发模式:backend/dms-storage/

+ 1 - 19
backend/dms/api/v1/roles.py

@@ -1,4 +1,4 @@
-"""角色管理:角色 CRUD、可访问部门、权限矩阵与用户关联。"""
+"""角色管理:角色 CRUD、权限矩阵与用户关联。"""
 
 from __future__ import annotations
 
@@ -12,12 +12,10 @@ from dms.security.decorators import admin_required, bearer_auth_required
 from dms.services.role_service import (
     create_role,
     delete_role,
-    list_role_departments,
     list_role_permissions,
     list_role_users,
     list_roles,
     update_role,
-    update_role_departments,
     update_role_permissions,
     update_role_users,
 )
@@ -95,22 +93,6 @@ def delete_role_route(id: str):
     )
 
 
-@api_v1.get("/roles/<id>/departments")
-@bearer_auth_required
-@admin_required
-def role_departments(id: str):
-    return success_response(list_role_departments(_parse_role_id(id)))
-
-
-@api_v1.put("/roles/<id>/departments")
-@bearer_auth_required
-@admin_required
-def update_role_departments_route(id: str):
-    return success_response(
-        update_role_departments(_parse_role_id(id), _json_body())
-    )
-
-
 @api_v1.get("/roles/<id>/permissions")
 @bearer_auth_required
 @admin_required

+ 3 - 1
backend/dms/common/enums.py

@@ -56,6 +56,8 @@ class AttachmentType(StrEnum):
     SUPPORT_ACHIEVEMENT = "SUPPORT_ACHIEVEMENT"
     TRAINING_MATERIAL = "TRAINING_MATERIAL"
     TYPICAL_CASE = "TYPICAL_CASE"
+    # 配套方案(原SUB_PLAN并轨,0020迁移):入库/挂载/解绑与其余三类附件一致
+    ACCOMPANYING_PLAN = "ACCOMPANYING_PLAN"
 
 
 class SubjectType(StrEnum):
@@ -65,7 +67,7 @@ class SubjectType(StrEnum):
 
 class DeptScope(StrEnum):
     OWN = "OWN"
-    ASSIGNED = "ASSIGNED"
+    # 其他部门:所属部门子树之外的全部部门(含无归属文档)
     OTHER = "OTHER"
 
 

+ 15 - 7
backend/dms/config.py

@@ -19,9 +19,10 @@ DEFAULT_UI_DICTIONARY_CONFIG_PATH = (
 DEFAULT_DOCUMENT_RELATION_PRESET_CONFIG_PATH = (
     BACKEND_ROOT / "dms" / "resources" / "document-relation-presets.zh-CN.json"
 )
-DEFAULT_DATABASE_URL = (
-    "mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4"
-)
+# 当前数据库:达梦 DM(dm+dmPython 方言,默认端口 5236,schema 由用户 DMS 决定)。
+# 原 MySQL 默认链接(保留备查,回切时恢复):
+# "mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4"
+DEFAULT_DATABASE_URL = "dm+dmPython://DMS:change-me@127.0.0.1:5236"
 
 
 def _positive_int(name: str, default: int) -> int:
@@ -98,16 +99,23 @@ def load_dms_config() -> dict[str, Any]:
         pdf_viewer_engine_raw if pdf_viewer_engine_raw in {"iframe", "pdfjs"} else "iframe"
     )
 
+    resolved_database_url = database_url or DEFAULT_DATABASE_URL
+    # init_command(SET time_zone)为 pymysql 专属参数,达梦 dmPython 不接受;
+    # 仅在 MySQL 链接时下发。
+    connect_args = (
+        {"init_command": "SET time_zone = '+00:00'"}
+        if resolved_database_url.startswith(("mysql", "jdbc:mysql"))
+        else {}
+    )
+
     return {
-        "SQLALCHEMY_DATABASE_URI": database_url or DEFAULT_DATABASE_URL,
+        "SQLALCHEMY_DATABASE_URI": resolved_database_url,
         "SQLALCHEMY_TRACK_MODIFICATIONS": False,
         "SQLALCHEMY_ENGINE_OPTIONS": {
             "isolation_level": "READ COMMITTED",
             "pool_pre_ping": True,
             "pool_recycle": 1800,
-            "connect_args": {
-                "init_command": "SET time_zone = '+00:00'",
-            },
+            "connect_args": connect_args,
         },
         "DMS_STORAGE_ROOT": str(storage_root.resolve()),
         "DMS_MAX_UPLOAD_SIZE": max_upload_size,

+ 1 - 2
backend/dms/models/__init__.py

@@ -8,7 +8,7 @@ from dms.models.document_relation import DocumentRelation
 from dms.models.ip_entry import IpEntry, OrgIpAssignment
 from dms.models.organization import Organization
 from dms.models.permission import Permission
-from dms.models.role import Role, RoleDepartment, RolePermission
+from dms.models.role import Role, RolePermission
 from dms.models.sub_plan_binding import SubPlanBinding
 from dms.models.user import User
 
@@ -23,7 +23,6 @@ __all__ = [
     "OrgIpAssignment",
     "Permission",
     "Role",
-    "RoleDepartment",
     "RolePermission",
     "SubPlanBinding",
     "User",

+ 5 - 46
backend/dms/models/role.py

@@ -1,9 +1,8 @@
-"""自定义业务角色模型:角色、可访问部门、权限矩阵。
+"""自定义业务角色模型:角色、权限矩阵。
 
 内置 ADMIN/USER 之外的第三层授权体系:
 - Role 本体(含全局唯一默认角色标记);
-- RoleDepartment 角色可访问部门列表(矩阵"已配置部门"判定依据);
-- RolePermission 权限矩阵(密级 × 部门范围 × 7 操作,每角色固定 15 行)。
+- RolePermission 权限矩阵(密级 × 部门范围 × 7 操作,每角色固定 10 行)。
 """
 
 from __future__ import annotations
@@ -83,50 +82,10 @@ class Role(BusinessTableMixin, db.Model):
     )
 
 
-class RoleDepartment(BusinessTableMixin, db.Model):
-    """角色可访问部门列表(矩阵 ASSIGNED 维度判定依据)。"""
-
-    __tablename__ = "sys_role_dept"
-    __table_args__ = (
-        UniqueConstraint(
-            "role_id",
-            "organization_id",
-            "active_marker",
-            name="uq_sys_role_dept_active",
-        ),
-        Index(
-            "ix_sys_role_dept_role_active",
-            "role_id",
-            "is_deleted",
-        ),
-        Index(
-            "ix_sys_role_dept_org_active",
-            "organization_id",
-            "is_deleted",
-        ),
-    )
-
-    role_id: Mapped[int] = mapped_column(
-        BigInteger,
-        ForeignKey("sys_role.id", ondelete="RESTRICT"),
-        nullable=False,
-    )
-    organization_id: Mapped[int] = mapped_column(
-        BigInteger,
-        ForeignKey("sys_organization.id", ondelete="RESTRICT"),
-        nullable=False,
-    )
-    active_marker: Mapped[int | None] = mapped_column(
-        Integer,
-        Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),
-        nullable=True,
-    )
-
-
 class RolePermission(BusinessTableMixin, db.Model):
     """角色权限矩阵行:密级 × 部门范围 → 7 个操作布尔位。
 
-    每角色固定 15 行(5 密级 × 3 部门范围),整存整取;
+    每角色固定 10 行(5 密级 × 2 部门范围),整存整取;
     与文档 ACL 为 OR 叠加,矩阵是主权限源。
     """
 
@@ -138,8 +97,8 @@ class RolePermission(BusinessTableMixin, db.Model):
             name="role_permission_security_level",
         ),
         CheckConstraint(
-            "dept_scope IN ('OWN', 'ASSIGNED', 'OTHER')",
-            name="role_permission_dept_scope",
+            "dept_scope IN ('OWN', 'OTHER')",
+            name="dept_scope_v2",
         ),
         UniqueConstraint(
             "role_id",

+ 450 - 90
backend/dms/resources/ui-dictionaries.zh-CN.json

@@ -3,124 +3,484 @@
   "locale": "zh-CN",
   "dictionaries": {
     "roleCodes": [
-      {"code": "USER", "label": "普通用户", "sortNo": 10},
-      {"code": "ADMIN", "label": "管理员", "sortNo": 20}
+      {
+        "code": "USER",
+        "label": "普通用户",
+        "sortNo": 10
+      },
+      {
+        "code": "ADMIN",
+        "label": "管理员",
+        "sortNo": 20
+      }
     ],
     "allowedModules": [
-      {"code": "DOCUMENT_BROWSER", "label": "文档浏览", "sortNo": 10},
-      {"code": "BACKEND_MANAGEMENT", "label": "后台管理", "sortNo": 20},
-      {"code": "AUDIT_LOG", "label": "日志审计", "sortNo": 30},
-      {"code": "ORGANIZATION_USER_MANAGEMENT", "label": "机构用户", "sortNo": 40}
+      {
+        "code": "DOCUMENT_BROWSER",
+        "label": "文档浏览",
+        "sortNo": 10
+      },
+      {
+        "code": "BACKEND_MANAGEMENT",
+        "label": "后台管理",
+        "sortNo": 20
+      },
+      {
+        "code": "AUDIT_LOG",
+        "label": "日志审计",
+        "sortNo": 30
+      },
+      {
+        "code": "ORGANIZATION_USER_MANAGEMENT",
+        "label": "机构用户",
+        "sortNo": 40
+      }
     ],
     "documentTypes": [
-      {"code": "MAIN", "label": "主案", "sortNo": 10},
-      {"code": "SUB_PLAN", "label": "子方案", "sortNo": 20},
-      {"code": "ATTACHMENT", "label": "共享附件", "sortNo": 30}
+      {
+        "code": "MAIN",
+        "label": "主案",
+        "sortNo": 10
+      },
+      {
+        "code": "SUB_PLAN",
+        "label": "子方案",
+        "sortNo": 20
+      },
+      {
+        "code": "ATTACHMENT",
+        "label": "共享附件",
+        "sortNo": 30
+      }
     ],
     "documentStatuses": [
-      {"code": "DRAFT", "label": "草稿", "sortNo": 10},
-      {"code": "PUBLISHED", "label": "已发布", "sortNo": 20},
-      {"code": "ARCHIVED", "label": "已归档", "sortNo": 30}
+      {
+        "code": "DRAFT",
+        "label": "草稿",
+        "sortNo": 10
+      },
+      {
+        "code": "PUBLISHED",
+        "label": "已发布",
+        "sortNo": 20
+      },
+      {
+        "code": "ARCHIVED",
+        "label": "已归档",
+        "sortNo": 30
+      }
     ],
     "securityLevels": [
-      {"code": "PUBLIC", "label": "公开", "sortNo": 10},
-      {"code": "INTERNAL", "label": "内部", "sortNo": 20},
-      {"code": "SECRET", "label": "秘密", "sortNo": 30},
-      {"code": "CONFIDENTIAL", "label": "机密", "sortNo": 40},
-      {"code": "TOP_SECRET", "label": "绝密", "sortNo": 50}
+      {
+        "code": "PUBLIC",
+        "label": "公开",
+        "sortNo": 10
+      },
+      {
+        "code": "INTERNAL",
+        "label": "内部",
+        "sortNo": 20
+      },
+      {
+        "code": "SECRET",
+        "label": "秘密",
+        "sortNo": 30
+      },
+      {
+        "code": "CONFIDENTIAL",
+        "label": "机密",
+        "sortNo": 40
+      },
+      {
+        "code": "TOP_SECRET",
+        "label": "绝密",
+        "sortNo": 50
+      }
     ],
     "visibilityTypes": [
-      {"code": "ALL_AUTHENTICATED", "label": "全体已登录用户", "sortNo": 10},
-      {"code": "ORGANIZATION", "label": "按组织范围", "sortNo": 20},
-      {"code": "CUSTOM", "label": "自定义范围", "sortNo": 30}
+      {
+        "code": "ALL_AUTHENTICATED",
+        "label": "全体已登录用户",
+        "sortNo": 10
+      },
+      {
+        "code": "ORGANIZATION",
+        "label": "按组织范围",
+        "sortNo": 20
+      },
+      {
+        "code": "CUSTOM",
+        "label": "自定义范围",
+        "sortNo": 30
+      }
     ],
     "attachmentTypes": [
-      {"code": "SUPPORT_ACHIEVEMENT", "label": "支撑成果", "sortNo": 10},
-      {"code": "TRAINING_MATERIAL", "label": "方案训考资料", "sortNo": 20},
-      {"code": "TYPICAL_CASE", "label": "典型案例", "sortNo": 30}
+      {
+        "code": "ACCOMPANYING_PLAN",
+        "label": "配套方案",
+        "sortNo": 10
+      },
+      {
+        "code": "SUPPORT_ACHIEVEMENT",
+        "label": "支撑成果",
+        "sortNo": 20
+      },
+      {
+        "code": "TRAINING_MATERIAL",
+        "label": "方案训考资料",
+        "sortNo": 30
+      },
+      {
+        "code": "TYPICAL_CASE",
+        "label": "典型案例",
+        "sortNo": 40
+      }
     ],
     "subjectTypes": [
-      {"code": "ORG", "label": "组织", "sortNo": 10},
-      {"code": "USER", "label": "人员", "sortNo": 20}
+      {
+        "code": "ORG",
+        "label": "组织",
+        "sortNo": 10
+      },
+      {
+        "code": "USER",
+        "label": "人员",
+        "sortNo": 20
+      }
     ],
     "deptScopes": [
-      {"code": "OWN", "label": "所属部门方案", "sortNo": 10},
-      {"code": "ASSIGNED", "label": "可访问部门方案", "sortNo": 20},
-      {"code": "OTHER", "label": "其他部门方案", "sortNo": 30}
+      {
+        "code": "OWN",
+        "label": "所属部门方案",
+        "sortNo": 10
+      },
+      {
+        "code": "OTHER",
+        "label": "其他部门方案",
+        "sortNo": 30
+      }
     ],
     "allowedActions": [
-      {"code": "VIEW", "label": "查看", "sortNo": 10},
-      {"code": "DOWNLOAD", "label": "下载", "sortNo": 20},
-      {"code": "EDIT", "label": "编辑", "sortNo": 30},
-      {"code": "CONFIG_PERMISSION", "label": "配置权限", "sortNo": 40},
-      {"code": "DELETE", "label": "删除", "sortNo": 50},
-      {"code": "BIND_ATTACHMENT", "label": "挂载附件", "sortNo": 60},
-      {"code": "UNBIND_ATTACHMENT", "label": "解除挂载", "sortNo": 70}
+      {
+        "code": "VIEW",
+        "label": "查看",
+        "sortNo": 10
+      },
+      {
+        "code": "DOWNLOAD",
+        "label": "下载",
+        "sortNo": 20
+      },
+      {
+        "code": "EDIT",
+        "label": "编辑",
+        "sortNo": 30
+      },
+      {
+        "code": "CONFIG_PERMISSION",
+        "label": "配置权限",
+        "sortNo": 40
+      },
+      {
+        "code": "DELETE",
+        "label": "删除",
+        "sortNo": 50
+      },
+      {
+        "code": "BIND_ATTACHMENT",
+        "label": "挂载附件",
+        "sortNo": 60
+      },
+      {
+        "code": "UNBIND_ATTACHMENT",
+        "label": "解除挂载",
+        "sortNo": 70
+      }
     ],
     "categoryTypes": [
-      {"code": "SCENE", "label": "场景", "sortNo": 10},
-      {"code": "STYLE", "label": "样式", "sortNo": 20},
-      {"code": "SITUATION", "label": "情况", "sortNo": 30},
-      {"code": "VERSION", "label": "版本", "sortNo": 40},
-      {"code": "OTHER", "label": "其他", "sortNo": 50}
+      {
+        "code": "SCENE",
+        "label": "场景",
+        "sortNo": 10
+      },
+      {
+        "code": "STYLE",
+        "label": "样式",
+        "sortNo": 20
+      },
+      {
+        "code": "SITUATION",
+        "label": "情况",
+        "sortNo": 30
+      },
+      {
+        "code": "VERSION",
+        "label": "版本",
+        "sortNo": 40
+      },
+      {
+        "code": "OTHER",
+        "label": "其他",
+        "sortNo": 50
+      }
     ],
     "enabledStatuses": [
-      {"code": "ENABLED", "label": "启用", "sortNo": 10},
-      {"code": "DISABLED", "label": "停用", "sortNo": 20}
+      {
+        "code": "ENABLED",
+        "label": "启用",
+        "sortNo": 10
+      },
+      {
+        "code": "DISABLED",
+        "label": "停用",
+        "sortNo": 20
+      }
     ],
     "auditResults": [
-      {"code": "SUCCESS", "label": "成功", "sortNo": 10},
-      {"code": "FAILURE", "label": "失败", "sortNo": 20}
+      {
+        "code": "SUCCESS",
+        "label": "成功",
+        "sortNo": 10
+      },
+      {
+        "code": "FAILURE",
+        "label": "失败",
+        "sortNo": 20
+      }
     ],
     "auditActions": [
-      {"code": "LOGIN", "label": "登录", "sortNo": 10},
-      {"code": "LOGOUT", "label": "退出", "sortNo": 20},
-      {"code": "VIEW_DOCUMENT", "label": "查看文档", "sortNo": 30},
-      {"code": "DOWNLOAD_DOCUMENT", "label": "下载文档", "sortNo": 40},
-      {"code": "UPLOAD_DOCUMENT", "label": "上传文档", "sortNo": 50},
-      {"code": "BATCH_IMPORT", "label": "批量导入", "sortNo": 60},
-      {"code": "EDIT_DOCUMENT", "label": "编辑文档", "sortNo": 70},
-      {"code": "DELETE_DOCUMENT", "label": "删除文档", "sortNo": 80},
-      {"code": "CREATE_CATEGORY", "label": "新建分类", "sortNo": 90},
-      {"code": "EDIT_CATEGORY", "label": "编辑分类", "sortNo": 100},
-      {"code": "DELETE_CATEGORY", "label": "删除分类", "sortNo": 110},
-      {"code": "CHANGE_PERMISSION", "label": "变更权限", "sortNo": 120},
-      {"code": "BIND_ATTACHMENT", "label": "挂载附件", "sortNo": 130},
-      {"code": "UNBIND_ATTACHMENT", "label": "解除挂载", "sortNo": 140},
-      {"code": "RESTORE_DOCUMENT", "label": "恢复文档", "sortNo": 150},
-      {"code": "CREATE_ORGANIZATION", "label": "新建机构", "sortNo": 160},
-      {"code": "EDIT_ORGANIZATION", "label": "编辑机构", "sortNo": 170},
-      {"code": "DELETE_ORGANIZATION", "label": "删除机构", "sortNo": 180},
-      {"code": "CREATE_USER", "label": "新建用户", "sortNo": 190},
-      {"code": "EDIT_USER", "label": "编辑用户", "sortNo": 200},
-      {"code": "DELETE_USER", "label": "删除用户", "sortNo": 210},
-      {"code": "RESET_USER_PASSWORD", "label": "重置用户密码", "sortNo": 220},
-      {"code": "REORDER_DOCUMENT", "label": "调整方案排序", "sortNo": 230},
-      {"code": "REORDER_ATTACHMENT", "label": "调整挂载顺序", "sortNo": 240},
-      {"code": "REORDER_CATEGORY", "label": "调整分类排序", "sortNo": 250},
-      {"code": "CREATE_ROLE", "label": "新建角色", "sortNo": 260},
-      {"code": "EDIT_ROLE", "label": "编辑角色", "sortNo": 270},
-      {"code": "DELETE_ROLE", "label": "删除角色", "sortNo": 280},
-      {"code": "CHANGE_ROLE_PERMISSION", "label": "调整角色权限", "sortNo": 290},
-      {"code": "CHANGE_ROLE_USER", "label": "调整角色用户", "sortNo": 300},
-      {"code": "DOCUMENT_PASSWORD_SET", "label": "设置方案密码", "sortNo": 310},
-      {"code": "DOCUMENT_PASSWORD_REMOVE", "label": "清除方案密码", "sortNo": 320},
-      {"code": "CREATE_IP_ENTRY", "label": "新建IP条目", "sortNo": 330},
-      {"code": "EDIT_IP_ENTRY", "label": "编辑IP条目", "sortNo": 340},
-      {"code": "DELETE_IP_ENTRY", "label": "删除IP条目", "sortNo": 350},
-      {"code": "ASSIGN_ORG_IP", "label": "分配部门IP", "sortNo": 360}
+      {
+        "code": "LOGIN",
+        "label": "登录",
+        "sortNo": 10
+      },
+      {
+        "code": "LOGOUT",
+        "label": "退出",
+        "sortNo": 20
+      },
+      {
+        "code": "VIEW_DOCUMENT",
+        "label": "查看文档",
+        "sortNo": 30
+      },
+      {
+        "code": "DOWNLOAD_DOCUMENT",
+        "label": "下载文档",
+        "sortNo": 40
+      },
+      {
+        "code": "UPLOAD_DOCUMENT",
+        "label": "上传文档",
+        "sortNo": 50
+      },
+      {
+        "code": "BATCH_IMPORT",
+        "label": "批量导入",
+        "sortNo": 60
+      },
+      {
+        "code": "EDIT_DOCUMENT",
+        "label": "编辑文档",
+        "sortNo": 70
+      },
+      {
+        "code": "DELETE_DOCUMENT",
+        "label": "删除文档",
+        "sortNo": 80
+      },
+      {
+        "code": "CREATE_CATEGORY",
+        "label": "新建分类",
+        "sortNo": 90
+      },
+      {
+        "code": "EDIT_CATEGORY",
+        "label": "编辑分类",
+        "sortNo": 100
+      },
+      {
+        "code": "DELETE_CATEGORY",
+        "label": "删除分类",
+        "sortNo": 110
+      },
+      {
+        "code": "CHANGE_PERMISSION",
+        "label": "变更权限",
+        "sortNo": 120
+      },
+      {
+        "code": "BIND_ATTACHMENT",
+        "label": "挂载附件",
+        "sortNo": 130
+      },
+      {
+        "code": "UNBIND_ATTACHMENT",
+        "label": "解除挂载",
+        "sortNo": 140
+      },
+      {
+        "code": "RESTORE_DOCUMENT",
+        "label": "恢复文档",
+        "sortNo": 150
+      },
+      {
+        "code": "CREATE_ORGANIZATION",
+        "label": "新建机构",
+        "sortNo": 160
+      },
+      {
+        "code": "EDIT_ORGANIZATION",
+        "label": "编辑机构",
+        "sortNo": 170
+      },
+      {
+        "code": "DELETE_ORGANIZATION",
+        "label": "删除机构",
+        "sortNo": 180
+      },
+      {
+        "code": "CREATE_USER",
+        "label": "新建用户",
+        "sortNo": 190
+      },
+      {
+        "code": "EDIT_USER",
+        "label": "编辑用户",
+        "sortNo": 200
+      },
+      {
+        "code": "DELETE_USER",
+        "label": "删除用户",
+        "sortNo": 210
+      },
+      {
+        "code": "RESET_USER_PASSWORD",
+        "label": "重置用户密码",
+        "sortNo": 220
+      },
+      {
+        "code": "REORDER_DOCUMENT",
+        "label": "调整方案排序",
+        "sortNo": 230
+      },
+      {
+        "code": "REORDER_ATTACHMENT",
+        "label": "调整挂载顺序",
+        "sortNo": 240
+      },
+      {
+        "code": "REORDER_CATEGORY",
+        "label": "调整分类排序",
+        "sortNo": 250
+      },
+      {
+        "code": "CREATE_ROLE",
+        "label": "新建角色",
+        "sortNo": 260
+      },
+      {
+        "code": "EDIT_ROLE",
+        "label": "编辑角色",
+        "sortNo": 270
+      },
+      {
+        "code": "DELETE_ROLE",
+        "label": "删除角色",
+        "sortNo": 280
+      },
+      {
+        "code": "CHANGE_ROLE_PERMISSION",
+        "label": "调整角色权限",
+        "sortNo": 290
+      },
+      {
+        "code": "CHANGE_ROLE_USER",
+        "label": "调整角色用户",
+        "sortNo": 300
+      },
+      {
+        "code": "DOCUMENT_PASSWORD_SET",
+        "label": "设置方案密码",
+        "sortNo": 310
+      },
+      {
+        "code": "DOCUMENT_PASSWORD_REMOVE",
+        "label": "清除方案密码",
+        "sortNo": 320
+      },
+      {
+        "code": "CREATE_IP_ENTRY",
+        "label": "新建IP条目",
+        "sortNo": 330
+      },
+      {
+        "code": "EDIT_IP_ENTRY",
+        "label": "编辑IP条目",
+        "sortNo": 340
+      },
+      {
+        "code": "DELETE_IP_ENTRY",
+        "label": "删除IP条目",
+        "sortNo": 350
+      },
+      {
+        "code": "ASSIGN_ORG_IP",
+        "label": "分配部门IP",
+        "sortNo": 360
+      }
     ],
     "auditTargets": [
-      {"code": "AUTH", "label": "认证", "sortNo": 10},
-      {"code": "CATEGORY", "label": "分类", "sortNo": 20},
-      {"code": "DOCUMENT", "label": "文档", "sortNo": 30},
-      {"code": "ATTACHMENT", "label": "共享附件", "sortNo": 40},
-      {"code": "PERMISSION", "label": "权限", "sortNo": 50},
-      {"code": "ATTACHMENT_BINDING", "label": "附件挂载关系", "sortNo": 60},
-      {"code": "ORGANIZATION", "label": "机构", "sortNo": 70},
-      {"code": "USER", "label": "用户", "sortNo": 80},
-      {"code": "ROLE", "label": "角色", "sortNo": 90},
-      {"code": "IP_ENTRY", "label": "IP条目", "sortNo": 100}
+      {
+        "code": "AUTH",
+        "label": "认证",
+        "sortNo": 10
+      },
+      {
+        "code": "CATEGORY",
+        "label": "分类",
+        "sortNo": 20
+      },
+      {
+        "code": "DOCUMENT",
+        "label": "文档",
+        "sortNo": 30
+      },
+      {
+        "code": "ATTACHMENT",
+        "label": "共享附件",
+        "sortNo": 40
+      },
+      {
+        "code": "PERMISSION",
+        "label": "权限",
+        "sortNo": 50
+      },
+      {
+        "code": "ATTACHMENT_BINDING",
+        "label": "附件挂载关系",
+        "sortNo": 60
+      },
+      {
+        "code": "ORGANIZATION",
+        "label": "机构",
+        "sortNo": 70
+      },
+      {
+        "code": "USER",
+        "label": "用户",
+        "sortNo": 80
+      },
+      {
+        "code": "ROLE",
+        "label": "角色",
+        "sortNo": 90
+      },
+      {
+        "code": "IP_ENTRY",
+        "label": "IP条目",
+        "sortNo": 100
+      }
     ]
   }
 }

+ 21 - 40
backend/dms/services/authorization_service.py

@@ -25,7 +25,6 @@ from dms.models import (
     Organization,
     Permission,
     Role,
-    RoleDepartment,
     RolePermission,
     User,
 )
@@ -197,10 +196,8 @@ def _effective_role(context: AuthContext) -> Role | None:
     )
 
 
-def _dept_scope(
-    source: Document, context: AuthContext, role: Role | None
-) -> DeptScope:
-    """按文档归属机构划分 OWN / ASSIGNED / OTHER。"""
+def _dept_scope(source: Document, context: AuthContext) -> DeptScope:
+    """按文档归属机构划分 OWN / OTHER(OTHER 涵盖所属部门子树外全部部门)。"""
     if source.organization_id is None:
         return DeptScope.OTHER
     if context.organization_id is not None:
@@ -219,16 +216,6 @@ def _dept_scope(
             own_prefix = user_path if user_path.endswith("/") else user_path + "/"
             if doc_path == user_path or doc_path.startswith(own_prefix):
                 return DeptScope.OWN
-    if role is not None:
-        assigned = db.session.scalar(
-            select(RoleDepartment.id).where(
-                RoleDepartment.role_id == role.id,
-                RoleDepartment.organization_id == source.organization_id,
-                RoleDepartment.is_deleted.is_(False),
-            )
-        )
-        if assigned is not None:
-            return DeptScope.ASSIGNED
     return DeptScope.OTHER
 
 
@@ -237,7 +224,7 @@ def _matrix_actions(source: Document, context: AuthContext) -> frozenset[str]:
     role = _effective_role(context)
     if role is None:
         return frozenset()
-    scope = _dept_scope(source, context, role)
+    scope = _dept_scope(source, context)
     row = db.session.scalar(
         select(RolePermission).where(
             RolePermission.role_id == role.id,
@@ -360,23 +347,19 @@ def plan_allowed_actions(
             AllowedAction.EDIT.value,
             AllowedAction.DELETE.value,
         ]
-    actions = [
+    # 阶段11方案B:ADMIN恒含UNBIND(与BIND对称)。原"存在附件挂载才有位"
+    # 是UI显影残留——附件表零挂载不渲染、子案挂载(0018)复用该位后反而出现
+    # "仅有子案挂载的主案管理员无法解挂"的倒挂,故移除条件(顺带消除
+    # 主案列表每行一次的count查询)。
+    return [
         AllowedAction.VIEW.value,
         AllowedAction.DOWNLOAD.value,
         AllowedAction.EDIT.value,
         AllowedAction.CONFIG_PERMISSION.value,
         AllowedAction.DELETE.value,
         AllowedAction.BIND_ATTACHMENT.value,
+        AllowedAction.UNBIND_ATTACHMENT.value,
     ]
-    binding_count = db.session.scalar(
-        select(func.count(AttachmentBinding.id)).where(
-            AttachmentBinding.main_document_id == document.id,
-            AttachmentBinding.is_deleted.is_(False),
-        )
-    )
-    if binding_count:
-        actions.append(AllowedAction.UNBIND_ATTACHMENT.value)
-    return actions
 
 
 def attachment_allowed_actions(
@@ -402,7 +385,13 @@ def evaluate_attachment_access(
     attachment: Document,
     context: AuthContext,
 ) -> tuple[bool, bool]:
-    """USER只能通过至少一个有权有效主案访问共享附件。"""
+    """USER只能通过至少一个有权有效主案访问共享附件。
+
+    阶段11:附件允许携带任意密级(0020放宽shape约束,存量子案保留原密级),
+    读路径密级前置与主案/子方案同口径——任何角色(含ADMIN)密级不足即拒绝。
+    """
+    if not _security_allowed(attachment, context):
+        return False, False
     if context.role_code == RoleCode.ADMIN:
         return True, True
     mains = db.session.scalars(
@@ -434,6 +423,9 @@ def require_attachment_access(
     *,
     download: bool,
 ) -> tuple[bool, bool]:
+    # 密级不足优先按密级拒绝(前端据此提示"密级不足"而非"权限不足")
+    if not _security_allowed(attachment, context):
+        raise SecurityLevelForbiddenError()
     can_view, can_download = evaluate_attachment_access(attachment, context)
     if download and not can_download:
         raise DocumentDownloadForbiddenError()
@@ -503,17 +495,8 @@ def has_any_manage_action(context: AuthContext) -> bool:
     if not candidates:
         return False
 
-    # 部门范围三区集合:OWN 子树 / 角色已配置部门(扣除 OWN)/ 其余(含无归属)。
+    # 部门范围两区集合:OWN 子树 / 其余(含无归属)。
     own_ids = _organization_subtree_ids(context.organization_id)
-    assigned_ids = set(
-        db.session.scalars(
-            select(RoleDepartment.organization_id).where(
-                RoleDepartment.role_id == role.id,
-                RoleDepartment.is_deleted.is_(False),
-            )
-        )
-    ) - own_ids
-    rest_ids = own_ids | assigned_ids
     ancestor_ids = _active_organization_ancestor_ids(context.organization_id)
     # ORG 主体授权对机构/自定义可见均生效;USER 主体仅自定义可见生效。
     org_subject = and_(
@@ -542,12 +525,10 @@ def has_any_manage_action(context: AuthContext) -> bool:
         scope = DeptScope(row.dept_scope)
         if scope == DeptScope.OWN:
             scope_filter = Document.organization_id.in_(own_ids)
-        elif scope == DeptScope.ASSIGNED:
-            scope_filter = Document.organization_id.in_(assigned_ids)
         else:
             scope_filter = or_(
                 Document.organization_id.is_(None),
-                Document.organization_id.not_in(rest_ids),
+                Document.organization_id.not_in(own_ids),
             )
         # 查看权三来源(与 evaluate_plan_access 同口径):
         # ① 本矩阵行勾选查看;② 全员可见;③ 文档级 ACL 授权查看。

+ 8 - 3
backend/dms/services/document_mutation_service.py

@@ -317,15 +317,20 @@ def _create_record(
         status = DocumentStatus.PUBLISHED.value
     else:
         raw_type = payload.get("documentType")
+        # 阶段11:配套方案并入共享资料库(0020),停止新建SUB_PLAN文档;
+        # 历史子案已迁移为ACCOMPANYING_PLAN附件,编辑/挂载走附件链路。
+        if raw_type == DocumentType.SUB_PLAN.value:
+            raise InvalidArgumentError(
+                "配套方案已并入共享资料库,请以附件类型ACCOMPANYING_PLAN上传"
+            )
         try:
             document_type = DocumentType(raw_type).value
         except (ValueError, TypeError) as exc:
-            raise InvalidArgumentError("documentType只允许MAIN或SUB_PLAN") from exc
+            raise InvalidArgumentError("documentType必须是MAIN") from exc
         if document_type not in {
             DocumentType.MAIN.value,
-            DocumentType.SUB_PLAN.value,
         }:
-            raise InvalidArgumentError("documentType只允许MAIN或SUB_PLAN")
+            raise InvalidArgumentError("documentType必须是MAIN")
         create_fields = (
             SUB_PLAN_CREATE_FIELDS
             if document_type == DocumentType.SUB_PLAN.value

+ 17 - 0
backend/dms/services/organization_service.py

@@ -32,6 +32,7 @@ CREATE_FIELDS = {
     "status",
 }
 UPDATE_FIELDS = {
+    "orgCode",
     "orgName",
     "parentId",
     "sortNo",
@@ -293,6 +294,7 @@ def create_organization(payload: Any) -> dict[str, object]:
 
 def update_organization(organization_id: int, payload: Any) -> dict[str, object]:
     body = _exact_payload(payload, UPDATE_FIELDS)
+    org_code = _required_text(body["orgCode"], field="orgCode", max_length=64)
     org_name = _required_text(body["orgName"], field="orgName", max_length=128)
     parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
     sort_no = _sort_no(body["sortNo"])
@@ -313,6 +315,19 @@ def update_organization(organization_id: int, payload: Any) -> dict[str, object]
                 details={"currentRowVersion": organization.row_version},
             )
 
+        # 机构编码可编辑:保存时查重(排除自身,口径与创建一致——仅比对未删除机构)
+        if (
+            session.scalar(
+                select(Organization.id)
+                .where(
+                    Organization.org_code == org_code,
+                    Organization.id != organization.id,
+                    Organization.is_deleted.is_(False),
+                )
+            )
+            is not None
+        ):
+            raise ConflictError("机构编码已存在")
         children = _children_map(organizations)
         descendants = _descendants(organization.id, children)
         descendant_ids = {item.id for item in descendants}
@@ -331,6 +346,7 @@ def update_organization(organization_id: int, payload: Any) -> dict[str, object]
                 raise InvalidArgumentError("parentId必须指向有效且已启用的机构")
 
         before = {
+            "orgCode": organization.org_code,
             "orgName": organization.org_name,
             "parentId": serialize_id(organization.parent_id),
             "orgPath": organization.org_path,
@@ -342,6 +358,7 @@ def update_organization(organization_id: int, payload: Any) -> dict[str, object]
             org_name != organization.org_name
             or parent_id != organization.parent_id
         )
+        organization.org_code = org_code
         organization.org_name = org_name
         organization.parent_id = parent_id
         organization.org_path = _org_path(parent, org_name)

+ 13 - 104
backend/dms/services/role_service.py

@@ -1,4 +1,4 @@
-"""角色 CRUD、可访问部门、权限矩阵与用户关联维护。"""
+"""角色 CRUD、权限矩阵与用户关联维护。"""
 
 from __future__ import annotations
 
@@ -26,7 +26,7 @@ from dms.common.pagination import PageRequest, page_result
 from dms.common.response import serialize_id
 from dms.database.transaction import transaction
 from dms.extensions import db
-from dms.models import Organization, Role, RoleDepartment, RolePermission, User
+from dms.models import Role, RolePermission, User
 from dms.security.auth_context import get_auth_context
 from dms.services.audit_service import business_audit
 
@@ -40,7 +40,6 @@ ROLE_UPDATE_FIELDS = {
     "isDefault",
     "rowVersion",
 }
-ROLE_DEPARTMENT_FIELDS = {"organizationIds", "rowVersion"}
 ROLE_PERMISSION_FIELDS = {"matrix", "rowVersion"}
 ROLE_USER_FIELDS = {"userIds", "rowVersion"}
 
@@ -385,18 +384,17 @@ def delete_role(role_id: int, row_version: int) -> dict[str, object]:
         role.updated_by = actor_id
         role.updated_at = now
         role.row_version += 1
-        # 部门配置与矩阵行随角色软删,保留审计追溯
-        for model in (RoleDepartment, RolePermission):
-            for row in session.scalars(
-                select(model).where(
-                    model.role_id == role_id,
-                    model.is_deleted.is_(False),
-                )
-            ).all():
-                row.is_deleted = True
-                row.deleted_at = now
-                row.updated_by = actor_id
-                row.updated_at = now
+        # 矩阵行随角色软删,保留审计追溯
+        for row in session.scalars(
+            select(RolePermission).where(
+                RolePermission.role_id == role_id,
+                RolePermission.is_deleted.is_(False),
+            )
+        ).all():
+            row.is_deleted = True
+            row.deleted_at = now
+            row.updated_by = actor_id
+            row.updated_at = now
         session.add(
             business_audit(
                 action=AuditAction.DELETE_ROLE,
@@ -409,95 +407,6 @@ def delete_role(role_id: int, row_version: int) -> dict[str, object]:
     return {"id": serialize_id(role_id), "deleted": True}
 
 
-def list_role_departments(role_id: int) -> list[str]:
-    _role_row(db.session, role_id)
-    organization_ids = db.session.scalars(
-        select(RoleDepartment.organization_id)
-        .where(
-            RoleDepartment.role_id == role_id,
-            RoleDepartment.is_deleted.is_(False),
-        )
-        .order_by(RoleDepartment.organization_id.asc())
-    ).all()
-    return [serialize_id(value) for value in organization_ids]
-
-
-def update_role_departments(role_id: int, payload: Any) -> dict[str, object]:
-    body = _exact_payload(payload, ROLE_DEPARTMENT_FIELDS)
-    organization_ids = _id_list(body["organizationIds"], field="organizationIds")
-    expected_version = _row_version(body["rowVersion"])
-    actor_id = get_auth_context().user_id
-    now = _utc_now()
-
-    with transaction() as session:
-        role = _role_row(session, role_id, for_update=True)
-        if role.row_version != expected_version:
-            raise ConflictError(
-                "数据已被其他用户修改,请刷新后重试",
-                details={"currentRowVersion": role.row_version},
-            )
-        target_ids = set(organization_ids)
-        if target_ids:
-            valid_ids = set(
-                session.scalars(
-                    select(Organization.id).where(
-                        Organization.id.in_(target_ids),
-                        Organization.is_deleted.is_(False),
-                    )
-                ).all()
-            )
-            if target_ids - valid_ids:
-                raise InvalidArgumentError("organizationIds包含无效机构")
-
-        existing_rows = session.scalars(
-            select(RoleDepartment).where(
-                RoleDepartment.role_id == role_id,
-                RoleDepartment.is_deleted.is_(False),
-            )
-        ).all()
-        existing_map = {row.organization_id: row for row in existing_rows}
-        removed, added = [], []
-        for org_id, row in existing_map.items():
-            if org_id not in target_ids:
-                row.is_deleted = True
-                row.deleted_at = now
-                row.updated_by = actor_id
-                row.updated_at = now
-                removed.append(org_id)
-        for org_id in sorted(target_ids - existing_map.keys()):
-            session.add(
-                RoleDepartment(
-                    role_id=role_id,
-                    organization_id=org_id,
-                    created_by=actor_id,
-                    updated_by=actor_id,
-                )
-            )
-            added.append(org_id)
-
-        role.updated_by = actor_id
-        role.updated_at = now
-        role.row_version += 1
-        session.add(
-            business_audit(
-                action=AuditAction.CHANGE_ROLE_PERMISSION,
-                target=AuditTarget.ROLE,
-                target_id=role_id,
-                target_name=role.role_name,
-                detail={
-                    "scope": "departments",
-                    "addedOrganizationIds": [serialize_id(v) for v in added],
-                    "removedOrganizationIds": [serialize_id(v) for v in removed],
-                },
-            )
-        )
-    return {
-        "id": serialize_id(role_id),
-        "organizationIds": [serialize_id(v) for v in sorted(target_ids)],
-        "rowVersion": role.row_version,
-    }
-
-
 def _permission_row_dto(row: RolePermission) -> dict[str, object]:
     dto: dict[str, object] = {
         "securityLevel": row.security_level,

+ 240 - 0
backend/migrations/versions/0020_sub_plan_to_attachment.py

@@ -0,0 +1,240 @@
+"""配套方案并入共享资料库:SUB_PLAN → ATTACHMENT(ACCOMPANYING_PLAN)。
+
+Revision ID: 0020_sub_plan_to_attachment
+Revises: 0019_content_extract_error
+Create Date: 2026-08-29
+
+变更要点:
+1. doc_document 附件类型CHECK扩四值:新名 ck_doc_document_attachment_type_v2
+   (旧名 DROP IF EXISTS,新名 ADD IF NOT EXISTS,按名幂等,与 Flyway V5 一致);
+2. 同名消歧:存活子案与存活附件同名、或与更小id存活子案同名者,追加"(id)"后缀;
+3. 全部 SUB_PLAN(含软删)→ ATTACHMENT + attachment_type=ACCOMPANYING_PLAN,
+   visibility 统一 ALL_AUTHENTICATED(附件可见性口径);
+4. 自有子案关系转挂载:存活子案按 parent_document_id 补 doc_attachment_binding
+   (sort_no 沿用原排序,NOT EXISTS 防重);
+5. doc_sub_plan_binding 活跃行转 doc_attachment_binding 后软删(防重跑双转);
+6. 重算全部主案 attachment_count;child_count 清零(子案不再按父子计数);
+7. 降级保护:已产生 ACCOMPANYING_PLAN 数据时拒绝有损回退。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0020_sub_plan_to_attachment"
+down_revision: str | None = "0019_content_extract_error"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_OLD_CHECK = "ck_doc_document_document_attachment_type"
+_NEW_CHECK = "ck_doc_document_attachment_type_v2"
+_NEW_CHECK_DDL = (
+    "ALTER TABLE doc_document ADD CONSTRAINT " + _NEW_CHECK + " "
+    "CHECK (attachment_type IS NULL OR attachment_type IN "
+    "('SUPPORT_ACHIEVEMENT','TRAINING_MATERIAL','TYPICAL_CASE','ACCOMPANYING_PLAN'))"
+)
+
+# 附件shape约束:放宽 security_level(配套方案保留原密级,读路径由密级前置拦截)
+_OLD_SHAPE = "ck_doc_document_document_type_shape"
+_NEW_SHAPE = "ck_doc_document_type_shape_v2"
+_NEW_SHAPE_DDL = (
+    "ALTER TABLE doc_document ADD CONSTRAINT " + _NEW_SHAPE + " CHECK ("
+    "((document_type = 'MAIN') AND (parent_document_id IS NULL) "
+    "AND (category_id IS NOT NULL) AND (attachment_type IS NULL)) "
+    "OR ((document_type = 'SUB_PLAN') AND (parent_document_id IS NOT NULL) "
+    "AND (root_document_id IS NOT NULL) AND (category_id IS NOT NULL) "
+    "AND (attachment_type IS NULL)) "
+    "OR ((document_type = 'ATTACHMENT') AND (parent_document_id IS NULL) "
+    "AND (root_document_id IS NULL) AND (category_id IS NULL) "
+    "AND (attachment_type IS NOT NULL) "
+    "AND (visibility_type = 'ALL_AUTHENTICATED')))"
+)
+
+
+def _constraint_exists(name: str) -> bool:
+    bind = op.get_bind()
+    return bool(
+        bind.scalar(
+            sa.text(
+                "SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
+                "WHERE TABLE_SCHEMA = DATABASE() "
+                "AND TABLE_NAME = 'doc_document' AND CONSTRAINT_NAME = :name"
+            ),
+            {"name": name},
+        )
+    )
+
+
+def _run(statement: str) -> None:
+    op.get_bind().execute(sa.text(statement))
+
+
+def upgrade() -> None:
+    # 1) CHECK 约束扩四值(按名幂等)
+    if _constraint_exists(_OLD_CHECK):
+        _run(f"ALTER TABLE doc_document DROP CHECK {_OLD_CHECK}")
+    if not _constraint_exists(_NEW_CHECK):
+        _run(_NEW_CHECK_DDL)
+    # 1b) shape约束放宽:附件允许任意密级(存量子案密级保留,读路径密级前置)
+    if _constraint_exists(_OLD_SHAPE):
+        _run(f"ALTER TABLE doc_document DROP CHECK {_OLD_SHAPE}")
+    if not _constraint_exists(_NEW_SHAPE):
+        _run(_NEW_SHAPE_DDL)
+
+    # 2) 同名消歧(仅存活行;后缀含id保证确定性且可重跑无冲突)。
+    #    候选id先落临时表再JOIN更新,规避 MySQL 1093(UPDATE 目标表不可出现在子查询)。
+    _run("DROP TEMPORARY TABLE IF EXISTS tmp_0020_sub_dedupe")
+    _run(
+        "CREATE TEMPORARY TABLE tmp_0020_sub_dedupe (id BIGINT PRIMARY KEY)"
+    )
+    _run(
+        """
+        INSERT INTO tmp_0020_sub_dedupe (id)
+        SELECT a.id FROM doc_document a
+        WHERE a.document_type = 'SUB_PLAN' AND a.is_deleted = 0 AND (
+            EXISTS (
+                SELECT 1 FROM doc_document b
+                WHERE b.is_deleted = 0 AND b.document_type = 'ATTACHMENT'
+                  AND b.document_name = a.document_name
+            )
+            OR EXISTS (
+                SELECT 1 FROM doc_document c
+                WHERE c.document_type = 'SUB_PLAN'
+                  AND c.document_name = a.document_name AND c.id < a.id
+            )
+        )
+        """
+    )
+    _run(
+        """
+        UPDATE doc_document d JOIN tmp_0020_sub_dedupe t ON d.id = t.id
+        SET d.document_name = CONCAT(d.document_name, '(', d.id, ')')
+        """
+    )
+    _run("DROP TEMPORARY TABLE IF EXISTS tmp_0020_sub_dedupe")
+
+    # 3) 自有子案关系先转挂载(需 parent_document_id,父主案存活的存活子案)
+    _run(
+        """
+        INSERT INTO doc_attachment_binding
+            (main_document_id, attachment_document_id, sort_no, created_by, updated_by)
+        SELECT d.parent_document_id, d.id, d.sort_no, d.created_by, d.updated_by
+        FROM doc_document d
+        WHERE d.document_type = 'SUB_PLAN'
+          AND d.is_deleted = 0
+          AND d.parent_document_id IS NOT NULL
+          AND EXISTS (
+              SELECT 1 FROM doc_document m
+              WHERE m.id = d.parent_document_id AND m.is_deleted = 0
+          )
+          AND NOT EXISTS (
+              SELECT 1 FROM doc_attachment_binding b
+              WHERE b.main_document_id = d.parent_document_id
+                AND b.attachment_document_id = d.id AND b.is_deleted = 0
+          )
+        """
+    )
+
+    # 4) 子案跨主案挂载关系转附件挂载后软删(防重跑双转)
+    _run(
+        """
+        INSERT INTO doc_attachment_binding
+            (main_document_id, attachment_document_id, sort_no, created_by, updated_by)
+        SELECT s.main_document_id, s.sub_document_id, s.sort_no, s.created_by, s.updated_by
+        FROM doc_sub_plan_binding s
+        WHERE s.is_deleted = 0
+          AND NOT EXISTS (
+              SELECT 1 FROM doc_attachment_binding b
+              WHERE b.main_document_id = s.main_document_id
+                AND b.attachment_document_id = s.sub_document_id AND b.is_deleted = 0
+          )
+        """
+    )
+    _run(
+        """
+        UPDATE doc_sub_plan_binding
+        SET is_deleted = 1, deleted_at = NOW(3), updated_at = NOW(3),
+            row_version = row_version + 1
+        WHERE is_deleted = 0
+        """
+    )
+
+    # 5) 类型转换(全部 SUB_PLAN,含软删,重跑自然无目标行):
+    #    置空父/根/分类(附件shape),保留密级(读路径密级前置拦截)
+    _run(
+        """
+        UPDATE doc_document
+        SET document_type = 'ATTACHMENT',
+            attachment_type = 'ACCOMPANYING_PLAN',
+            visibility_type = 'ALL_AUTHENTICATED',
+            parent_document_id = NULL,
+            root_document_id = NULL,
+            category_id = NULL,
+            category_name = NULL,
+            category_path = NULL
+        WHERE document_type = 'SUB_PLAN'
+        """
+    )
+
+    # 6) 计数重算与清零
+    _run(
+        """
+        UPDATE doc_document m
+        SET m.attachment_count = (
+            SELECT COUNT(*) FROM doc_attachment_binding b
+            WHERE b.main_document_id = m.id AND b.is_deleted = 0
+        )
+        WHERE m.document_type = 'MAIN'
+          AND m.attachment_count <> (
+            SELECT COUNT(*) FROM doc_attachment_binding b
+            WHERE b.main_document_id = m.id AND b.is_deleted = 0
+          )
+        """
+    )
+    _run(
+        "UPDATE doc_document SET child_count = 0 "
+        "WHERE document_type = 'MAIN' AND child_count <> 0"
+    )
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    converted = bind.scalar(
+        sa.text(
+            "SELECT COUNT(*) FROM doc_document "
+            "WHERE attachment_type = 'ACCOMPANYING_PLAN'"
+        )
+    )
+    if converted:
+        raise RuntimeError(
+            "0020已产生配套方案(ACCOMPANYING_PLAN)数据,拒绝有损降级;"
+            "请先备份并按数据治理流程处理"
+        )
+    if _constraint_exists(_NEW_CHECK):
+        _run(f"ALTER TABLE doc_document DROP CHECK {_NEW_CHECK}")
+    if not _constraint_exists(_OLD_CHECK):
+        _run(
+            "ALTER TABLE doc_document ADD CONSTRAINT " + _OLD_CHECK + " "
+            "CHECK (attachment_type IS NULL OR attachment_type IN "
+            "('SUPPORT_ACHIEVEMENT','TRAINING_MATERIAL','TYPICAL_CASE'))"
+        )
+    # shape约束回旧版(附件强制PUBLIC密级);已拒绝并轨数据,安全恢复
+    if _constraint_exists(_NEW_SHAPE):
+        _run(f"ALTER TABLE doc_document DROP CHECK {_NEW_SHAPE}")
+    if not _constraint_exists(_OLD_SHAPE):
+        _run(
+            "ALTER TABLE doc_document ADD CONSTRAINT " + _OLD_SHAPE + " "
+            "CHECK (((document_type = 'MAIN') AND (parent_document_id IS NULL) "
+            "AND (category_id IS NOT NULL) AND (attachment_type IS NULL)) "
+            "OR ((document_type = 'SUB_PLAN') AND (parent_document_id IS NOT NULL) "
+            "AND (root_document_id IS NOT NULL) AND (category_id IS NOT NULL) "
+            "AND (attachment_type IS NULL)) "
+            "OR ((document_type = 'ATTACHMENT') AND (parent_document_id IS NULL) "
+            "AND (root_document_id IS NULL) AND (category_id IS NULL) "
+            "AND (attachment_type IS NOT NULL) "
+            "AND (security_level = 'PUBLIC') "
+            "AND (visibility_type = 'ALL_AUTHENTICATED')))"
+        )

+ 194 - 0
backend/migrations/versions/0021_remove_assigned_scope.py

@@ -0,0 +1,194 @@
+"""移除"可访问部门"维度:ASSIGNED 矩阵行删除 + sys_role_dept 删表。
+
+Revision ID: 0021_remove_assigned_scope
+Revises: 0020_sub_plan_to_attachment
+Create Date: 2026-08-31
+
+变更要点:
+1. 部门范围由 OWN/ASSIGNED/OTHER 收敛为 OWN/OTHER,"其他部门"涵盖
+   所属部门子树之外的全部部门(含无归属文档);
+2. sys_role_permission 中全部 dept_scope='ASSIGNED' 行物理删除(含软删行),
+   存量"可访问部门权限"数据按需求清除;
+3. dept_scope CHECK 约束换名收敛:旧名 ck_sys_role_permission_role_permission_dept_scope
+   DROP IF EXISTS,新名 ck_sys_role_permission_dept_scope_v2 ADD IF NOT EXISTS
+   (按名幂等,与 Flyway V6 一致);
+4. sys_role_dept 角色可访问部门表 DROP IF EXISTS;
+5. 降级:重建 sys_role_dept 空表、恢复旧 CHECK,并为存活角色补种
+   ASSIGNED 默认全否行以还原 15 行矩阵不变量(原有勾选数据不可恢复)。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import mysql
+
+revision: str = "0021_remove_assigned_scope"
+down_revision: str | None = "0020_sub_plan_to_attachment"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_OLD_CHECK = "ck_sys_role_permission_role_permission_dept_scope"
+_NEW_CHECK = "ck_sys_role_permission_dept_scope_v2"
+_NEW_CHECK_DDL = (
+    "ALTER TABLE sys_role_permission ADD CONSTRAINT " + _NEW_CHECK + " "
+    "CHECK (dept_scope IN ('OWN', 'OTHER'))"
+)
+_OLD_CHECK_DDL = (
+    "ALTER TABLE sys_role_permission ADD CONSTRAINT " + _OLD_CHECK + " "
+    "CHECK (dept_scope IN ('OWN', 'ASSIGNED', 'OTHER'))"
+)
+
+
+def _constraint_exists(name: str) -> bool:
+    bind = op.get_bind()
+    return bool(
+        bind.scalar(
+            sa.text(
+                "SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
+                "WHERE TABLE_SCHEMA = DATABASE() "
+                "AND TABLE_NAME = 'sys_role_permission' AND CONSTRAINT_NAME = :name"
+            ),
+            {"name": name},
+        )
+    )
+
+
+def _table_exists(name: str) -> bool:
+    bind = op.get_bind()
+    return bool(
+        bind.scalar(
+            sa.text(
+                "SELECT COUNT(*) FROM information_schema.TABLES "
+                "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :name"
+            ),
+            {"name": name},
+        )
+    )
+
+
+def _run(statement: str) -> None:
+    op.get_bind().execute(sa.text(statement))
+
+
+def _create_role_dept_table() -> None:
+    op.create_table(
+        "sys_role_dept",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("created_by", sa.BigInteger(), nullable=True),
+        sa.Column("updated_by", sa.BigInteger(), nullable=True),
+        sa.Column(
+            "created_at",
+            mysql.DATETIME(fsp=3),
+            server_default=sa.text("CURRENT_TIMESTAMP(3)"),
+            nullable=False,
+        ),
+        sa.Column(
+            "updated_at",
+            mysql.DATETIME(fsp=3),
+            server_default=sa.text("CURRENT_TIMESTAMP(3)"),
+            nullable=False,
+        ),
+        sa.Column("deleted_at", mysql.DATETIME(fsp=3), nullable=True),
+        sa.Column(
+            "is_deleted",
+            sa.Boolean(),
+            server_default=sa.text("0"),
+            nullable=False,
+        ),
+        sa.Column(
+            "row_version",
+            sa.Integer(),
+            server_default=sa.text("0"),
+            nullable=False,
+        ),
+        sa.Column("role_id", sa.BigInteger(), nullable=False),
+        sa.Column("organization_id", sa.BigInteger(), nullable=False),
+        sa.Column(
+            "active_marker",
+            sa.Integer(),
+            sa.Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),
+            nullable=True,
+        ),
+        sa.ForeignKeyConstraint(
+            ["role_id"],
+            ["sys_role.id"],
+            name="fk_sys_role_dept_role_id_sys_role",
+            ondelete="RESTRICT",
+        ),
+        sa.ForeignKeyConstraint(
+            ["organization_id"],
+            ["sys_organization.id"],
+            name="fk_sys_role_dept_organization_id_sys_organization",
+            ondelete="RESTRICT",
+        ),
+        sa.UniqueConstraint(
+            "role_id",
+            "organization_id",
+            "active_marker",
+            name="uq_sys_role_dept_active",
+        ),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index(
+        "ix_sys_role_dept_role_active",
+        "sys_role_dept",
+        ["role_id", "is_deleted"],
+    )
+    op.create_index(
+        "ix_sys_role_dept_org_active",
+        "sys_role_dept",
+        ["organization_id", "is_deleted"],
+    )
+
+
+def upgrade() -> None:
+    # 1) 存量 ASSIGNED 权限行物理删除(含软删行;新 CHECK 前置条件)
+    _run("DELETE FROM sys_role_permission WHERE dept_scope = 'ASSIGNED'")
+
+    # 2) CHECK 约束收敛为 OWN/OTHER(按名幂等)
+    if _constraint_exists(_OLD_CHECK):
+        _run(f"ALTER TABLE sys_role_permission DROP CHECK {_OLD_CHECK}")
+    if not _constraint_exists(_NEW_CHECK):
+        _run(_NEW_CHECK_DDL)
+
+    # 3) 角色可访问部门表下线
+    _run("DROP TABLE IF EXISTS sys_role_dept")
+
+
+def downgrade() -> None:
+    # 1) 恢复旧 CHECK(先删新名,避免取值集合冲突)
+    if _constraint_exists(_NEW_CHECK):
+        _run(f"ALTER TABLE sys_role_permission DROP CHECK {_NEW_CHECK}")
+    if not _constraint_exists(_OLD_CHECK):
+        _run(_OLD_CHECK_DDL)
+
+    # 2) 重建角色可访问部门空表(原数据已在升级时删除,不可恢复)
+    if not _table_exists("sys_role_dept"):
+        _create_role_dept_table()
+
+    # 3) 为存活角色补种 ASSIGNED 默认全否行,还原"每角色 15 行"不变量
+    _run(
+        """
+        INSERT INTO sys_role_permission
+            (role_id, security_level, dept_scope, created_by, updated_by)
+        SELECT r.id, lv.security_level, 'ASSIGNED', r.created_by, r.updated_by
+        FROM sys_role r
+        JOIN (
+            SELECT 'PUBLIC' AS security_level
+            UNION ALL SELECT 'INTERNAL'
+            UNION ALL SELECT 'SECRET'
+            UNION ALL SELECT 'CONFIDENTIAL'
+            UNION ALL SELECT 'TOP_SECRET'
+        ) lv
+        WHERE r.is_deleted = 0
+          AND NOT EXISTS (
+              SELECT 1 FROM sys_role_permission p
+              WHERE p.role_id = r.id
+                AND p.security_level = lv.security_level
+                AND p.dept_scope = 'ASSIGNED'
+          )
+        """
+    )

+ 2 - 48
backend/openapi/components/schemas.yaml

@@ -1994,8 +1994,8 @@ RuntimeConfigResponse:
 
 DeptScope:
   type: string
-  description: 权限矩阵部门范围:所属部门方案/可访问部门方案/其他部门方案。
-  enum: [OWN, ASSIGNED, OTHER]
+  description: 权限矩阵部门范围:所属部门方案/其他部门方案(其他部门含所属部门子树外全部部门与无归属文档)。
+  enum: [OWN, OTHER]
 
 RoleSummary:
   type: object
@@ -2104,52 +2104,6 @@ DeleteRoleResponse:
     data: {$ref: '#/DeleteRoleData'}
     requestId: {type: string, format: uuid}
 
-RoleDepartmentListResponse:
-  type: object
-  additionalProperties: false
-  required: [code, message, data, requestId]
-  properties:
-    code: {type: string, enum: [OK]}
-    message: {type: string, enum: [success]}
-    data:
-      type: array
-      items:
-        $ref: '#/StringId'
-    requestId: {type: string, format: uuid}
-
-UpdateRoleDepartmentsRequest:
-  type: object
-  additionalProperties: false
-  required: [organizationIds, rowVersion]
-  properties:
-    organizationIds:
-      type: array
-      items: {$ref: '#/StringId'}
-      uniqueItems: true
-    rowVersion: {type: integer, minimum: 0}
-
-RoleDepartmentsData:
-  type: object
-  additionalProperties: false
-  required: [id, organizationIds, rowVersion]
-  properties:
-    id: {$ref: '#/StringId'}
-    organizationIds:
-      type: array
-      items: {$ref: '#/StringId'}
-      uniqueItems: true
-    rowVersion: {type: integer, minimum: 0}
-
-RoleDepartmentsResponse:
-  type: object
-  additionalProperties: false
-  required: [code, message, data, requestId]
-  properties:
-    code: {type: string, enum: [OK]}
-    message: {type: string, enum: [success]}
-    data: {$ref: '#/RoleDepartmentsData'}
-    requestId: {type: string, format: uuid}
-
 RolePermissionRow:
   type: object
   additionalProperties: false

+ 1 - 9
backend/openapi/openapi.yaml

@@ -1,7 +1,7 @@
 openapi: 3.0.3
 info:
   title: 方案计划文档管理系统 API
-  version: 1.13.0
+  version: 1.16.0
   description: DMS第一阶段接口;Q3-B正文检索、原文件预览、1.6 LibreOffice离线文档转换与PDF预览、1.7 机构用户维护与详细说明、1.8 角色管理与权限矩阵、1.9 方案关联关系、1.10 部门IP登录限制、1.11 统一门户跳转登录与子案挂载、1.12 正文提取状态可见与重新解析、1.13 登录界面自定义背景图。
 servers:
   - url: /
@@ -50,8 +50,6 @@ paths:
     $ref: './paths/roles.yaml#/Roles'
   /api/v1/roles/{id}:
     $ref: './paths/roles.yaml#/Role'
-  /api/v1/roles/{id}/departments:
-    $ref: './paths/roles.yaml#/RoleDepartments'
   /api/v1/roles/{id}/permissions:
     $ref: './paths/roles.yaml#/RolePermissions'
   /api/v1/roles/{id}/users:
@@ -281,12 +279,6 @@ components:
       $ref: './components/schemas.yaml#/RoleResponse'
     DeleteRoleResponse:
       $ref: './components/schemas.yaml#/DeleteRoleResponse'
-    RoleDepartmentListResponse:
-      $ref: './components/schemas.yaml#/RoleDepartmentListResponse'
-    UpdateRoleDepartmentsRequest:
-      $ref: './components/schemas.yaml#/UpdateRoleDepartmentsRequest'
-    RoleDepartmentsResponse:
-      $ref: './components/schemas.yaml#/RoleDepartmentsResponse'
     RolePermissionRow:
       $ref: './components/schemas.yaml#/RolePermissionRow'
     RoleMatrixListResponse:

+ 2 - 60
backend/openapi/paths/roles.yaml

@@ -42,7 +42,7 @@ Roles:
         $ref: '../components/responses.yaml#/InternalError'
   post:
     operationId: createRole
-    summary: 创建角色(同时初始化15行权限矩阵)
+    summary: 创建角色(同时初始化10行权限矩阵)
     parameters:
       - $ref: '../components/parameters.yaml#/XRequestId'
     requestBody:
@@ -132,64 +132,6 @@ Role:
       '500':
         $ref: '../components/responses.yaml#/InternalError'
 
-RoleDepartments:
-  parameters:
-    - name: id
-      in: path
-      required: true
-      schema:
-        $ref: '../components/schemas.yaml#/StringId'
-  get:
-    operationId: listRoleDepartments
-    summary: 查询角色可访问部门ID列表
-    parameters:
-      - $ref: '../components/parameters.yaml#/XRequestId'
-    responses:
-      '200':
-        description: 部门ID列表
-        content:
-          application/json:
-            schema:
-              $ref: '../components/schemas.yaml#/RoleDepartmentListResponse'
-      '401':
-        $ref: '../components/responses.yaml#/AuthenticationError'
-      '403':
-        $ref: '../components/responses.yaml#/Forbidden'
-      '404':
-        $ref: '../components/responses.yaml#/NotFound'
-      '500':
-        $ref: '../components/responses.yaml#/InternalError'
-  put:
-    operationId: updateRoleDepartments
-    summary: 整存角色可访问部门列表
-    parameters:
-      - $ref: '../components/parameters.yaml#/XRequestId'
-    requestBody:
-      required: true
-      content:
-        application/json:
-          schema:
-            $ref: '../components/schemas.yaml#/UpdateRoleDepartmentsRequest'
-    responses:
-      '200':
-        description: 保存成功
-        content:
-          application/json:
-            schema:
-              $ref: '../components/schemas.yaml#/RoleDepartmentsResponse'
-      '400':
-        $ref: '../components/responses.yaml#/InvalidArgument'
-      '401':
-        $ref: '../components/responses.yaml#/AuthenticationError'
-      '403':
-        $ref: '../components/responses.yaml#/Forbidden'
-      '404':
-        $ref: '../components/responses.yaml#/NotFound'
-      '409':
-        $ref: '../components/responses.yaml#/Conflict'
-      '500':
-        $ref: '../components/responses.yaml#/InternalError'
-
 RolePermissions:
   parameters:
     - name: id
@@ -199,7 +141,7 @@ RolePermissions:
         $ref: '../components/schemas.yaml#/StringId'
   get:
     operationId: listRolePermissions
-    summary: 查询角色权限矩阵(15行)
+    summary: 查询角色权限矩阵(10行)
     parameters:
       - $ref: '../components/parameters.yaml#/XRequestId'
     responses:

+ 10 - 6
backend/tests/dms/conftest.py

@@ -17,6 +17,13 @@ if str(BACKEND_ROOT) not in sys.path:
 
 @pytest.fixture(scope="session")
 def flask_app():
+    # 本组用例不真正连库,仅需 SQLAlchemy 可解析的方言;达梦 dmPython
+    # 属部署期驱动(测试环境未安装),默认 DM 链接会导致引擎创建失败,
+    # 故未显式配置 DMS_DATABASE_URL 时固定注入 MySQL 占位链接。
+    os.environ.setdefault(
+        "DMS_DATABASE_URL",
+        "mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4",
+    )
     from app import app
 
     app.config.update(TESTING=True)
@@ -53,7 +60,6 @@ def b2_app():
         Organization,
         Permission,
         Role,
-        RoleDepartment,
         RolePermission,
         SubPlanBinding,
         User,
@@ -71,11 +77,10 @@ def b2_app():
 
     with app.app_context():
         db.session.execute(delete(AuditLog))
-        # 角色三表:先解除用户 role_id 引用,避免外键残留(角色测试
+        # 角色两表:先解除用户 role_id 引用,避免外键残留(角色测试
         # 结束后 role_cleanup 无 teardown,残行会卡死机构清理)
         db.session.execute(update(User).values(role_id=None))
         db.session.execute(delete(RolePermission))
-        db.session.execute(delete(RoleDepartment))
         db.session.execute(delete(Role))
         db.session.execute(delete(SubPlanBinding))
         db.session.execute(delete(DocumentRelation))
@@ -228,16 +233,15 @@ def token_for(b2_client, b2_password):
 
 @pytest.fixture()
 def role_cleanup(b2_app):
-    """清空角色三表并解除用户角色关联,供角色管理/矩阵判定测试隔离。"""
+    """清空角色两表并解除用户角色关联,供角色管理/矩阵判定测试隔离。"""
     from sqlalchemy import delete, update
 
     from dms.extensions import db
-    from dms.models import Role, RoleDepartment, RolePermission, User
+    from dms.models import Role, RolePermission, User
 
     with b2_app.app_context():
         db.session.execute(update(User).values(role_id=None))
         db.session.execute(delete(RolePermission))
-        db.session.execute(delete(RoleDepartment))
         db.session.execute(delete(Role))
         db.session.commit()
 

+ 17 - 5
backend/tests/dms/test_config.py

@@ -11,7 +11,7 @@ from dms.config import (
 )
 
 
-def test_default_config_uses_mysql_read_committed(monkeypatch):
+def test_default_config_uses_dm_dialect_read_committed(monkeypatch):
     for name in (
         "DMS_DATABASE_URL",
         "DMS_STORAGE_ROOT",
@@ -25,11 +25,10 @@ def test_default_config_uses_mysql_read_committed(monkeypatch):
 
     config = load_dms_config()
 
-    assert config["SQLALCHEMY_DATABASE_URI"].startswith("mysql+pymysql://")
+    assert config["SQLALCHEMY_DATABASE_URI"].startswith("dm+dmPython://")
     assert config["SQLALCHEMY_ENGINE_OPTIONS"]["isolation_level"] == "READ COMMITTED"
-    assert config["SQLALCHEMY_ENGINE_OPTIONS"]["connect_args"] == {
-        "init_command": "SET time_zone = '+00:00'"
-    }
+    # init_command 为 pymysql 专属参数,达梦默认链接不下发
+    assert config["SQLALCHEMY_ENGINE_OPTIONS"]["connect_args"] == {}
     assert config["DMS_STORAGE_ROOT"] == str(DEFAULT_STORAGE_ROOT.resolve())
     assert config["MAX_CONTENT_LENGTH"] == config["DMS_MAX_UPLOAD_SIZE"]
     assert config["DMS_BATCH_MAX_FILES"] == 50
@@ -40,6 +39,19 @@ def test_default_config_uses_mysql_read_committed(monkeypatch):
     )
 
 
+def test_mysql_url_still_applies_pymysql_session_init(monkeypatch):
+    """回切 MySQL 链接时仍下发 SET time_zone 会话初始化(向后兼容)。"""
+    monkeypatch.delenv("DMS_DATABASE_URL", raising=False)
+    monkeypatch.setenv(
+        "DMS_DATABASE_URL",
+        "mysql+pymysql://example:example@db.invalid:3306/dms?charset=utf8mb4",
+    )
+    config = load_dms_config()
+    assert config["SQLALCHEMY_ENGINE_OPTIONS"]["connect_args"] == {
+        "init_command": "SET time_zone = '+00:00'"
+    }
+
+
 def test_environment_overrides_are_loaded(monkeypatch, tmp_path: Path):
     monkeypatch.setenv(
         "DMS_DATABASE_URL",

+ 8 - 4
backend/tests/dms/test_db_upgrade.py

@@ -18,9 +18,9 @@ def test_resolve_migrations_dir_contains_env_and_versions():
 
 
 def test_migration_chain_head_and_steps_from_0013():
-    """远端生产库 0013 → head 恰好经过 0014~0019,upgrade 不含数据删除。"""
+    """远端生产库 0013 → head 恰好经过 0014~0021。"""
     script = _script()
-    assert script.get_current_head() == "0019_content_extract_error"
+    assert script.get_current_head() == "0021_remove_assigned_scope"
     steps = [
         r.revision
         for r in reversed(
@@ -35,13 +35,15 @@ def test_migration_chain_head_and_steps_from_0013():
         "0017_category_type_display_name",
         "0018_sub_plan_binding",
         "0019_content_extract_error",
+        "0020_sub_plan_to_attachment",
+        "0021_remove_assigned_scope",
     ]
 
 
 def test_run_upgrade_lists_pending_steps_excluding_current(monkeypatch, capsys):
-    """0016 升级到 head:待执行清单只含 0017/0018/0019,当前版本不重复出现。"""
+    """0016 升级到 head:待执行清单含 0017~0021,当前版本不重复出现。"""
     monkeypatch.setenv("DMS_DATABASE_URL", "mysql+pymysql://u:secret@127.0.0.1:3306/dms")
-    versions = iter(["0016_portal_user_provision", "0019_content_extract_error"])
+    versions = iter(["0016_portal_user_provision", "0021_remove_assigned_scope"])
     monkeypatch.setattr(
         "dms.db_upgrade._read_current_version", lambda url: next(versions)
     )
@@ -51,6 +53,8 @@ def test_run_upgrade_lists_pending_steps_excluding_current(monkeypatch, capsys):
     assert "  - 0017_category_type_display_name" in out
     assert "  - 0018_sub_plan_binding" in out
     assert "  - 0019_content_extract_error" in out
+    assert "  - 0020_sub_plan_to_attachment" in out
+    assert "  - 0021_remove_assigned_scope" in out
     assert "  - 0016" not in out
     assert "secret" not in out  # 输出中的地址必须已脱敏
 

+ 0 - 1
backend/tests/dms/test_http.py

@@ -61,7 +61,6 @@ def test_only_q2b_dms_routes_are_registered(flask_app):
         "/api/v1/roles",
         "/api/v1/roles/<id>",
         "/api/v1/roles/<id>/permissions",
-        "/api/v1/roles/<id>/departments",
         "/api/v1/roles/<id>/users",
         "/api/v1/ip-entries",
         "/api/v1/ip-entries/<id>",

+ 22 - 1
backend/tests/dms/test_migration.py

@@ -53,6 +53,9 @@ SUB_PLAN_BINDING_REVISION_PATH = (
 CONTENT_EXTRACT_ERROR_REVISION_PATH = (
     MIGRATIONS_ROOT / "versions" / "0019_content_extract_error.py"
 )
+REMOVE_ASSIGNED_SCOPE_REVISION_PATH = (
+    MIGRATIONS_ROOT / "versions" / "0021_remove_assigned_scope.py"
+)
 
 
 def _load_revision_module():
@@ -66,7 +69,7 @@ def _load_revision_module():
 def test_alembic_config_and_head_are_valid():
     config = Config(str(MIGRATIONS_ROOT / "alembic.ini"))
     script = ScriptDirectory.from_config(config)
-    assert script.get_current_head() == "0019_content_extract_error"
+    assert script.get_current_head() == "0021_remove_assigned_scope"
 
 
 def test_initial_migration_exports_upgrade_and_downgrade():
@@ -342,3 +345,21 @@ def test_content_extract_error_migration_is_scoped_and_idempotent():
     # 降级保护:已产生失败记录时拒绝有损降级
     assert "RuntimeError" in source
     assert "IS NOT NULL" in source
+
+
+def test_remove_assigned_scope_migration_is_scoped_and_idempotent():
+    source = REMOVE_ASSIGNED_SCOPE_REVISION_PATH.read_text(encoding="utf-8")
+    assert (
+        'down_revision: str | None = "0020_sub_plan_to_attachment"' in source
+    )
+    # 存量 ASSIGNED 行物理删除(含软删行)
+    assert "DELETE FROM sys_role_permission WHERE dept_scope = 'ASSIGNED'" in source
+    # CHECK 约束按名幂等换名收敛:旧删新加
+    assert "ck_sys_role_permission_role_permission_dept_scope" in source
+    assert "ck_sys_role_permission_dept_scope_v2" in source
+    assert "dept_scope IN ('OWN', 'OTHER')" in source
+    # 角色可访问部门表下线
+    assert "DROP TABLE IF EXISTS sys_role_dept" in source
+    # 降级:重建 sys_role_dept 空表并补种 ASSIGNED 默认全否行
+    assert 'op.create_table(\n        "sys_role_dept"' in source
+    assert "'ASSIGNED'" in source

+ 1 - 6
backend/tests/dms/test_models.py

@@ -27,7 +27,6 @@ EXPECTED_TABLES = {
     "doc_permission",
     "sys_audit_log",
     "sys_role",
-    "sys_role_dept",
     "sys_role_permission",
     "sys_ip_entry",
     "sys_org_ip_assignment",
@@ -47,7 +46,7 @@ COMMON_COLUMNS = {
 
 def test_expected_models_are_registered():
     assert set(db.metadata.tables) == EXPECTED_TABLES
-    assert len(db.metadata.tables) == 14
+    assert len(db.metadata.tables) == 13
 
 
 def test_business_tables_have_common_columns():
@@ -82,7 +81,6 @@ def test_active_marker_columns_are_virtual_generated_columns():
         "doc_attachment_binding",
         "doc_permission",
         "sys_role",
-        "sys_role_dept",
         "sys_role_permission",
         "sys_ip_entry",
         "sys_org_ip_assignment",
@@ -133,9 +131,6 @@ def test_active_permission_unique_constraint():
 def test_role_tables_unique_constraints():
     assert ("role_name", "active_marker") in _unique_column_sets("sys_role")
     assert ("default_marker",) in _unique_column_sets("sys_role")
-    assert ("role_id", "organization_id", "active_marker") in _unique_column_sets(
-        "sys_role_dept",
-    )
     assert (
         "role_id",
         "security_level",

+ 1 - 2
backend/tests/dms/test_openapi_storage.py

@@ -100,7 +100,6 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/users/{id}/reset-password",
         "/api/v1/roles",
         "/api/v1/roles/{id}",
-        "/api/v1/roles/{id}/departments",
         "/api/v1/roles/{id}/permissions",
         "/api/v1/roles/{id}/users",
         "/api/v1/config/ui-dictionaries",
@@ -142,7 +141,7 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/recycle-bin/documents",
         "/api/v1/recycle-bin/documents/{id}/restore",
     }
-    assert document["info"]["version"] == "1.13.0"
+    assert document["info"]["version"] == "1.16.0"
     assert "/api/v1/documents/{id}/restore" not in document["paths"]
     assert document["paths"]["/api/v1/health"]["get"]["security"] == []
     login_path = yaml.safe_load(

+ 1 - 1
backend/tests/dms/test_q1b_ui_dictionaries.py

@@ -284,7 +284,7 @@ def test_openapi_ui_dictionary_contract_and_no_write_operation():
         )
     )
 
-    assert document["info"]["version"] == "1.13.0"
+    assert document["info"]["version"] == "1.16.0"
     assert set(path_item) == {"get"}
     assert path_item["get"]["operationId"] == "getUiDictionaries"
     assert {"200", "401", "500"} <= set(path_item["get"]["responses"])

+ 1 - 1
backend/tests/dms/test_q2b_updated_range.py

@@ -344,7 +344,7 @@ def test_openapi_declares_q2b_parameters_without_new_paths():
         (root / "paths" / "documents.yaml").read_text(encoding="utf-8")
     )
 
-    assert document["info"]["version"] == "1.13.0"
+    assert document["info"]["version"] == "1.16.0"
     for operation in ("Documents", "SubPlans", "MainPlanAttachments"):
         references = {
             parameter.get("$ref")

+ 1 - 1
backend/tests/dms/test_q3b_business_content_preview.py

@@ -461,7 +461,7 @@ def test_q3_openapi_contract_has_no_new_preview_path():
     paths = yaml.safe_load(
         (root / "paths" / "documents.yaml").read_text("utf-8")
     )
-    assert document["info"]["version"] == "1.13.0"
+    assert document["info"]["version"] == "1.16.0"
     assert schemas["RoleCode"]["enum"] == ["USER", "ADMIN"]
     parameters = paths["Documents"]["get"]["parameters"]
     assert any(item.get("name") == "includeDescendants" for item in parameters)

+ 6 - 14
backend/tests/dms/test_role_authorization.py

@@ -1,7 +1,7 @@
 """需求16 矩阵判定融合测试(authorization_service)。
 
 覆盖:
-- OWN/ASSIGNED/OTHER 部门三分类矩阵判定
+- OWN/OTHER 部门两分类矩阵判定(OTHER 涵盖所属部门子树外全部部门)
 - 矩阵与单文档 ACL 的 OR 叠加
 - 密级前置与状态前置不可被矩阵绕过
 - 无角色→默认角色→无默认则贡献为空
@@ -154,22 +154,14 @@ def test_other_scope_without_matrix_denies(matrix_env):
 
 
 @pytest.mark.integration
-def test_assigned_scope_via_role_departments(matrix_env):
-    from dms.models import RoleDepartment
-
+def test_other_scope_matrix_grants_cross_department_view(matrix_env):
     app, ids = matrix_env
     with app.app_context():
         comms = db.session.get(Organization, ids["comms"])
         user = db.session.get(User, ids["user"])
         role = _make_role("跨部门角色", actor_id=user.id)
-        _matrix_row(role.id, "SECRET", "ASSIGNED", can_view=True)
-        db.session.add(
-            RoleDepartment(
-                role_id=role.id,
-                organization_id=comms.id,
-            )
-        )
-        doc = _make_document("矩阵已配置部门主案", org=comms, visibility="ORGANIZATION")
+        _matrix_row(role.id, "SECRET", "OTHER", can_view=True)
+        doc = _make_document("矩阵其他部门主案", org=comms, visibility="ORGANIZATION")
         db.session.commit()
 
         context = _user_context(user)
@@ -309,7 +301,7 @@ def test_user_without_manage_bits_gets_browser_only(matrix_env):
         user = db.session.get(User, ids["user"])
         role = _make_role("只读角色", is_default=True, actor_id=user.id)
         # 矩阵只有查看/下载位,无任何管理位
-        for scope in ("OWN", "ASSIGNED", "OTHER"):
+        for scope in ("OWN", "OTHER"):
             _matrix_row(
                 role.id, "SECRET", scope, can_view=True, can_download=True
             )
@@ -477,7 +469,7 @@ def test_require_plan_action_attachment_admin_only(matrix_env):
             db.select(User).where(User.username == "admin")
         )
         role = _make_role("附件全权角色", is_default=True, actor_id=user.id)
-        for scope in ("OWN", "ASSIGNED", "OTHER"):
+        for scope in ("OWN", "OTHER"):
             _matrix_row(
                 role.id, "PUBLIC", scope,
                 can_view=True, can_download=True, can_edit=True, can_delete=True,

+ 4 - 62
backend/tests/dms/test_role_management.py

@@ -4,8 +4,7 @@
 - 角色 CRUD、角色名唯一、默认角色全局唯一(先清后设)
 - row_version 乐观锁
 - ROLE_IN_USE 删除守卫与解除后可删
-- 可访问部门整存(含无效机构拒绝、空列表清空)
-- 权限矩阵 15 行整存校验与持久化
+- 权限矩阵 10 行整存校验与持久化
 - 用户关联(仅普通用户、auth_version 递增、解除)
 - 非 ADMIN 访问 403
 - 审计记录(CREATE_ROLE/EDIT_ROLE/CHANGE_ROLE_PERMISSION/CHANGE_ROLE_USER/DELETE_ROLE)
@@ -20,7 +19,7 @@ from dms.models import AuditLog, User
 
 
 LEVELS = ("PUBLIC", "INTERNAL", "SECRET", "CONFIDENTIAL", "TOP_SECRET")
-SCOPES = ("OWN", "ASSIGNED", "OTHER")
+SCOPES = ("OWN", "OTHER")
 ACTION_KEYS = (
     "canView",
     "canDownload",
@@ -91,17 +90,6 @@ def _row_payload(role, *, is_default=None, name=None) -> dict:
     }
 
 
-def _org_id(app, code: str) -> int:
-    from dms.models import Organization
-
-    with app.app_context():
-        org = db.session.scalar(
-            db.select(Organization).where(Organization.org_code == code)
-        )
-        assert org is not None, f"organization {code} not found"
-        return org.id
-
-
 def _user_id(app, username: str) -> int:
     with app.app_context():
         user = db.session.scalar(
@@ -132,7 +120,7 @@ def test_create_role_seeds_matrix_and_lists(
     matrix = b2_client.get(
         f"/api/v1/roles/{role['id']}/permissions", headers=_auth(login_token)
     ).get_json()["data"]
-    assert len(matrix) == 15
+    assert len(matrix) == 10
     assert {(row["securityLevel"], row["deptScope"]) for row in matrix} == {
         (level, scope) for level in LEVELS for scope in SCOPES
     }
@@ -235,52 +223,6 @@ def test_delete_role_blocked_while_in_use_and_succeeds_after_unbind(
     assert all(item["id"] != role["id"] for item in items)
 
 
-@pytest.mark.integration
-def test_role_departments_full_replace_and_invalid_rejected(
-    b2_app, b2_client, login_token, role_cleanup
-):
-    ops_id = _org_id(b2_app, "ORG_OPS")
-    comms_id = _org_id(b2_app, "ORG_COMMS")
-    role = _create_role(b2_client, login_token, name="部门角色").get_json()[
-        "data"
-    ]
-
-    saved = b2_client.put(
-        f"/api/v1/roles/{role['id']}/departments",
-        json={
-            "organizationIds": [str(ops_id), str(comms_id)],
-            "rowVersion": role["rowVersion"],
-        },
-        headers=_auth(login_token),
-    )
-    assert saved.status_code == 200, saved.get_json()
-    assert saved.get_json()["data"]["organizationIds"] == [
-        str(comms_id),
-        str(ops_id),
-    ]
-
-    listed = b2_client.get(
-        f"/api/v1/roles/{role['id']}/departments", headers=_auth(login_token)
-    ).get_json()["data"]
-    assert set(listed) == {str(ops_id), str(comms_id)}
-
-    invalid = b2_client.put(
-        f"/api/v1/roles/{role['id']}/departments",
-        json={"organizationIds": ["99999"], "rowVersion": 1},
-        headers=_auth(login_token),
-    )
-    assert invalid.status_code == 400
-    assert invalid.get_json()["code"] == "INVALID_ARGUMENT"
-
-    cleared = b2_client.put(
-        f"/api/v1/roles/{role['id']}/departments",
-        json={"organizationIds": [], "rowVersion": 1},
-        headers=_auth(login_token),
-    )
-    assert cleared.status_code == 200
-    assert cleared.get_json()["data"]["organizationIds"] == []
-
-
 @pytest.mark.integration
 def test_matrix_validation_rejects_malformed_rows(
     b2_client, login_token, role_cleanup
@@ -291,7 +233,7 @@ def test_matrix_validation_rejects_malformed_rows(
 
     short = b2_client.put(
         f"/api/v1/roles/{role['id']}/permissions",
-        json={"matrix": _matrix()[:14], "rowVersion": 0},
+        json={"matrix": _matrix()[:9], "rowVersion": 0},
         headers=_auth(login_token),
     )
     assert short.status_code == 400

+ 113 - 0
docs/plans/SpringBoot转写验证记录.md

@@ -454,3 +454,116 @@ rowVersion 联动实证:登录 touch 用户 rv(创建0→更新1→重置2
 
 
 
+
+## 阶段10:bad04bc 需求集同步(2026-08-29)
+
+阶段1~9收官后,Python 版在 `bad04bc`(2026-08-29 提交,工作跨度 08-24~08-29)中继续落地了一批需求,
+Java 版快照停留在其间,形成 4 个缺口接口、3 条缺失迁移、约 13 个路由权限语义过时与 2 处口径不一致。
+本阶段按 P0→P1→P2 将其全量同步。
+
+### 交付物
+
+| 分组 | Java 交付物 | Python 对应 / 迁移 | 说明 |
+| --- | --- | --- | --- |
+| 迁移 | `db/migration/V2__category_type_display_name.sql` | Alembic 0017 | `doc_category.type_display_name VARCHAR(128) NULL`;information_schema 探测后 PREPARE 执行,幂等 |
+| 迁移 | `db/migration/V3__sub_plan_binding.sql` | Alembic 0018 | `doc_sub_plan_binding` 建表(生成列 active_marker + uq 唯一键 + 双索引 + FK RESTRICT);`CREATE TABLE IF NOT EXISTS` 幂等 |
+| 迁移 | `db/migration/V4__content_extract_error.sql` | Alembic 0019 | `doc_document.content_extract_error VARCHAR(512) NULL`;同 V2 幂等模式 |
+| 分类 | `CategoryEntity/Mapper/Service` 扩展 | 0017 | typeDisplayName 进出(创建/编辑必填校验、子分类强制 NULL、DTO/审计快照);树计数改为全量子树主案数(`countActiveMainGroupByCategory`,不按密级/权限过滤,子树累加穿过停用节点);flat_mode 切换守卫增加 `hasSubPlanMount`;删除原可见性计数查询;创建响应 rowVersion 补 0(对齐 Python 事务提交后按库值刷新的口径) |
+| 子案挂载 | `SubPlanBindingEntity` + `SubPlanBindingMapper` + `SubPlanBindingService` + `MainPlansController` 两路由 | 0018 | `POST /main-plans/{id}/sub-plans/bind`、`DELETE /main-plans/{id}/sub-plans/{subPlanId}`:主案行锁+密级+requirePlanAction(BIND/UNBIND)+密码锁+乐观锁+步长10排序+审计(scope=subPlanMount);自有子案剔除;`listSubPlans` 合并挂载子案(自有在前、按 binding.sort_no 追加、mountId 标记,挂载过滤去掉 parentDocumentId) |
+| 重新解析 | `DocumentMutationService.reExtractContent` + `DocumentMapper.updateContentExtraction` + `DocumentsController` 路由 | 0019 | `POST /documents/{id}/re-extract`(仅管理员):按当前存储文件覆盖写回提取结果(含失败原因)并重建 search_text,审计 EDIT_DOCUMENT(scope=reExtractContent);上传/覆盖/批量共用 `extractFileContent`(Office 类转换失败携带脱敏原因,转换器不可用固定文案);文档与附件详情对 ADMIN 追加 `contentExtractStatus/contentExtractError/contentExtractedAt` |
+| 提取算法 | `DocumentContentService` 重写 | `_pdf_page_text` 系列 | PDF 版面块段落化:LineCollector(sortByPosition 逐字符收集行/主字号/bbox)→ 自适应块分组(中位行距×1.4 且至少+2pt 为块边界)→ `mergeLineItems`(条目编号/字号突变≥0.15/句末标点切分,段内软换行合并,ASCII 字母数字间补空格)→ 退化网格(每行一块+等距+同字号)回退纯文本启发式;页间空行分隔;`sanitizeExtractError` 脱敏(绝对路径→`<path>`,480 字符截断) |
+| 登录背景 | `api/v1/LoginBackgroundController` | v1.13 | `GET /config/login-background` 公开:background.jpg 优先 png,`nosniff`+`public, max-age=60`+ETag/Last-Modified/304;无图 404 FILE_NOT_FOUND;`DMS_LOGIN_BACKGROUND_DIR` 覆盖运行目录(`LauncherBootstrap.appRoot()` 改 public) |
+| 写路径鉴权 | `AuthorizationService.requirePlanAction` + 各服务 | `require_plan_action` | 新错误码 `DOCUMENT_ACTION_FORBIDDEN`;密级前置→共享附件仅 ADMIN→evaluatePlanAccess→操作位校验;接入:方案编辑/说明/删除/重排(逐文档 EDIT)/关联(EDIT)/密码锁(CONFIG_PERMISSION)/ACL 读写(CONFIG_PERMISSION+密码)/附件编辑删除/挂载三路由;对应 13 个控制器路由由 requireAdmin 降为仅认证 |
+| 动态菜单 | `AuthorizationService.hasAnyManageAction` + `UserSummaryService.allowedModulesForUser` | `has_any_manage_action` | MANAGE_MATRIX_COLUMNS 五管理位;OWN/ASSIGNED/OTHER 三区部门集合(空集 [-1] 占位)+查看权三来源(矩阵/全员可见/ACL)任一已发布主案命中即真;login/`users/me`/门户设密三处下发动态 allowedModules |
+| 附件列表 | `AttachmentsController.list` + `AttachmentQueryService` | list_attachments | 门禁降为仅认证;USER 全量可见但 description=null、无 contentSnippet、allowedActions 空集 |
+| Mapper | `DocumentMapper.existsManageHit`、`CategoryMapper.countActiveMainGroupByCategory/existsSubPlanMountUnderCategories`、insert/updateOverwrite 增列 | — | content_extract_error 进出;空集合占位对齐 Python in_/not_in 空集语义 |
+
+### 实施偏差说明
+
+- **UNBIND 位的既有口径**:两侧 `plan_allowed_actions` 对 ADMIN 的 `UNBIND_ATTACHMENT` 均以"存在有效附件挂载"为前提(子案挂载不计入),仅有子案挂载的主案解挂子案会 403——Java 按 Python 现状逐行对齐,验证脚本先挂附件使位生效;该行为是否需两侧同步调整,留待统领会话决策。
+- **PDF 块分组/字号阈值**:fitz 直接给出版面块与字号,PDFBox 只有逐字符 TextPosition。块分组以"中位行距×1.4(+2pt 下限)"自适应阈值近似邻近度聚块;字号取行内最大 `getHeightDir()`,其相对差异较 fitz 字号被压缩(实测16pt/11pt≈0.195),故切分比例由 0.2 调至 0.15 以对齐效果。
+- **分类树计数**:删除 `countVisibleMainByLevels/countVisibleMainForUser`(旧口径按用户密级+PUBLISHED+可见性过滤),与 Python"括号数字=分类真实挂载规模"口径一致;USER 列表/详情接口继续独立控制内容访问。
+- **迁移引擎**:Python 版在并行期违反 §4.3 冻结新增了 Alembic 0017~0019;Java 侧以幂等 V2~V4 等价承接(已由 Alembic 升级到 0019 的库上执行为 no-op,仅登记 Flyway 版本线),此后仍以 Flyway 为唯一出口。
+
+### 本阶段修复的 Java 缺陷(2 处)
+
+1. **`listSubPlans` 挂载过滤误带 `parentDocumentId`**:挂载子案(他主案子案)被同时要求满足本主案的父约束,恒被过滤——挂载列表永远为空。修复:挂载过滤副本移除 `parentDocumentId`,仅按 ids+公共过滤。
+2. **分类创建响应 `rowVersion=null`**:insert 仅回填 id,DTO 直接序列化实体导致 null(Python 事务提交后过期重读为 0)。修复:创建路径显式置 0。
+
+### 验证结果(端口 9347,远程测试库 dms@82.156.83.248:3306,脚本 `docs/temp/stage10_contract.py` 自建自清可重复执行)
+
+| 分组 | 用例 | 结果 |
+| --- | --- | --- |
+| A 登录背景(2) | 无图 404 FILE_NOT_FOUND 标准错误体(无需认证);运行目录有图时 200+image/*+nosniff+max-age=60 | ✅ 2/2 |
+| B 分类(4) | 一级分类携带 typeDisplayName 创建/修改;子分类强制 NULL;缺字段 400 逐字拒绝 | ✅ 4/4 |
+| C 迁移(2) | flyway 版本线 0/1/2/3/4;schema 含 type_display_name/content_extract_error/doc_sub_plan_binding(在 Alembic 0019 库上 V2~V4 幂等 no-op 实证) | ✅ 2/2 |
+| D 计数与可见性(5) | 子分类计数=3 主案(ADMIN=USER,含 SECRET);父分类沿链汇总=3;USER 列表全量可见 SECRET 主案且 allowedActions 空集 | ✅ 5/5 |
+| E 子案挂载(9) | bind created=1/subPlanCount 口径;重复挂载 existing;自有子案剔除;列表合并(自有在前+mountId 标记);USER 无权 403 DOCUMENT_ACTION_FORBIDDEN;解挂 subPlanCount=0;重复解挂 404;恢复仅自有;(附件挂载使 UNBIND 位生效) | ✅ 9/9 |
+| F 提取与重解析(8) | 坏 PDF→FAILED+脱敏 error(管理员详情三字段,USER 详情无);re-extract 200 覆盖写回+USER 403;图片 UNSUPPORTED 无 error;docx SUCCESS;**PDF 版面段落化专项**(LibreOffice 实转 PDF:标题独立成段+两段软换行合并=3 段) | ✅ 8/8 |
+| G 附件列表(2) | USER 可访问(不再 403);description=null+allowedActions 空集 | ✅ 2/2 |
+| H 写路径与动态菜单(7) | 无角色编辑/看 ACL 403;角色矩阵(PUBLIC 三范围管理4位)挂用户;登录 allowedModules 动态含 BACKEND_MANAGEMENT;有 EDIT 位 USER 编辑主案 200;未获授权位设密码锁 403 | ✅ 7/7 |
+
+**==== stage10: 39/39 PASS ====**
+
+### 踩坑记录
+
+- **Windows 控制台 GBK**:脚本输出中文乱码不影响断言;JAVA 侧 `PYTHONIOENCODING=utf-8` 读取正常。
+- **上传 metadata 必含 `summary`**:MAIN/SUB_PLAN 创建字段集严格匹配,漏传即 400(与 Python `_exact` 同语义);附件 attachmentType 合法值为 SUPPORT_ACHIEVEMENT/TRAINING_MATERIAL/TYPICAL_CASE。
+- **测试素材避开切分规则**:验证软换行合并的段落不能含条目编号开头/句末标点,否则分别命中规则1/规则3 被正确切分(首次用"第N条…。"素材得到 16 段属算法按设计工作,非缺陷)。
+- **清理需临时关闭外键检查**:doc_document 父子 FK RESTRICT,脚本 cleanup 先 `SET FOREIGN_KEY_CHECKS=0` 再按 J10 前缀清理,角色需先删 sys_role_dept/sys_role_permission。
+- **soffice 路径含空格**:启动脚本经 python 解析 .env 并转正斜杠(bash 直接 source 会截断含空格值,同阶段8踩坑)。
+
+**结论:阶段10(bad04bc 需求集同步)验证通过——0017/0018/0019 三条迁移的 Flyway 幂等等价物、顶级分类类型显示名称、子案跨主案挂载、正文提取失败可视化与重新解析、登录背景图、写路径 require_plan_action、动态 allowedModules、附件列表全量可见、分类树全量计数与 PDF 版面段落化提取全部落地,39/39 用例全绿(真实 LibreOffice 转换、真实远程库、真实并发口径)。Java 版与 Python 版重新回到契约一致状态。**
+
+## 阶段11:UNBIND方案B + 配套方案并入共享资料库(2026-08-29)
+
+统领会话决策:①ADMIN无条件拥有UNBIND_ATTACHMENT(方案B);②"配套方案"与支撑成果/
+方案训考资料/典型案例并轨为共享资料库附件第四种类型`ACCOMPANYING_PLAN`,入库/挂载/
+解绑/留存语义与其余三类一致。方案详见`docs/plans/配套方案并入共享资料库方案.md`。
+
+### 交付物
+
+| 分组 | 交付物 | 说明 |
+| --- | --- | --- |
+| 迁移 | Alembic `0020_sub_plan_to_attachment` + Flyway `V5__sub_plan_to_attachment.sql` | 附件类型CHECK扩四值(新名v2,旧删新加按名幂等);附件shape约束放宽(`ck_doc_document_type_shape_v2`,允许任意密级);同名消歧(临时表规避MySQL 1093);自有子案关系先补`doc_attachment_binding`(沿用sort_no);`doc_sub_plan_binding`活跃行转挂载后软删;SUB_PLAN全量转ATTACHMENT+ACCOMPANYING_PLAN(置空父/根/分类,保留密级,visibility=ALL_AUTHENTICATED);attachment_count重算、child_count清零;降级保护(存在并轨数据即拒绝) |
+| 后端(两侧) | `plan_allowed_actions` ADMIN分支 | 方案B:恒含UNBIND(与BIND对称),消除主案列表每行一次的count查询;"仅有子案挂载的主案管理员无法解挂"的倒挂随之消除 |
+| 后端(两侧) | SUB_PLAN创建拒绝 | 单传/批量/覆盖一致:400"配套方案已并入共享资料库,请以附件类型ACCOMPANYING_PLAN上传" |
+| 后端(两侧) | `evaluate_attachment_access`/`require_attachment_access` | 附件读路径密级前置:密级不足优先抛SECURITY_LEVEL_FORBIDDEN(任何角色含ADMIN),通过后仍按"至少一个挂载主案权限"判定 |
+| 枚举/字典 | `AttachmentType.ACCOMPANYING_PLAN`(Python enums / Java DmsEnums / AttachmentQueryService) | UI字典attachmentTypes四值(配套方案sortNo 10居首),服务端启动枚举一致性校验两版同步生效 |
+| 前端 | App.tsx + AttachmentBindingModal + enums.ts | 关系Tab首项改为ACCOMPANYING_PLAN("sub"退役);浏览/管理两处关系面板统一走挂载附件列表(类型=当前Tab)+上传自动挂载+解绑/拖动走binding接口;挂载弹窗配套方案Tab为软排序(preferredType居前不过滤,单页200条),其余三类维持类型预过滤;子案上传/子案挂载/子案解挂弹窗退役;共享资料库类型筛选/徽章随字典自动四值 |
+
+### 实施偏差说明
+
+- **密级保留而非归一PUBLIC**:原附件shape约束强制PUBLIC,为不静默降密存量INTERNAL/SECRET
+  子案,放宽约束+读路径密级前置(新建附件仍默认PUBLIC);密级前置的拒绝码为
+  SECURITY_LEVEL_FORBIDDEN,前端"密级不足"提示语义成立。
+- **挂载弹窗配套方案Tab为软排序而非过滤**:需求原文"配套方案排在前面,其他的排在后面",
+  preferredType模式取pageSize 200单页并客户端稳定排序(偏好类型居前),隐藏分页器。
+- **共享测试库存在带授权的默认角色**:stage11的"主案详情USER访问回归"按200/403双合法口径断言。
+
+### 本阶段踩坑
+
+- MySQL 1093:UPDATE目标表不可出现在派生表子查询——同名消歧候选id先落临时表再JOIN更新。
+- 附件shape约束(`ck_doc_document_document_type_shape`)隐含`security_level='PUBLIC'`且
+  要求父/根/分类为NULL:首次执行在类型转换步骤暴露(3819),由此确立"密级保留+约束放宽"
+  方案并调整迁移顺序(先补挂载、后转类型置空父/根/分类)。
+- Flyway V5在已被Alembic 0020升级的库上执行:全部步骤按名/按数据态幂等no-op
+  (flyway版本线0~5,SUB_PLAN仍为0、挂载29条无重复)。
+
+### 验证结果(端口9347,远程测试库已升0020,脚本`docs/temp/stage11_contract.py`自建自清)
+
+| 分组 | 用例 | 结果 |
+| --- | --- | --- |
+| A 方案B(2) | 零挂载主案ADMIN allowedActions恒含UNBIND/BIND;USER访问回归(200/403双合法) | ✅ 2/2 |
+| B 创建拒绝(1) | SUB_PLAN创建400逐字消息 | ✅ 1/1 |
+| C 配套方案闭环(6) | ACCOMPANYING_PLAN上传入库→USER按类型检索可见→挂载→Tab列表(bindingId)→解绑→库中留存 | ✅ 6/6 |
+| D 存量迁移(5) | SUB_PLAN清零/12条就位;密级INTERNAL10+SECRET2保留;child_count清零;v2双约束在位旧约束已除;快照对照父主案挂载全部补齐 | ✅ 5/5 |
+| E 密级前置(2) | PUBLIC密级USER访问INTERNAL配套方案403 SECURITY_LEVEL_FORBIDDEN;TOP_SECRET ADMIN 200 | ✅ 2/2 |
+| F UI字典(1) | attachmentTypes四值且配套方案居首 | ✅ 1/1 |
+
+**==== stage11: 18/18 PASS ====**;前端`tsc`零错误、`vitest`412/412、`vite build`通过;
+Java `mvn package`通过(61MB)。契约变更记录于`DMS_API_CONTRACT.md`版本1.14。
+
+**结论:阶段11验证通过——方案B与管理员解挂倒挂消除;配套方案全链路(上传入库/自动挂载/
+Tab列表/解绑留存/跨类型挂载弹窗软排序)与其余三类附件同构;存量12条子案无损并轨
+(密级保留、挂载补齐、计数重算);两版后端与前端契约一致,Java/Python/数据库三方收敛。**

+ 15 - 15
docs/plans/角色管理与权限矩阵方案.md

@@ -1,7 +1,7 @@
 # 角色管理与权限矩阵方案
 
 > 需求来源:prompts.md(用户管理模块增加角色管理)
-> 状态:已确认(2026-08-20)
+> 状态:已确认(2026-08-20);2026-08-31 修订:移除"可访问部门"维度(见文末修订记录)
 
 ## 需求重申
 
@@ -9,12 +9,11 @@
 
 1. **角色 CRUD**:新建/编辑/删除角色;可设置(或取消)**一个默认角色**——未选择角色的用户自动套用默认角色权限
 2. **角色关联用户**:角色可关联多个用户;用户编辑时可单选角色
-3. **角色可访问部门列表**:每个角色可配置"可访问部门"(组织树多选),用于矩阵中"已配置部门"判定
-4. **表格式权限矩阵**:
+3. **表格式权限矩阵**:
    - **横向表头(操作列,7 项)**:查看 / 下载 / 编辑 / 删除 / 授权 / 挂载附件 / 解除挂载(与 `AllowedAction` 对齐)
-   - **纵向表头(两级)**:第一级 = 五个密级(公开/内部/秘密/机密/绝密),第二级 = 本部门文档 / 已配置部门文档 / 其他部门文档 → 共 5×3 = 15 行
+   - **纵向表头(两级)**:第一级 = 五个密级(公开/内部/秘密/机密/绝密),第二级 = 所属部门方案 / 其他部门方案 → 共 5×2 = 10 行
    - **表头复选框**:每个行头与每个列头均有复选框,勾选/取消即批量切换整行或整列
-5. **权限叠加**:矩阵与现有单文档 ACL 为 **OR 叠加**(矩阵为主权限源;现有 ACL 保留,后续将演进为限时跨部门借阅,本次不实现限时)
+4. **权限叠加**:矩阵与现有单文档 ACL 为 **OR 叠加**(矩阵为主权限源;现有 ACL 保留,后续将演进为限时跨部门借阅,本次不实现限时)
 
 ## 关键设计决策(已确认)
 
@@ -22,15 +21,14 @@
 |---|---|
 | ADMIN 与自定义角色关系 | **保留内置超管**:role_code(ADMIN/USER)不变,ADMIN 恒为超管(绕过矩阵,密级限制仍适用);自定义角色仅挂 USER 体系 |
 | 矩阵与文档 ACL 关系 | **OR 叠加**;后续 ACL 迁移为限时借阅权限(本次不实现) |
-| 部门三分类判定 | 本部门 = 文档归属机构是用户机构或其下级(org_path 前缀);已配置部门 = 文档归属机构 ∈ 角色可访问部门列表;其余 = 其他部门 |
+| 部门两分类判定 | 所属部门 = 文档归属机构是用户机构或其下级(org_path 前缀);其余(含文档无归属)= 其他部门 |
 
-## 数据模型(迁移 0012_role_management)
+## 数据模型(迁移 0012_role_management,0021 修订)
 
 | 表 | 结构 |
 |---|---|
 | `sys_role` | id、role_name、sort_no、status(ENABLED/DISABLED)、remark、is_default、`default_marker`(计算列:is_default=1→1 否则 NULL)+ 唯一约束保证全局至多一个默认角色(复用 active_marker 模式)+ BusinessTableMixin |
-| `sys_role_dept` | role_id → sys_role、organization_id → sys_organization,唯一约束 (role_id, organization_id, active_marker) + BusinessTableMixin |
-| `sys_role_permission` | role_id、security_level、dept_scope(OWN/ASSIGNED/OTHER)、can_view / can_download / can_edit / can_delete / can_config_permission / can_bind_attachment / can_unbind_attachment(布尔列,每角色固定 15 行)+ 唯一约束 (role_id, security_level, dept_scope, active_marker) + BusinessTableMixin |
+| `sys_role_permission` | role_id、security_level、dept_scope(OWN/OTHER)、can_view / can_download / can_edit / can_delete / can_config_permission / can_bind_attachment / can_unbind_attachment(布尔列,每角色固定 10 行)+ 唯一约束 (role_id, security_level, dept_scope, active_marker) + BusinessTableMixin |
 | `sys_user` | 新增 `role_id` FK → sys_role 可空(NULL → 默认角色) |
 
 ## 权限判定融合(authorization_service.py)
@@ -38,7 +36,7 @@
 `evaluate_plan_access` / `plan_allowed_actions` 新增矩阵判定:
 
 1. 密级前置检查不变(密级不足一律拒绝,ADMIN 也不绕过)
-2. 部门归属分类:文档 org_path 以用户机构 org_path 为前缀 → **OWN**;∈ 角色可访问部门列表 → **ASSIGNED**;否则 **OTHER**;文档无归属 → OTHER
+2. 部门归属分类:文档 org_path 以用户机构 org_path 为前缀 → **OWN**;否则(含文档无归属)→ **OTHER**
 3. 取用户角色(无 → 默认角色;无默认 → 矩阵贡献为空)→ 查 `(security_level, dept_scope)` 行 → 与 ACL 结果 OR 叠加
 4. 角色矩阵变更不动 auth_version(权限实时查库);用户换角色时 auth_version+1
 
@@ -50,9 +48,7 @@
 | POST /roles | 新建(roleName/sortNo/status/remark/isDefault) |
 | PUT /roles/<id> | 编辑(rowVersion 乐观锁) |
 | DELETE /roles/<id>?rowVersion= | 删除(有关联用户时拒绝,ROLE_IN_USE) |
-| GET /roles/<id>/departments | 可访问部门 id 列表 |
-| PUT /roles/<id>/departments | 整存部门列表(rowVersion) |
-| GET /roles/<id>/permissions | 15×7 矩阵 |
+| GET /roles/<id>/permissions | 10×7 矩阵 |
 | PUT /roles/<id>/permissions | 矩阵整存整取(rowVersion) |
 | PUT /roles/<id>/users | 关联用户集合(rowVersion) |
 | GET /users 扩展 | User DTO 增加 roleId/roleName |
@@ -65,7 +61,7 @@
 
 1. 第三个 Tab **"角色管理"**:角色列表(名称/默认/成员数/状态/操作)
 2. 角色编辑弹窗:名称、排序、状态、是否默认
-3. 角色详情:基本信息 + 可访问部门(组织树复选)+ 权限矩阵表格(行头两级嵌套,行头/列头复选框全选切换)
+3. 角色详情:基本信息 + 权限矩阵表格(行头两级嵌套,行头/列头复选框全选切换)
 4. UserEditorModal 增加"角色"下拉(仅 USER 显示)
 
 ## 实现阶段
@@ -73,7 +69,7 @@
 | 阶段 | 内容 | 验证 |
 |---|---|---|
 | 1 | 数据模型 + 迁移 0012 | test_migration.py |
-| 2 | 角色 CRUD/部门/矩阵/用户关联 API + 审计 + OpenAPI | 新增 pytest |
+| 2 | 角色 CRUD/矩阵/用户关联 API + 审计 + OpenAPI | 新增 pytest |
 | 3 | authorization_service 矩阵判定融合 | 判定单测 + 既有 90 测试守底 |
 | 4 | 前端角色 Tab + 矩阵 + 用户角色下拉 | tsc + vitest |
 | 5 | 全量回归 | 前后端全量测试 |
@@ -84,3 +80,7 @@
 - **中**:默认角色唯一性并发(default_marker 计算列 + 唯一约束 DB 层兜底)
 - **中**:矩阵整存整取与乐观锁交互
 - **低**:文档 organization_id 为空的历史数据 → 归入 OTHER
+
+## 修订记录
+
+- **2026-08-31(迁移 0021 / Flyway V6)**:移除"可访问部门"维度——`sys_role_dept` 表删除、`GET/PUT /roles/<id>/departments` 接口下线、`DeptScope` 收敛为 OWN/OTHER(OTHER 涵盖所属部门子树外全部部门,含无归属文档)、矩阵由 15 行收敛为 10 行、存量 ASSIGNED 权限行物理删除、UI 字典 deptScopes 同步收敛。原"已配置部门"语义取消,跨部门访问由"其他部门"行承载。

+ 81 - 0
docs/plans/配套方案并入共享资料库方案.md

@@ -0,0 +1,81 @@
+# 配套方案并入共享资料库方案(含UNBIND方案B)
+
+> 决策:①ADMIN无条件拥有UNBIND_ATTACHMENT(方案B);②"配套方案"(原子案SUB_PLAN)
+> 与支撑成果/方案训考资料/典型案例并轨为共享资料库附件的第四种类型,入库、挂载、
+> 解绑、留存语义与另外三类完全一致。
+
+## 一、目标语义
+
+1. 共享资料库条目新增类型`ACCOMPANYING_PLAN`(配套方案),类型集合扩为四值;
+2. 主案"配套方案"Tab:上传→以配套方案类型入库共享资料库并自动挂载到当前主案;
+   挂载→打开共享资料库挂载弹窗,配套方案排前、其余类型排后(软排序,不过滤);
+   列表→仅显示挂载到当前主案且类型为配套方案的条目(与其他三类Tab同构);
+   解绑/拖动排序→走附件挂载关系(binding)接口;
+3. 解绑后条目保留在共享资料库,与任何主案无关联也不删除;
+4. 停止新建SUB_PLAN文档(接口拒绝,存量数据迁移),子案跨主案挂载(0018)与
+   子案重排等专属链路随之退役(接口保留兼容但无数据可达)。
+
+## 二、权限与可见性变化(需知悉)
+
+- 原SUB_PLAN可见性/ACL继承其父主案;并轨后与其他附件一致:标题对全体登录用户
+  可见(列表全量可见),正文/下载由"至少一个挂载主案的查看/下载权限"控制;
+- 密级保留文档自身密级;密码锁仍仅主案可设(附件不可设密,与现状一致);
+- 删除配套方案=删除共享资料库条目:被挂载时拒绝(ATTACHMENT_IN_USE),先解绑;
+- 主案删除不再受"存在子方案"限制(MAIN_PLAN_HAS_CHILDREN退役),挂载关系随主案
+  软删,配套方案条目留在库中。
+
+## 三、数据库迁移(0020_sub_plan_to_attachment / Flyway V5)
+
+1. CHECK约束`ck_doc_document_document_attachment_type`扩四值:以新名
+   `ck_doc_document_attachment_type_v2`重建(旧名DROP IF EXISTS,新名ADD IF NOT EXISTS,
+   双引擎按名幂等);
+2. 同名消歧:存活子案与存活附件同名、或与更小id存活子案同名者,追加"(id)"后缀;
+3. 全部SUB_PLAN(含软删)→`document_type='ATTACHMENT'`、
+   `attachment_type='ACCOMPANYING_PLAN'`、`visibility_type='ALL_AUTHENTICATED'`;
+4. 自有子案关系转挂载:存活子案按`parent_document_id`补`doc_attachment_binding`
+   (sort_no沿用,NOT EXISTS防重);
+5. `doc_sub_plan_binding`活跃行转`doc_attachment_binding`后软删(防重跑双转);
+6. 重算全部主案`attachment_count`;`child_count`清零;
+7. 幂等可重跑;降级:存在ACCOMPANYING_PLAN数据时拒绝有损回退。
+   生产升级仍走`dms-server upgrade-db`(Alembic 0020),Java并行环境由Flyway V5
+   幂等承接。
+
+## 四、后端改动(两侧同步)
+
+- 枚举/字典:`AttachmentType.ACCOMPANYING_PLAN`;UI字典attachmentTypes四值
+  (配套方案10/支撑成果20/训考30/典型40),服务端启动校验两版同步生效;
+- 方案B:`plan_allowed_actions` ADMIN分支删除"附件挂载数>0"条件,恒含UNBIND
+  (顺带消除主案列表每行一次的count查询);
+- SUB_PLAN创建拒绝(单传/批量/覆盖一致):
+  "配套方案已并入共享资料库,请以附件类型ACCOMPANYING_PLAN上传";
+- 其余SUB_PLAN读路径(listSubPlans/bind/unbind/重排/守卫)保留兼容,数据迁移后
+  自然不可达。
+
+## 五、前端改动
+
+- `AttachmentType`枚举+`ACCOMPANYING_PLAN`;关系Tab首项由"sub"改为该类型;
+  两处关系面板(浏览/管理)统一走挂载附件列表(类型=当前Tab)、上传自动挂载、
+  挂载弹窗(配套方案Tab软排序)、解绑/拖动走binding接口;
+- 检索类型徽章、定位Tab、共享资料库类型筛选与列展示随字典自动扩展;
+- 子案专属弹窗(子案上传/子案挂载/子案解挂)退役。
+
+## 六、验证
+
+`docs/temp/stage11_contract.py`(自建自清):方案B放行/无权仍拒;SUB_PLAN创建400;
+配套方案上传→入库→挂载→Tab列表→解绑→仍在库;迁移前后存量12条子案的转换
+(类型/挂载/计数/child_count);四值字典下发。
+
+## 七、实施偏差与补充决策(2026-08-29 实施时确定)
+
+1. **密级保留而非归一PUBLIC**:原附件shape约束强制`security_level='PUBLIC'`。
+   为不静默降密存量INTERNAL/SECRET子案,迁移同步放宽shape约束
+   (`ck_doc_document_type_shape_v2`,附件允许任意密级),存量子案密级原样保留;
+   附件读路径(详情/预览/下载)增加**密级前置**(任何角色含ADMIN密级不足即拒,
+   拒绝码SECURITY_LEVEL_FORBIDDEN,与主案口径一致)。新建附件仍默认PUBLIC
+   (上传不含securityLevel字段)。
+2. **类型转换置空父/根/分类**:附件shape要求parent/root/category为NULL,
+   迁移在补齐挂载关系之后置空三组字段(挂载关系承载原父子关联)。
+3. **同名消歧经临时表**:UPDATE自联派生表触发MySQL 1093,候选id先落临时表再JOIN。
+4. **挂载顺序调整**:先补挂载(需parent_document_id)后转类型,与初稿顺序相反。
+5. **mount弹窗配套方案Tab为软排序**(preferredType:配套方案居前、其余类型排后,
+   不做类型过滤);其余三类Tab维持原类型预过滤。

File diff ditekan karena terlalu besar
+ 159 - 149
frontend/src/app/App.tsx


+ 44 - 58
frontend/src/features/audit/AuditDashboard.tsx

@@ -13,7 +13,6 @@ import {
   BarChart,
   CartesianGrid,
   Cell,
-  Legend,
   Line,
   LineChart,
   Pie,
@@ -47,7 +46,9 @@ import {
 const NAVY = "#1A5299";
 const BLUE = "#3191eb";
 const WHITE = "#fff";
-const COLORS = ["#3191eb", "#1A5299", "#6eaee8", "#8bc4f0", "#4d88c7"];
+// 多彩配色(不再拘泥蓝色系):蓝为主色,辅以橙/绿/紫/红/青等,扇区按序取色
+const COLORS = ["#3191eb", "#f5a623", "#34a853", "#a55eea", "#e74c3c",
+                "#00bcd4", "#f8e71c", "#ff7043", "#7cb342", "#5c6bc0"];
 const EMPTY = "—";
 
 function ResourceState<T>({
@@ -61,7 +62,7 @@ function ResourceState<T>({
 }) {
   if (state.loading && !state.data) {
     return (
-      <div className="p-4 text-xs font-bold" style={{ color: NAVY }}>
+      <div className="p-4 text-base font-bold" style={{ color: NAVY }}>
         <RefreshCw size={12} className="inline animate-spin mr-2" />
         正在加载{label}…
       </div>
@@ -69,7 +70,7 @@ function ResourceState<T>({
   }
   if (state.error) {
     return (
-      <div className="p-4 text-xs font-bold" style={{ color: NAVY }}>
+      <div className="p-4 text-base font-bold" style={{ color: NAVY }}>
         <div>
           {state.error.httpStatus === 403
             ? `当前账号无权读取${label}`
@@ -92,7 +93,7 @@ function ResourceState<T>({
   }
   if (Array.isArray(state.data) && state.data.length === 0) {
     return (
-      <div className="p-4 text-xs font-bold opacity-60" style={{ color: NAVY }}>
+      <div className="p-4 text-base font-bold opacity-60" style={{ color: NAVY }}>
         当前条件下暂无{label}
       </div>
     );
@@ -225,7 +226,7 @@ export function AuditDashboard() {
           style={{ borderColor: "rgba(40,118,193,.14)" }}
         >
           <Filter size={14} style={{ color: BLUE }} />
-          <span className="text-sm font-black">日志筛选</span>
+          <span className="text-base font-black">日志筛选</span>
         </div>
         <div className="p-3 space-y-2">
           <label className="flex h-9 items-center border px-2">
@@ -236,7 +237,7 @@ export function AuditDashboard() {
                 setKeyword(event.target.value);
                 resetPage();
               }}
-              className="min-w-0 flex-1 outline-none text-sm font-bold"
+              className="min-w-0 flex-1 outline-none text-base font-bold"
               placeholder="名称、用户、requestId"
             />
           </label>
@@ -246,7 +247,7 @@ export function AuditDashboard() {
               setActionType(event.target.value);
               resetPage();
             }}
-            className="w-full h-9 border px-2 text-sm font-bold"
+            className="w-full h-9 border px-2 text-base font-bold"
           >
             <option value="">全部动作</option>
             {dictionaries.options("auditActions").map((option) => (
@@ -261,7 +262,7 @@ export function AuditDashboard() {
               setOperationResult(event.target.value);
               resetPage();
             }}
-            className="w-full h-9 border px-2 text-sm font-bold"
+            className="w-full h-9 border px-2 text-base font-bold"
           >
             <option value="">全部结果</option>
             {dictionaries.options("auditResults").map((option) => (
@@ -276,7 +277,7 @@ export function AuditDashboard() {
               setTargetType(event.target.value);
               resetPage();
             }}
-            className="w-full h-9 border px-2 text-sm font-bold"
+            className="w-full h-9 border px-2 text-base font-bold"
           >
             <option value="">全部对象类型</option>
             {dictionaries.options("auditTargets").map((option) => (
@@ -301,10 +302,10 @@ export function AuditDashboard() {
                 resetPage();
               }}
               placeholder={label as string}
-              className="w-full h-9 border px-2 text-sm font-bold"
+              className="w-full h-9 border px-2 text-base font-bold"
             />
           ))}
-          <label className="block text-xs font-black">
+          <label className="block text-base font-black">
             起始时间(含)
             <input
               type="datetime-local"
@@ -313,10 +314,10 @@ export function AuditDashboard() {
                 setCreatedFromLocal(event.target.value);
                 resetPage();
               }}
-              className="mt-1 w-full h-9 border px-2 text-xs font-bold"
+              className="mt-1 w-full h-9 border px-2 text-base font-bold"
             />
           </label>
-          <label className="block text-xs font-black">
+          <label className="block text-base font-black">
             结束时间(不含)
             <input
               type="datetime-local"
@@ -325,13 +326,13 @@ export function AuditDashboard() {
                 setCreatedToLocal(event.target.value);
                 resetPage();
               }}
-              className="mt-1 w-full h-9 border px-2 text-xs font-bold"
+              className="mt-1 w-full h-9 border px-2 text-base font-bold"
             />
           </label>
           <div className="flex gap-2">
             <button
               onClick={clearFilters}
-              className="flex-1 h-9 border text-xs font-black"
+              className="flex-1 h-9 border text-base font-black"
               style={{ borderColor: BLUE }}
             >
               清空
@@ -342,13 +343,13 @@ export function AuditDashboard() {
                 trend.refresh();
                 breakdowns.refresh();
               }}
-              className="flex-1 h-9 text-white text-xs font-black"
+              className="flex-1 h-9 text-white text-base font-black"
               style={{ background: BLUE }}
             >
               重新加载
             </button>
           </div>
-          <div className="text-[11px] leading-5 opacity-60">
+          <div className="text-base leading-5 opacity-60">
             时间按本地输入并转换为 UTC Z;查询区间为 [起始, 结束)。
           </div>
         </div>
@@ -364,7 +365,7 @@ export function AuditDashboard() {
             style={{ color: NAVY, borderColor: "rgba(40,118,193,.14)" }}
           >
           <span className="font-black">操作日志</span>
-            <span className="text-xs font-black" style={{ color: BLUE }}>
+            <span className="text-base font-black" style={{ color: BLUE }}>
               {logs.data?.total ?? 0} 条
             </span>
             <select
@@ -373,7 +374,7 @@ export function AuditDashboard() {
                 setSortDirection(event.target.value as "asc" | "desc");
                 setPage(1);
               }}
-              className="ml-auto h-8 border px-2 text-xs font-bold"
+              className="ml-auto h-8 border px-2 text-base font-bold"
             >
               <option value="desc">时间降序</option>
               <option value="asc">时间升序</option>
@@ -384,7 +385,7 @@ export function AuditDashboard() {
                 setPageSize(Number(event.target.value));
                 setPage(1);
               }}
-              className="h-8 border px-2 text-xs font-bold"
+              className="h-8 border px-2 text-base font-bold"
             >
               {[20, 50, 100].map((size) => (
                 <option key={size} value={size}>
@@ -394,26 +395,26 @@ export function AuditDashboard() {
             </select>
           </div>
           {logs.loading && !logs.data && (
-            <div className="p-8 text-sm font-bold" style={{ color: NAVY }}>
+            <div className="p-8 text-base font-bold" style={{ color: NAVY }}>
               <RefreshCw size={14} className="inline animate-spin mr-2" />
               正在读取审计日志…
             </div>
           )}
           {logs.error && (
-            <div className="p-6 text-sm font-bold" style={{ color: NAVY }}>
+            <div className="p-6 text-base font-bold" style={{ color: NAVY }}>
               <div>
                 {logs.error.httpStatus === 403
                   ? "当前账号无权读取审计日志"
                   : logs.error.message}
               </div>
               {logs.error.requestId && (
-                <div className="text-xs opacity-60 mt-1">
+                <div className="text-base opacity-60 mt-1">
                   错误编号:{logs.error.requestId}
                 </div>
               )}
               <button
                 onClick={logs.refresh}
-                className="mt-3 px-3 py-1.5 text-xs text-white"
+                className="mt-3 px-3 py-1.5 text-base text-white"
                 style={{ background: BLUE }}
               >
                 重试
@@ -421,7 +422,7 @@ export function AuditDashboard() {
             </div>
           )}
           {logs.data && logs.data.items.length === 0 && (
-            <div className="p-8 text-sm font-bold opacity-60" style={{ color: NAVY }}>
+            <div className="p-8 text-base font-bold opacity-60" style={{ color: NAVY }}>
               当前筛选条件下暂无日志
             </div>
           )}
@@ -440,7 +441,7 @@ export function AuditDashboard() {
                       "客户端IP",
                       "requestId",
                     ].map((heading) => (
-                      <th key={heading} className="px-3 py-2 text-left text-xs font-black">
+                      <th key={heading} className="px-3 py-2 text-left text-base font-black">
                         {heading}
                       </th>
                     ))}
@@ -458,7 +459,7 @@ export function AuditDashboard() {
                           color: "#B71C1C",
                         }}
                       >
-                        <td colSpan={8} className="px-3 py-2 text-xs font-bold">
+                        <td colSpan={8} className="px-3 py-2 text-base font-bold">
                           该行数据不符合DMS接口契约,无法展示
                         </td>
                       </tr>
@@ -471,20 +472,20 @@ export function AuditDashboard() {
                         borderColor: "rgba(40,118,193,.1)",
                       }}
                     >
-                      <td className="px-3 py-2 text-xs font-bold whitespace-nowrap">
+                      <td className="px-3 py-2 text-base font-bold whitespace-nowrap">
                         {formatAuditDate(log.createdAt)}
                       </td>
-                      <td className="px-3 py-2 text-xs font-bold">
+                      <td className="px-3 py-2 text-base font-bold">
                         <div>{log.username ?? EMPTY}</div>
                         <div className="opacity-60">{log.realName ?? EMPTY}</div>
                       </td>
-                      <td className="px-3 py-2 text-xs font-bold">
+                      <td className="px-3 py-2 text-base font-bold">
                         {log.organizationName ?? EMPTY}
                       </td>
-                      <td className="px-3 py-2 text-xs font-black">
+                      <td className="px-3 py-2 text-base font-black">
                         {dictionaries.label("auditActions", log.actionType)}
                       </td>
-                      <td className="px-3 py-2 text-xs font-bold">
+                      <td className="px-3 py-2 text-base font-bold">
                         <div>
                           {log.targetType
                             ? dictionaries.label("auditTargets", log.targetType)
@@ -495,7 +496,7 @@ export function AuditDashboard() {
                           {log.targetId ? `(${log.targetId})` : ""}
                         </div>
                       </td>
-                      <td className="px-3 py-2 text-xs font-bold max-w-[240px]">
+                      <td className="px-3 py-2 text-base font-bold max-w-[240px]">
                         <span
                           className="inline-block px-2 py-0.5 text-white"
                           style={{
@@ -511,10 +512,10 @@ export function AuditDashboard() {
                           {log.failureReason ?? EMPTY}
                         </div>
                       </td>
-                      <td className="px-3 py-2 text-xs font-bold">
+                      <td className="px-3 py-2 text-base font-bold">
                         {log.clientIp ?? EMPTY}
                       </td>
-                      <td className="px-3 py-2 text-xs font-mono max-w-[250px] break-all">
+                      <td className="px-3 py-2 text-base font-mono max-w-[250px] break-all">
                         {log.requestId ?? EMPTY}
                         {log.requestId && (
                           <button
@@ -535,7 +536,7 @@ export function AuditDashboard() {
             </div>
           )}
           <div
-            className="p-2 border-t flex items-center justify-center gap-3 text-xs font-bold"
+            className="p-2 border-t flex items-center justify-center gap-3 text-base font-bold"
             style={{ color: NAVY, borderColor: "rgba(40,118,193,.14)" }}
           >
             <button
@@ -568,7 +569,7 @@ export function AuditDashboard() {
                 <button
                   key={value}
                   onClick={() => setGranularity(value)}
-                  className="flex-1 py-1 text-xs font-black"
+                  className="flex-1 py-1 text-sm font-black"
                   style={{
                     color: granularity === value ? WHITE : NAVY,
                     background: granularity === value ? NAVY : "#EBF4FD",
@@ -588,8 +589,8 @@ export function AuditDashboard() {
                 <ResponsiveContainer width="100%" height={115}>
                   <LineChart data={trend.data}>
                     <CartesianGrid strokeDasharray="3 3" stroke="rgba(40,118,193,.12)" />
-                    <XAxis dataKey="period" tick={{ fontSize: 9, fill: NAVY }} />
-                    <YAxis tick={{ fontSize: 9, fill: NAVY }} />
+                    <XAxis dataKey="period" tick={{ fontSize: 12, fill: NAVY }} />
+                    <YAxis tick={{ fontSize: 12, fill: NAVY }} />
                     <Tooltip />
                     <Line
                       dataKey="operationCount"
@@ -599,9 +600,6 @@ export function AuditDashboard() {
                     />
                   </LineChart>
                 </ResponsiveContainer>
-                <div className="text-[10px] font-bold opacity-60" style={{ color: NAVY }}>
-                  {trend.data.map((item) => `${item.period}: ${item.operationCount}`).join(" · ")}
-                </div>
               </>
             )}
           </StatsPanel>
@@ -634,12 +632,8 @@ export function AuditDashboard() {
                       ))}
                     </Pie>
                     <Tooltip />
-                    <Legend iconSize={7} wrapperStyle={{ fontSize: 9 }} />
                   </PieChart>
                 </ResponsiveContainer>
-                <div className="text-[10px] font-bold" style={{ color: NAVY }}>
-                  {pieData.map((item) => `${item.name} ${item.value}`).join(" · ")}
-                </div>
               </>
             )}
           </StatsPanel>
@@ -658,12 +652,12 @@ export function AuditDashboard() {
                 <ResponsiveContainer width="100%" height={115}>
                   <BarChart data={activeUsers} layout="vertical">
                     <CartesianGrid strokeDasharray="3 3" horizontal={false} />
-                    <XAxis type="number" tick={{ fontSize: 9 }} />
+                    <XAxis type="number" tick={{ fontSize: 12 }} />
                     <YAxis
                       type="category"
                       dataKey="label"
                       width={62}
-                      tick={{ fontSize: 9, fill: NAVY }}
+                      tick={{ fontSize: 12, fill: NAVY }}
                     />
                     <Tooltip />
                     <Bar
@@ -673,14 +667,6 @@ export function AuditDashboard() {
                     />
                   </BarChart>
                 </ResponsiveContainer>
-                <div className="space-y-1 text-[10px] font-bold" style={{ color: NAVY }}>
-                  {activeUsers.map((item) => (
-                    <div key={item.userId}>
-                      {item.label} · {item.organizationName ?? EMPTY} ·{" "}
-                      {item.operationCount} 次
-                    </div>
-                  ))}
-                </div>
               </>
             )}
           </StatsPanel>
@@ -689,7 +675,7 @@ export function AuditDashboard() {
       {copyNotice && (
         <button
           onClick={() => setCopyNotice("")}
-          className="fixed right-6 bottom-6 px-4 py-3 text-xs font-bold text-white shadow-xl"
+          className="fixed right-6 bottom-6 px-4 py-3 text-base font-bold text-white shadow-xl"
           style={{ background: NAVY }}
         >
           {copyNotice}

+ 41 - 26
frontend/src/features/bindings/AttachmentBindingModal.tsx

@@ -26,6 +26,9 @@ interface AttachmentBindingModalProps {
   mainPlan: DocumentSummary;
   /** 附件类型筛选预置值(管理页附件 Tab 页"挂载"入口按当前页签类型预过滤)。 */
   initialAttachmentType?: AttachmentTypeCode;
+  /** 软排序偏好类型:该类型条目排在前面、其余类型排在其后(不过滤)。
+   *  配套方案Tab入口使用:库内配套方案优先展示,其余类型仍可选择挂载。 */
+  preferredAttachmentType?: AttachmentTypeCode;
   onClose: () => void;
   onChanged: () => void;
 }
@@ -52,6 +55,7 @@ function asHttpError(error: unknown, message: string): HttpError {
 export function AttachmentBindingModal({
   mainPlan,
   initialAttachmentType,
+  preferredAttachmentType,
   onClose,
   onChanged,
 }: AttachmentBindingModalProps) {
@@ -84,10 +88,21 @@ export function AttachmentBindingModal({
     updatedTo: updatedTo ? `${updatedTo}T23:59:59.999Z` : undefined,
     sortBy,
     sortDirection,
-    page,
-    pageSize: 10,
+    page: preferredAttachmentType ? 1 : page,
+    pageSize: preferredAttachmentType ? 200 : 10,
   });
 
+  // 软排序:偏好类型排前(稳定排序保留服务端排序的相对次序),其余类型排后
+  const orderedItems = useMemo(() => {
+    const items = attachments.data?.items ?? [];
+    if (!preferredAttachmentType) return items;
+    return [...items].sort((left, right) => {
+      const leftRank = left.attachmentType === preferredAttachmentType ? 0 : 1;
+      const rightRank = right.attachmentType === preferredAttachmentType ? 0 : 1;
+      return leftRank - rightRank;
+    });
+  }, [attachments.data, preferredAttachmentType]);
+
   useEffect(() => {
     const version = ++mountedRequestVersion.current;
     const controller = new AbortController();
@@ -182,7 +197,7 @@ export function AttachmentBindingModal({
         <div className="h-12 px-5 flex items-center justify-between" style={{ background: NAVY, color: WHITE }}>
           <div>
             <span className="font-black">挂载共享资料</span>
-          <span className="ml-3 text-xs opacity-75">主案:{mainPlan.documentName}</span>
+          <span className="ml-3 text-base opacity-75">主案:{mainPlan.documentName}</span>
           </div>
           <button onClick={onClose} disabled={submitting}><X size={18} /></button>
         </div>
@@ -194,13 +209,13 @@ export function AttachmentBindingModal({
               value={keyword}
               onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
               placeholder="搜索附件名称、概述或标签"
-              className="w-full bg-transparent outline-none text-sm font-bold"
+              className="w-full bg-transparent outline-none text-base font-bold"
             />
           </label>
           <select
             value={attachmentType}
             onChange={(event) => { setAttachmentType(event.target.value as AttachmentTypeCode | ""); setPage(1); }}
-            className="border px-2 text-sm font-bold"
+            className="border px-2 text-base font-bold"
           >
             <option value="">全部附件类型</option>
             {dictionaries.options("attachmentTypes").map((option) => (
@@ -211,19 +226,19 @@ export function AttachmentBindingModal({
             value={fileExtension}
             onChange={(event) => { setFileExtension(event.target.value); setPage(1); }}
             placeholder="扩展名,如 docx"
-            className="border px-3 text-sm font-bold outline-none"
+            className="border px-3 text-base font-bold outline-none"
           />
-          <label className="text-xs font-bold">更新起始
+          <label className="text-base font-bold">更新起始
             <input type="date" value={updatedFrom} onChange={(event) => { setUpdatedFrom(event.target.value); setPage(1); }} className="block mt-1 border h-8 px-2 w-full" />
           </label>
-          <label className="text-xs font-bold">更新截止
+          <label className="text-base font-bold">更新截止
             <input type="date" value={updatedTo} onChange={(event) => { setUpdatedTo(event.target.value); setPage(1); }} className="block mt-1 border h-8 px-2 w-full" />
           </label>
-          <select value={sortBy} onChange={(event) => setSortBy(event.target.value as "documentName" | "updatedAt")} className="border px-2 text-sm font-bold">
+          <select value={sortBy} onChange={(event) => setSortBy(event.target.value as "documentName" | "updatedAt")} className="border px-2 text-base font-bold">
             <option value="updatedAt">按更新时间</option>
             <option value="documentName">按名称</option>
           </select>
-          <select value={sortDirection} onChange={(event) => setSortDirection(event.target.value as "asc" | "desc")} className="border px-2 text-sm font-bold">
+          <select value={sortDirection} onChange={(event) => setSortDirection(event.target.value as "asc" | "desc")} className="border px-2 text-base font-bold">
             <option value="desc">降序</option>
             <option value="asc">升序</option>
           </select>
@@ -235,7 +250,7 @@ export function AttachmentBindingModal({
             <div className="p-10 text-center font-bold" style={{ color: NAVY }}>
               <AlertCircle size={16} className="inline mr-2" />
               {loadError.httpStatus === 403 ? "当前账号无权管理附件挂载" : loadError.message}
-              {loadError.requestId && <div className="text-xs mt-2">错误编号:{loadError.requestId}</div>}
+              {loadError.requestId && <div className="text-sm mt-2">错误编号:{loadError.requestId}</div>}
             </div>
           )}
           {!loading && !loadError && attachments.data?.items.length === 0 && (
@@ -245,11 +260,11 @@ export function AttachmentBindingModal({
             <table className="w-full border-collapse">
               <thead className="sticky top-0">
                 <tr style={{ background: PALE, color: NAVY }}>
-                  {["选择", "附件名称", "类型", "扩展名", "更新时间", "状态"].map((heading) => <th key={heading} className="text-left px-4 py-3 text-xs font-black">{heading}</th>)}
+                  {["选择", "附件名称", "类型", "扩展名", "更新时间", "状态"].map((heading) => <th key={heading} className="text-left px-4 py-3 text-base font-black">{heading}</th>)}
                 </tr>
               </thead>
               <tbody>
-                {attachments.data.items.map((item) => {
+                {orderedItems.map((item) => {
                   const alreadyMounted = mounted.ids.has(item.id);
                   const checked = selected.includes(item.id);
                   return (
@@ -262,14 +277,14 @@ export function AttachmentBindingModal({
                           onChange={() => setSelected((current) => toggleAttachmentSelection(current, item.id))}
                         />
                       </td>
-                  <td className="px-4 py-3"><div className="text-sm font-black">{item.documentName}</div></td>
-                      <td className="px-4 py-3 text-xs font-bold">{dictionaries.label("attachmentTypes", item.attachmentType)}</td>
-                      <td className="px-4 py-3 text-xs font-bold">{item.fileExtension}</td>
-                      <td className="px-4 py-3 text-xs font-bold">{new Date(item.updatedAt).toLocaleString("zh-CN")}</td>
+                  <td className="px-4 py-3"><div className="text-base font-black">{item.documentName}</div></td>
+                      <td className="px-4 py-3 text-base font-bold">{dictionaries.label("attachmentTypes", item.attachmentType)}</td>
+                      <td className="px-4 py-3 text-base font-bold">{item.fileExtension}</td>
+                      <td className="px-4 py-3 text-base font-bold">{new Date(item.updatedAt).toLocaleString("zh-CN")}</td>
                       <td className="px-4 py-3">
                         {alreadyMounted
-                          ? <span className="inline-flex items-center gap-1 text-xs font-black"><Check size={13} />已挂载</span>
-                          : <span className="text-xs font-bold opacity-60">可挂载</span>}
+                          ? <span className="inline-flex items-center gap-1 text-base font-black"><Check size={13} />已挂载</span>
+                          : <span className="text-base font-bold opacity-60">可挂载</span>}
                       </td>
                     </tr>
                   );
@@ -279,8 +294,8 @@ export function AttachmentBindingModal({
           )}
         </div>
 
-        {attachments.data && attachments.data.totalPages > 1 && (
-          <div className="px-4 py-2 flex justify-end gap-4 text-xs font-black" style={{ color: NAVY }}>
+        {!preferredAttachmentType && attachments.data && attachments.data.totalPages > 1 && (
+          <div className="px-4 py-2 flex justify-end gap-4 text-base font-black" style={{ color: NAVY }}>
             <button disabled={page <= 1 || submitting} onClick={() => setPage((value) => value - 1)}>上一页</button>
             <span>{page}/{attachments.data.totalPages}</span>
             <button disabled={page >= attachments.data.totalPages || submitting} onClick={() => setPage((value) => value + 1)}>下一页</button>
@@ -288,13 +303,13 @@ export function AttachmentBindingModal({
         )}
 
         <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(49,145,235,.2)", color: NAVY }}>
-          {error && <div className="mb-2 text-sm font-black"><AlertCircle size={14} className="inline mr-1" />{error.httpStatus === 403 ? "当前账号无挂载管理权限" : error.message}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
-          {notice && <div className="mb-2 text-sm font-black">{notice}</div>}
+          {error && <div className="mb-2 text-base font-black"><AlertCircle size={14} className="inline mr-1" />{error.httpStatus === 403 ? "当前账号无挂载管理权限" : error.message}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
+          {notice && <div className="mb-2 text-base font-black">{notice}</div>}
           <div className="flex items-center">
-            <span className="text-xs font-bold">已选择 {selectedCount} 个未挂载附件;翻页和搜索不会清除选择。</span>
+            <span className="text-base font-bold">已选择 {selectedCount} 个未挂载附件;翻页和搜索不会清除选择。</span>
             <div className="ml-auto flex gap-2">
-              <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={{ borderColor: BLUE }}>取消</button>
-              <button onClick={() => void handleSubmit()} disabled={submitting || selected.length === 0 || !!loadError} className="px-4 py-2 text-sm font-black text-white disabled:opacity-50" style={{ background: BLUE }}>
+              <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={{ borderColor: BLUE }}>取消</button>
+              <button onClick={() => void handleSubmit()} disabled={submitting || selected.length === 0 || !!loadError} className="px-4 py-2 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}>
                 {submitting ? "正在挂载…" : "确认挂载"}
               </button>
             </div>

+ 16 - 16
frontend/src/features/bindings/SubPlanBindingModal.tsx

@@ -155,7 +155,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
         <div className="h-12 px-5 flex items-center justify-between" style={{ background: NAVY, color: WHITE }}>
           <div>
             <span className="font-black">挂载配套方案</span>
-            <span className="ml-3 text-xs opacity-75">主案:{mainPlan.documentName}</span>
+            <span className="ml-3 text-base opacity-75">主案:{mainPlan.documentName}</span>
           </div>
           <button onClick={onClose} disabled={submitting}><X size={18} /></button>
         </div>
@@ -167,7 +167,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
               value={keyword}
               onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
               placeholder="搜索子案名称"
-              className="w-full bg-transparent outline-none text-sm font-bold"
+              className="w-full bg-transparent outline-none text-base font-bold"
             />
           </label>
         </div>
@@ -178,7 +178,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
             <div className="p-10 text-center font-bold" style={{ color: NAVY }}>
               <AlertCircle size={16} className="inline mr-2" />
               {loadError.message}
-              {loadError.requestId && <div className="text-xs mt-2">错误编号:{loadError.requestId}</div>}
+              {loadError.requestId && <div className="text-sm mt-2">错误编号:{loadError.requestId}</div>}
             </div>
           )}
           {!loading && !loadError && candidates.data?.items.length === 0 && (
@@ -188,7 +188,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
             <table className="w-full border-collapse">
               <thead className="sticky top-0">
                 <tr style={{ background: PALE, color: NAVY }}>
-                  {["选择", "子案名称", "所属分类", "密级", "更新时间", "状态"].map((heading) => <th key={heading} className="text-left px-4 py-3 text-xs font-black">{heading}</th>)}
+                  {["选择", "子案名称", "所属分类", "密级", "更新时间", "状态"].map((heading) => <th key={heading} className="text-left px-4 py-3 text-base font-black">{heading}</th>)}
                 </tr>
               </thead>
               <tbody>
@@ -205,14 +205,14 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
                           onChange={() => toggle(item.id)}
                         />
                       </td>
-                      <td className="px-4 py-3"><div className="text-sm font-black">{item.documentName}</div></td>
-                      <td className="px-4 py-3 text-xs font-bold">{item.categoryPath ?? item.categoryName ?? "-"}</td>
-                      <td className="px-4 py-3 text-xs font-bold">{dictionaries.label("securityLevels", item.securityLevel)}</td>
-                      <td className="px-4 py-3 text-xs font-bold">{new Date(item.updatedAt).toLocaleString("zh-CN")}</td>
+                      <td className="px-4 py-3"><div className="text-base font-black">{item.documentName}</div></td>
+                      <td className="px-4 py-3 text-base font-bold">{item.categoryPath ?? item.categoryName ?? "-"}</td>
+                      <td className="px-4 py-3 text-base font-bold">{dictionaries.label("securityLevels", item.securityLevel)}</td>
+                      <td className="px-4 py-3 text-base font-bold">{new Date(item.updatedAt).toLocaleString("zh-CN")}</td>
                       <td className="px-4 py-3">
                         {alreadyListed
-                          ? <span className="inline-flex items-center gap-1 text-xs font-black"><Check size={13} />已在列表</span>
-                          : <span className="text-xs font-bold opacity-60">可挂载</span>}
+                          ? <span className="inline-flex items-center gap-1 text-base font-black"><Check size={13} />已在列表</span>
+                          : <span className="text-base font-bold opacity-60">可挂载</span>}
                       </td>
                     </tr>
                   );
@@ -223,7 +223,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
         </div>
 
         {candidates.data && candidates.data.totalPages > 1 && (
-          <div className="px-4 py-2 flex justify-end gap-4 text-xs font-black" style={{ color: NAVY }}>
+          <div className="px-4 py-2 flex justify-end gap-4 text-base font-black" style={{ color: NAVY }}>
             <button disabled={page <= 1 || submitting} onClick={() => setPage((value) => value - 1)}>上一页</button>
             <span>{page}/{candidates.data.totalPages}</span>
             <button disabled={page >= candidates.data.totalPages || submitting} onClick={() => setPage((value) => value + 1)}>下一页</button>
@@ -231,13 +231,13 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
         )}
 
         <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(49,145,235,.2)", color: NAVY }}>
-          {error && <div className="mb-2 text-sm font-black"><AlertCircle size={14} className="inline mr-1" />{error.message}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
-          {notice && <div className="mb-2 text-sm font-black">{notice}</div>}
+          {error && <div className="mb-2 text-base font-black"><AlertCircle size={14} className="inline mr-1" />{error.message}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
+          {notice && <div className="mb-2 text-base font-black">{notice}</div>}
           <div className="flex items-center">
-            <span className="text-xs font-bold">已选择 {selectedCount} 个子案;挂载仅建立展示关系,权限仍继承其原主案。</span>
+            <span className="text-base font-bold">已选择 {selectedCount} 个子案;挂载仅建立展示关系,权限仍继承其原主案。</span>
             <div className="ml-auto flex gap-2">
-              <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={{ borderColor: BLUE }}>取消</button>
-              <button onClick={() => void handleSubmit()} disabled={submitting || selected.length === 0 || !!loadError} className="px-4 py-2 text-sm font-black text-white disabled:opacity-50" style={{ background: BLUE }}>
+              <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={{ borderColor: BLUE }}>取消</button>
+              <button onClick={() => void handleSubmit()} disabled={submitting || selected.length === 0 || !!loadError} className="px-4 py-2 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}>
                 {submitting ? "正在挂载…" : "确认挂载"}
               </button>
             </div>

+ 4 - 4
frontend/src/features/bindings/UnbindAttachmentModal.tsx

@@ -93,11 +93,11 @@ export function UnbindAttachmentModal({
         </div>
         <div className="p-6">
           <p className="font-black mb-4">确认解除“{mainPlan.documentName}”与“{attachment.documentName}”的挂载关系?</p>
-          <div className="text-sm font-bold">附件及其文件不会被删除。</div>
-          {error && <div className="mt-4 p-3 text-sm font-black" style={{ background: "#EBF4FD" }}><AlertCircle size={14} className="inline mr-1" />{errorMessage}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
+          <div className="text-base font-bold">附件及其文件不会被删除。</div>
+          {error && <div className="mt-4 p-3 text-base font-black" style={{ background: "#EBF4FD" }}><AlertCircle size={14} className="inline mr-1" />{errorMessage}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
           <div className="flex justify-end gap-2 mt-6">
-            <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={{ borderColor: BLUE }}>取消</button>
-            <button onClick={() => void handleUnbind()} disabled={submitting} className="px-4 py-2 text-sm font-black text-white disabled:opacity-50" style={{ background: BLUE }}>
+            <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={{ borderColor: BLUE }}>取消</button>
+            <button onClick={() => void handleUnbind()} disabled={submitting} className="px-4 py-2 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}>
               {submitting ? "正在解除…" : "仅解除挂载"}
             </button>
           </div>

+ 50 - 45
frontend/src/features/directory/DirectoryPage.tsx

@@ -28,6 +28,7 @@ import {
   type RoleCode as RoleCodeValue,
   type SecurityLevel as SecurityLevelValue,
 } from "../../shared/api/enums";
+import { useConfirm } from "../../shared/ui/ConfirmDialog";
 import { useDictionaries } from "../dictionaries/dictionaryContext";
 import type { UserSummary } from "../auth/authTypes";
 import {
@@ -109,7 +110,7 @@ export function DirectoryPage() {
         <button
           type="button"
           onClick={() => setTab("organizations")}
-          className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-black rounded-sm transition-colors"
+          className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
           style={{
             color: tab === "organizations" ? W : N,
             background: tab === "organizations" ? B : "transparent",
@@ -121,7 +122,7 @@ export function DirectoryPage() {
         <button
           type="button"
           onClick={() => setTab("users")}
-          className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-black rounded-sm transition-colors"
+          className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
           style={{
             color: tab === "users" ? W : N,
             background: tab === "users" ? B : "transparent",
@@ -133,7 +134,7 @@ export function DirectoryPage() {
         <button
           type="button"
           onClick={() => setTab("roles")}
-          className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-black rounded-sm transition-colors"
+          className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
           style={{
             color: tab === "roles" ? W : N,
             background: tab === "roles" ? B : "transparent",
@@ -145,7 +146,7 @@ export function DirectoryPage() {
         <button
           type="button"
           onClick={() => setTab("ipEntries")}
-          className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-black rounded-sm transition-colors"
+          className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
           style={{
             color: tab === "ipEntries" ? W : N,
             background: tab === "ipEntries" ? B : "transparent",
@@ -216,6 +217,7 @@ function OrganizationMaintenancePanel({
   api?: OrganizationApi;
 }) {
   const dictionaries = useDictionaries();
+  const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
   const [keyword, setKeyword] = useState("");
   const [retryVersion, setRetryVersion] = useState(0);
   const { tree, status, error } = useOrganizationTree(
@@ -328,6 +330,7 @@ function OrganizationMaintenancePanel({
         setSelectedId(created.id);
       } else if (editState.target) {
         const payload: OrganizationUpdateRequest = {
+          orgCode: form.orgCode.trim(),
           orgName: form.orgName.trim(),
           parentId: form.parentId,
           sortNo: form.sortNo,
@@ -344,7 +347,7 @@ function OrganizationMaintenancePanel({
   };
 
   const handleDelete = async (node: OrganizationNode) => {
-    if (!window.confirm(`确认删除机构「${node.orgName}」?`)) return;
+    if (!(await confirmDialog({ message: `确认删除机构「${node.orgName}」?`, danger: true }))) return;
     setMutation({ submitting: true, error: null });
     try {
       await api.delete(node.id, node.rowVersion);
@@ -367,14 +370,14 @@ function OrganizationMaintenancePanel({
             value={keyword}
             onChange={(e) => setKeyword(e.target.value)}
             placeholder="按机构名称或编码筛选"
-            className="flex-1 px-3 py-1.5 text-sm border rounded-sm outline-none"
+            className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
             style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
           />
           {keyword && (
             <button
               type="button"
               onClick={() => setKeyword("")}
-              className="text-xs font-bold opacity-60 hover:opacity-100"
+              className="text-base font-bold opacity-60 hover:opacity-100"
               style={{ color: N }}
             >
               <X size={14} />
@@ -401,7 +404,7 @@ function OrganizationMaintenancePanel({
       >
         {status === "loading" && (
           <div
-            className="text-sm font-bold text-center py-8"
+            className="text-base font-bold text-center py-8"
             style={{ color: N }}
           >
             正在加载机构树…
@@ -409,7 +412,7 @@ function OrganizationMaintenancePanel({
         )}
         {status === "error" && (
           <div
-            className="text-sm font-bold text-center py-8"
+            className="text-base font-bold text-center py-8"
             style={{ color: DANGER }}
           >
             {error?.message ?? "加载失败"}
@@ -417,7 +420,7 @@ function OrganizationMaintenancePanel({
         )}
         {status !== "loading" && status !== "error" && tree.length === 0 && (
           <div
-            className="text-sm font-bold text-center py-8"
+            className="text-base font-bold text-center py-8"
             style={{ color: N }}
           >
             暂无机构数据,点击右上角"新建顶层机构"开始维护。
@@ -449,7 +452,7 @@ function OrganizationMaintenancePanel({
 
       {mutation.error && (
         <div
-          className="flex-shrink-0 px-6 py-2 text-xs font-bold border-t"
+          className="flex-shrink-0 px-6 py-2 text-base font-bold border-t"
           style={{
             color: DANGER,
             background: "rgba(192,57,43,0.05)",
@@ -493,6 +496,7 @@ function OrganizationMaintenancePanel({
           statusLabel={(code) => dictionaries.label("enabledStatuses", code)}
         />
       )}
+      {confirmDialogNode}
     </div>
   );
 }
@@ -551,17 +555,17 @@ function OrganizationTreeRow({
         ) : (
           <span className="inline-block w-[13px]" />
         )}
-        <span className="text-sm font-black" style={{ color: N }}>
+        <span className="text-base font-black" style={{ color: N }}>
           {node.orgName}
         </span>
         <span
-          className="text-xs font-bold opacity-60"
+          className="text-base font-bold opacity-60"
           style={{ color: N }}
         >
           ({node.orgCode})
         </span>
         <span
-          className="text-xs font-bold px-1.5 py-0.5 rounded-sm"
+          className="text-base font-bold px-1.5 py-0.5 rounded-sm"
           style={{
             color: node.status === EnabledStatus.ENABLED ? N : DANGER,
             background:
@@ -573,7 +577,7 @@ function OrganizationTreeRow({
           {dictionaries.label("enabledStatuses", node.status)}
         </span>
         <span
-          className="text-xs font-bold opacity-50 flex-1 truncate ml-2"
+          className="text-base font-bold opacity-50 flex-1 truncate ml-2"
           style={{ color: N }}
         >
           {node.orgPath}
@@ -684,7 +688,6 @@ function OrganizationEditorModal({
           label="机构编码"
           value={form.orgCode}
           onChange={(v) => onChange({ orgCode: v })}
-          disabled={isEdit}
           placeholder="如:ORG_HQ"
         />
         <LabeledInput
@@ -791,6 +794,7 @@ function UserMaintenancePanel({
   roleApiImpl?: RoleApi;
 }) {
   const dictionaries = useDictionaries();
+  const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
   const [orgKeyword, setOrgKeyword] = useState("");
   const [orgRetry, setOrgRetry] = useState(0);
   // 功能角色下拉数据(仅 roleCode=USER 时可选)
@@ -933,7 +937,7 @@ function UserMaintenancePanel({
   };
 
   const handleDeleteUser = async (user: UserSummary) => {
-    if (!window.confirm(`确认删除用户「${user.realName}」?`)) return;
+    if (!(await confirmDialog({ message: `确认删除用户「${user.realName}」?`, danger: true }))) return;
     setMutation({ submitting: true, error: null });
     try {
       await userApiImpl.delete(user.id, user.rowVersion);
@@ -976,7 +980,7 @@ function UserMaintenancePanel({
         }}
       >
         <div
-          className="flex-shrink-0 px-4 py-2 border-b text-xs font-black tracking-wide"
+          className="flex-shrink-0 px-4 py-2 border-b text-base font-black tracking-wide"
           style={{
             color: N,
             borderColor: "rgba(49,145,235,0.15)",
@@ -990,7 +994,7 @@ function UserMaintenancePanel({
             value={orgKeyword}
             onChange={(e) => setOrgKeyword(e.target.value)}
             placeholder="按机构名称或编码筛选"
-            className="w-full px-2.5 py-1.5 text-xs border rounded-sm outline-none"
+            className="w-full px-2.5 py-1.5 text-base border rounded-sm outline-none"
             style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
           />
         </div>
@@ -998,7 +1002,7 @@ function UserMaintenancePanel({
           <button
             type="button"
             onClick={() => setSelectedOrgId(null)}
-            className="w-full text-left px-3 py-1.5 text-xs font-bold transition-colors"
+            className="w-full text-left px-3 py-1.5 text-base font-bold transition-colors"
             style={{
               color: selectedOrgId === null ? W : N,
               background: selectedOrgId === null ? B : "transparent",
@@ -1030,7 +1034,7 @@ function UserMaintenancePanel({
           style={{ borderColor: "rgba(49,145,235,0.15)" }}
         >
           <label
-            className="flex items-center gap-2 text-xs font-bold cursor-pointer"
+            className="flex items-center gap-2 text-base font-bold cursor-pointer"
             style={{ color: N }}
           >
             <input
@@ -1061,14 +1065,14 @@ function UserMaintenancePanel({
                 setPage(1);
               }}
               placeholder="按用户名或姓名筛选"
-              className="flex-1 px-3 py-1.5 text-sm border rounded-sm outline-none"
+              className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
               style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
             />
             {userKeyword && (
               <button
                 type="button"
                 onClick={() => setUserKeyword("")}
-                className="text-xs font-bold opacity-60 hover:opacity-100"
+                className="text-base font-bold opacity-60 hover:opacity-100"
                 style={{ color: N }}
               >
                 <X size={14} />
@@ -1082,7 +1086,7 @@ function UserMaintenancePanel({
                 );
                 setPage(1);
               }}
-              className="px-2 py-1.5 text-sm border rounded-sm outline-none"
+              className="px-2 py-1.5 text-base border rounded-sm outline-none"
               style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
             >
               <option value="">全部状态</option>
@@ -1113,7 +1117,7 @@ function UserMaintenancePanel({
         >
           {status === "loading" && (
             <div
-              className="text-sm font-bold text-center py-8"
+              className="text-base font-bold text-center py-8"
               style={{ color: N }}
             >
               正在加载用户列表…
@@ -1121,7 +1125,7 @@ function UserMaintenancePanel({
           )}
           {status === "error" && (
             <div
-              className="text-sm font-bold text-center py-8"
+              className="text-base font-bold text-center py-8"
               style={{ color: DANGER }}
             >
               {error?.message ?? "加载失败"}
@@ -1129,7 +1133,7 @@ function UserMaintenancePanel({
           )}
           {status !== "loading" && status !== "error" && data.items.length === 0 && (
             <div
-              className="text-sm font-bold text-center py-8"
+              className="text-base font-bold text-center py-8"
               style={{ color: N }}
             >
               暂无用户,请点击右上角"新建用户"。
@@ -1137,7 +1141,7 @@ function UserMaintenancePanel({
           )}
           {data.items.length > 0 && (
             <table
-              className="w-full text-sm"
+              className="w-full text-base"
               style={{ background: W, borderCollapse: "collapse" }}
             >
               <thead>
@@ -1154,7 +1158,7 @@ function UserMaintenancePanel({
                   ].map((h) => (
                     <th
                       key={h}
-                      className="text-left px-3 py-2 text-xs font-black border"
+                      className="text-left px-3 py-2 text-base font-black border"
                       style={{
                         color: N,
                         borderColor: "rgba(49,145,235,0.2)",
@@ -1175,24 +1179,24 @@ function UserMaintenancePanel({
                     <td className="px-3 py-2 font-bold" style={{ color: N }}>
                       {user.username}
                     </td>
-                    <td className="px-3 py-2" style={{ color: N }}>
+                    <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
                       {user.realName}
                     </td>
-                    <td className="px-3 py-2" style={{ color: N }}>
+                    <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
                       {user.organizationName || "—"}
                     </td>
-                    <td className="px-3 py-2 text-xs font-bold" style={{ color: N }}>
+                    <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
                       {user.roleCode === RoleCode.USER && user.roleName
                         ? user.roleName
                         : dictionaries.label("roleCodes", user.roleCode)}
                     </td>
-                    <td className="px-3 py-2 text-xs font-bold" style={{ color: N }}>
+                    <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
                       {dictionaries.label("securityLevels", user.securityLevel)}
                     </td>
-                    <td className="px-3 py-2 text-xs font-bold" style={{ color: N }}>
+                    <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
                       {dictionaries.label("enabledStatuses", user.status)}
                     </td>
-                    <td className="px-3 py-2 text-xs" style={{ color: N }}>
+                    <td className="px-3 py-2 text-base" style={{ color: N }}>
                       {user.lastLoginAt ?? "—"}
                     </td>
                     <td className="px-3 py-2">
@@ -1227,7 +1231,7 @@ function UserMaintenancePanel({
         </div>
 
         <div
-          className="flex-shrink-0 flex items-center justify-between px-6 py-2 border-t text-xs font-bold"
+          className="flex-shrink-0 flex items-center justify-between px-6 py-2 border-t text-base font-bold"
           style={{
             color: N,
             background: W,
@@ -1242,7 +1246,7 @@ function UserMaintenancePanel({
               type="button"
               onClick={() => setPage((p) => Math.max(1, p - 1))}
               disabled={data.page <= 1}
-              className="px-2.5 py-1 text-xs font-bold rounded-sm disabled:opacity-40"
+              className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
               style={{ border: `1px solid ${B}`, color: N, background: W }}
             >
               上一页
@@ -1255,7 +1259,7 @@ function UserMaintenancePanel({
                 )
               }
               disabled={data.page >= data.totalPages}
-              className="px-2.5 py-1 text-xs font-bold rounded-sm disabled:opacity-40"
+              className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
               style={{ border: `1px solid ${B}`, color: N, background: W }}
             >
               下一页
@@ -1266,7 +1270,7 @@ function UserMaintenancePanel({
 
       {mutation.error && (
         <div
-          className="flex-shrink-0 px-6 py-2 text-xs font-bold"
+          className="flex-shrink-0 px-6 py-2 text-base font-bold"
           style={{ color: DANGER, background: "rgba(192,57,43,0.05)" }}
         >
           {mutation.error}
@@ -1310,6 +1314,7 @@ function UserMaintenancePanel({
           submitting={mutation.submitting}
         />
       )}
+      {confirmDialogNode}
     </div>
   );
 }
@@ -1337,7 +1342,7 @@ function UserOrgTreeRow({
   return (
     <>
       <div
-        className="flex items-center gap-1.5 px-3 py-1.5 cursor-pointer rounded-sm transition-colors hover:bg-[rgba(49,145,235,0.18)]"
+        className={`flex items-center gap-1.5 px-3 py-1.5 cursor-pointer rounded-sm transition-colors hover:bg-[rgba(49,145,235,0.18)] ${isSelected ? "bg-[rgba(49,145,235,0.08)]" : ""}`}
         style={{ paddingLeft: 12 + depth * 14 }}
         onClick={() => onSelect(node.id)}
       >
@@ -1356,12 +1361,12 @@ function UserOrgTreeRow({
         ) : (
           <span className="inline-block w-[11px]" />
         )}
-        <span className="text-xs font-bold truncate flex-1" style={{ color: isSelected ? W : N }}>
+        <span className="text-base font-bold truncate flex-1" style={{ color: N }}>
           {node.orgName}
         </span>
         {isSelected && (
           <span
-            className="text-[10px] font-black px-1.5 py-0.5 rounded-sm"
+            className="text-base font-black px-1.5 py-0.5 rounded-sm"
             style={{ background: B, color: W }}
           >
             已选
@@ -1485,7 +1490,7 @@ function UserEditorModal({
           ]}
         />
         <LabeledSelect<RoleCodeValue>
-          label="角色"
+          label="用户类型"
           value={form.roleCode}
           onChange={(v) =>
             onChange(
@@ -1498,7 +1503,7 @@ function UserEditorModal({
         />
         {form.roleCode === RoleCode.USER && (
           <LabeledSelect<string>
-            label="功能角色"
+            label="角色"
             value={form.roleId ?? ""}
             onChange={(v) => onChange({ roleId: v || null })}
             options={[
@@ -1552,7 +1557,7 @@ function ResetPasswordModal({
     >
       <div className="space-y-3">
         <div
-          className="text-xs font-bold flex items-center gap-1 p-2 rounded-sm"
+          className="text-base font-bold flex items-center gap-1 p-2 rounded-sm"
           style={{ color: DANGER, background: "rgba(192,57,43,0.05)" }}
         >
           <Lock size={12} />

+ 17 - 14
frontend/src/features/directory/IpEntryMaintenancePanel.tsx

@@ -1,4 +1,5 @@
 import { useCallback, useEffect, useRef, useState } from "react";
+import { useConfirm } from "../../shared/ui/ConfirmDialog";
 import { Edit2, Info, Plus, RefreshCw, Trash2, X } from "lucide-react";
 import type { PageResult } from "../../shared/api/types";
 import { ipEntryApi, type IpEntryApi } from "./ipEntryApi";
@@ -58,6 +59,7 @@ export function IpEntryMaintenancePanel({
 }: {
   api?: IpEntryApi;
 }) {
+  const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
   const [keyword, setKeyword] = useState("");
   const [page, setPage] = useState(1);
   const pageSize = 20;
@@ -166,7 +168,7 @@ export function IpEntryMaintenancePanel({
   };
 
   const handleDelete = async (entry: IpEntrySummary) => {
-    if (!window.confirm(`确认删除IP「${entry.ipValue}」?`)) return;
+    if (!(await confirmDialog({ message: `确认删除IP「${entry.ipValue}」?`, danger: true }))) return;
     setMutation({ submitting: true, error: null });
     try {
       await api.delete(entry.id, entry.rowVersion);
@@ -179,6 +181,7 @@ export function IpEntryMaintenancePanel({
 
   return (
     <div className="w-full h-full flex flex-col overflow-hidden">
+      {confirmDialogNode}
       <div
         className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
         style={{ background: W, borderColor: "rgba(49,145,235,0.15)" }}
@@ -191,14 +194,14 @@ export function IpEntryMaintenancePanel({
               setPage(1);
             }}
             placeholder="按IP或备注筛选"
-            className="flex-1 px-3 py-1.5 text-sm border rounded-sm outline-none"
+            className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
             style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
           />
           {keyword && (
             <button
               type="button"
               onClick={() => setKeyword("")}
-              className="text-xs font-bold opacity-60 hover:opacity-100"
+              className="text-base font-bold opacity-60 hover:opacity-100"
               style={{ color: N }}
             >
               <X size={14} />
@@ -220,7 +223,7 @@ export function IpEntryMaintenancePanel({
       </div>
 
       <div
-        className="flex-shrink-0 flex items-center gap-1.5 px-6 py-2 text-xs font-bold border-b"
+        className="flex-shrink-0 flex items-center gap-1.5 px-6 py-2 text-base font-bold border-b"
         style={{
           color: N,
           background: "rgba(49,145,235,0.05)",
@@ -237,7 +240,7 @@ export function IpEntryMaintenancePanel({
       >
         {listStatus === "loading" && (
           <div
-            className="text-sm font-bold text-center py-8"
+            className="text-base font-bold text-center py-8"
             style={{ color: N }}
           >
             正在加载IP条目…
@@ -245,7 +248,7 @@ export function IpEntryMaintenancePanel({
         )}
         {listStatus === "error" && (
           <div
-            className="text-sm font-bold text-center py-8"
+            className="text-base font-bold text-center py-8"
             style={{ color: DANGER }}
           >
             {listError ?? "加载失败"}
@@ -253,7 +256,7 @@ export function IpEntryMaintenancePanel({
         )}
         {listStatus === "success" && data.items.length === 0 && (
           <div
-            className="text-sm font-bold text-center py-8"
+            className="text-base font-bold text-center py-8"
             style={{ color: N }}
           >
             暂无IP条目,点击右上角"新建IP"开始维护。
@@ -261,7 +264,7 @@ export function IpEntryMaintenancePanel({
         )}
         {data.items.length > 0 && (
           <table
-            className="w-full text-sm"
+            className="w-full text-base"
             style={{ background: W, borderCollapse: "collapse" }}
           >
             <thead>
@@ -269,7 +272,7 @@ export function IpEntryMaintenancePanel({
                 {["IP地址/网段", "备注", "操作"].map((h) => (
                   <th
                     key={h}
-                    className="text-left px-3 py-2 text-xs font-black border"
+                    className="text-left px-3 py-2 text-base font-black border"
                     style={{
                       color: N,
                       borderColor: "rgba(49,145,235,0.2)",
@@ -316,7 +319,7 @@ export function IpEntryMaintenancePanel({
       </div>
 
       <div
-        className="flex-shrink-0 flex items-center justify-between px-6 py-2 border-t text-xs font-bold"
+        className="flex-shrink-0 flex items-center justify-between px-6 py-2 border-t text-base font-bold"
         style={{
           color: N,
           background: W,
@@ -332,7 +335,7 @@ export function IpEntryMaintenancePanel({
             type="button"
             onClick={() => setPage((p) => Math.max(1, p - 1))}
             disabled={data.page <= 1}
-            className="px-2.5 py-1 text-xs font-bold rounded-sm disabled:opacity-40"
+            className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
             style={{ border: `1px solid ${B}`, color: N, background: W }}
           >
             上一页
@@ -343,7 +346,7 @@ export function IpEntryMaintenancePanel({
               setPage((p) => Math.min(Math.max(data.totalPages, 1), p + 1))
             }
             disabled={data.page >= data.totalPages}
-            className="px-2.5 py-1 text-xs font-bold rounded-sm disabled:opacity-40"
+            className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
             style={{ border: `1px solid ${B}`, color: N, background: W }}
           >
             下一页
@@ -353,7 +356,7 @@ export function IpEntryMaintenancePanel({
 
       {mutation.error && (
         <div
-          className="flex-shrink-0 px-6 py-2 text-xs font-bold border-t"
+          className="flex-shrink-0 px-6 py-2 text-base font-bold border-t"
           style={{
             color: DANGER,
             background: "rgba(192,57,43,0.05)",
@@ -414,7 +417,7 @@ function IpEntryEditorModal({
           placeholder="如:192.168.1.5 或 10.0.0.0/24"
         />
         <div
-          className="text-[11px] font-bold px-2 py-1.5 rounded-sm"
+          className="text-base font-bold px-2 py-1.5 rounded-sm"
           style={{ color: N, background: "rgba(49,145,235,0.05)" }}
         >
           支持精确IP(IPv4/IPv6)或无主机位的CIDR网段;输入会被自动规范化,

+ 10 - 10
frontend/src/features/directory/OrgIpAssignmentModal.tsx

@@ -115,7 +115,7 @@ export function OrgIpAssignmentModal({
     >
       <div className="space-y-3">
         <div
-          className="text-xs font-bold px-2 py-1.5 rounded-sm"
+          className="text-base font-bold px-2 py-1.5 rounded-sm"
           style={{ color: N, background: "rgba(49,145,235,0.05)" }}
         >
           勾选该部门用户登录时允许的来源IP;不勾选任何条目表示不限制。
@@ -123,7 +123,7 @@ export function OrgIpAssignmentModal({
         </div>
         {loading && (
           <div
-            className="text-sm font-bold text-center py-4"
+            className="text-base font-bold text-center py-4"
             style={{ color: N }}
           >
             正在加载IP池与当前分配…
@@ -131,7 +131,7 @@ export function OrgIpAssignmentModal({
         )}
         {loadError && (
           <div
-            className="text-xs font-bold px-2 py-1.5 rounded-sm"
+            className="text-base font-bold px-2 py-1.5 rounded-sm"
             style={{ color: DANGER, background: "rgba(192,57,43,0.05)" }}
           >
             {loadError}
@@ -143,12 +143,12 @@ export function OrgIpAssignmentModal({
               value={filter}
               onChange={(e) => setFilter(e.target.value)}
               placeholder="本地过滤IP或备注"
-              className="w-full px-2.5 py-1.5 text-xs border rounded-sm outline-none"
+              className="w-full px-2.5 py-1.5 text-base border rounded-sm outline-none"
               style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
             />
             {poolTotal > POOL_PAGE_SIZE && (
               <div
-                className="text-[11px] font-bold px-2 py-1 rounded-sm"
+                className="text-base font-bold px-2 py-1 rounded-sm"
                 style={{ color: DANGER, background: "rgba(192,57,43,0.05)" }}
               >
                 IP池共 {poolTotal} 条,仅加载前 {POOL_PAGE_SIZE} 条;未展示的已分配条目仍会保留。
@@ -160,7 +160,7 @@ export function OrgIpAssignmentModal({
             >
               {visibleEntries.length === 0 && (
                 <div
-                  className="text-xs font-bold text-center py-4"
+                  className="text-base font-bold text-center py-4"
                   style={{ color: N }}
                 >
                   {pool.length === 0
@@ -180,13 +180,13 @@ export function OrgIpAssignmentModal({
                     onChange={() => toggle(entry.id)}
                   />
                   <span
-                    className="text-xs font-black font-mono"
+                    className="text-base font-black font-mono"
                     style={{ color: N }}
                   >
                     {entry.ipValue}
                   </span>
                   <span
-                    className="text-xs font-bold opacity-60 truncate flex-1"
+                    className="text-base font-bold opacity-60 truncate flex-1"
                     style={{ color: N }}
                   >
                     {entry.remark || "—"}
@@ -195,7 +195,7 @@ export function OrgIpAssignmentModal({
               ))}
             </div>
             <div
-              className="text-[11px] font-bold text-right"
+              className="text-base font-bold text-right"
               style={{ color: N }}
             >
               已选 {selected.size} 个
@@ -205,7 +205,7 @@ export function OrgIpAssignmentModal({
         )}
         {saveError && (
           <div
-            className="text-xs font-bold px-2 py-1.5 rounded-sm"
+            className="text-base font-bold px-2 py-1.5 rounded-sm"
             style={{ color: DANGER, background: "rgba(192,57,43,0.05)" }}
           >
             {saveError}

+ 32 - 214
frontend/src/features/directory/RoleMaintenancePanel.tsx

@@ -5,9 +5,8 @@ import {
   useRef,
   useState,
 } from "react";
+import { useConfirm } from "../../shared/ui/ConfirmDialog";
 import {
-  ChevronDown,
-  ChevronRight,
   Edit2,
   Plus,
   RefreshCw,
@@ -24,8 +23,6 @@ import {
 } from "../../shared/api/enums";
 import type { PageResult } from "../../shared/api/types";
 import { useDictionaries } from "../dictionaries/dictionaryContext";
-import { flattenOrganizations, useOrganizationTree } from "./directoryState";
-import { organizationApi, type OrganizationApi } from "./organizationApi";
 import {
   roleApi,
   type RoleApi,
@@ -38,11 +35,9 @@ import {
   type RoleSummary,
   type RoleUpdateRequest,
 } from "./roleTypes";
-import type { OrganizationNode } from "./directoryTypes";
 import {
   B,
   DANGER,
-  MAX_TREE_DEPTH,
   N,
   PBG,
   W,
@@ -114,16 +109,15 @@ function buildRoleForm(mode: "create" | "edit", target: RoleSummary | null): Rol
   };
 }
 
-type SectionKey = "departments" | "matrix";
+type SectionKey = "matrix";
 
 export function RoleMaintenancePanel({
   api = roleApi,
-  orgApiImpl = organizationApi,
 }: {
   api?: RoleApi;
-  orgApiImpl?: OrganizationApi;
 }) {
   const dictionaries = useDictionaries();
+  const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
   const [keyword, setKeyword] = useState("");
   const [statusFilter, setStatusFilter] = useState<EnabledStatusCode | "">("");
   const [page, setPage] = useState(1);
@@ -140,8 +134,6 @@ export function RoleMaintenancePanel({
   const [detailReload, setDetailReload] = useState(0);
   const [detailLoading, setDetailLoading] = useState(false);
   const [detailError, setDetailError] = useState<string | null>(null);
-  const [deptDraft, setDeptDraft] = useState<ReadonlySet<string>>(new Set());
-  const [deptDirty, setDeptDirty] = useState(false);
   const [matrix, setMatrix] = useState<RoleMatrixRow[]>([]);
   const [matrixDirty, setMatrixDirty] = useState(false);
   const [saving, setSaving] = useState<SectionKey | null>(null);
@@ -187,29 +179,15 @@ export function RoleMaintenancePanel({
     [data.items, selectedId],
   );
 
-  // 部门树(全量,供角色可访问部门多选)
-  const [orgRetry, setOrgRetry] = useState(0);
-  const [orgExpanded, setOrgExpanded] = useState<Set<string>>(() => new Set());
-  const { tree } = useOrganizationTree("", orgRetry, orgApiImpl);
-  const initialExpandDone = useRef(false);
-  useEffect(() => {
-    if (!initialExpandDone.current && tree.length > 0) {
-      initialExpandDone.current = true;
-      setOrgExpanded(new Set(flattenOrganizations(tree).map((n) => n.id)));
-    }
-  }, [tree]);
-
-  // 选中角色后加载部门配置与权限矩阵
+  // 选中角色后加载权限矩阵
   useEffect(() => {
     if (!selectedId) return;
     let cancelled = false;
     setDetailLoading(true);
     setDetailError(null);
-    Promise.all([api.getDepartments(selectedId), api.getPermissions(selectedId)])
-      .then(([departmentIds, rows]) => {
+    api.getPermissions(selectedId)
+      .then((rows) => {
         if (cancelled) return;
-        setDeptDraft(new Set(departmentIds));
-        setDeptDirty(false);
         setMatrix(orderMatrix(rows));
         setMatrixDirty(false);
         setDetailLoading(false);
@@ -226,7 +204,7 @@ export function RoleMaintenancePanel({
 
   const refresh = useCallback(() => setRetry((v) => v + 1), []);
 
-  /** 保存部门/矩阵后同步列表内角色的 rowVersion,保证连续保存的乐观锁正确。 */
+  /** 保存矩阵后同步列表内角色的 rowVersion,保证连续保存的乐观锁正确。 */
   const bumpRowVersion = useCallback((id: string, rowVersion: number) => {
     setData((current) => ({
       ...current,
@@ -239,7 +217,6 @@ export function RoleMaintenancePanel({
   const selectRole = (id: string) => {
     setSelectedId(id);
     setDetailError(null);
-    setDeptDirty(false);
     setMatrixDirty(false);
   };
 
@@ -310,13 +287,12 @@ export function RoleMaintenancePanel({
   };
 
   const handleDeleteRole = async (role: RoleSummary) => {
-    if (!window.confirm(`确认删除角色「${role.roleName}」?`)) return;
+    if (!(await confirmDialog({ message: `确认删除角色「${role.roleName}」?`, danger: true }))) return;
     setMutation({ submitting: true, error: null });
     try {
       await api.delete(role.id, role.rowVersion);
       if (selectedId === role.id) {
         setSelectedId(null);
-        setDeptDirty(false);
         setMatrixDirty(false);
       }
       setMutation(initialResult);
@@ -326,36 +302,6 @@ export function RoleMaintenancePanel({
     }
   };
 
-  const toggleDepartment = (id: string) => {
-    setDeptDraft((current) => {
-      const next = new Set(current);
-      if (next.has(id)) next.delete(id);
-      else next.add(id);
-      return next;
-    });
-    setDeptDirty(true);
-  };
-
-  const saveDepartments = async () => {
-    if (!selected) return;
-    setSaving("departments");
-    setDetailError(null);
-    try {
-      const result = await api.updateDepartments(
-        selected.id,
-        [...deptDraft],
-        selected.rowVersion,
-      );
-      setDeptDraft(new Set(result.organizationIds));
-      setDeptDirty(false);
-      bumpRowVersion(selected.id, result.rowVersion);
-      setSaving(null);
-    } catch (error) {
-      setDetailError(describeError(error));
-      setSaving(null);
-    }
-  };
-
   const toggleCell = (index: number, field: MatrixActionField, checked: boolean) => {
     setMatrix((current) =>
       current.map((row, i) => (i === index ? { ...row, [field]: checked } : row)),
@@ -412,6 +358,7 @@ export function RoleMaintenancePanel({
 
   return (
     <div className="w-full h-full flex overflow-hidden">
+      {confirmDialogNode}
       {/* 左侧:角色列表 */}
       <div
         className="flex-shrink-0 border-r flex flex-col"
@@ -422,7 +369,7 @@ export function RoleMaintenancePanel({
         }}
       >
         <div
-          className="flex-shrink-0 px-4 py-2 border-b text-xs font-black tracking-wide"
+          className="flex-shrink-0 px-4 py-2 border-b text-base font-black tracking-wide"
           style={{
             color: N,
             borderColor: "rgba(49,145,235,0.15)",
@@ -442,14 +389,14 @@ export function RoleMaintenancePanel({
               setPage(1);
             }}
             placeholder="按角色名称筛选"
-            className="flex-1 px-2.5 py-1.5 text-xs border rounded-sm outline-none"
+            className="flex-1 px-2.5 py-1.5 text-base border rounded-sm outline-none"
             style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
           />
           {keyword && (
             <button
               type="button"
               onClick={() => setKeyword("")}
-              className="text-xs font-bold opacity-60 hover:opacity-100"
+              className="text-base font-bold opacity-60 hover:opacity-100"
               style={{ color: N }}
             >
               <X size={14} />
@@ -463,7 +410,7 @@ export function RoleMaintenancePanel({
               );
               setPage(1);
             }}
-            className="px-2 py-1.5 text-xs border rounded-sm outline-none"
+            className="px-2 py-1.5 text-base border rounded-sm outline-none"
             style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
           >
             <option value="">全部状态</option>
@@ -477,7 +424,7 @@ export function RoleMaintenancePanel({
         <div className="flex-1 min-h-0 overflow-auto py-1">
           {listStatus === "loading" && (
             <div
-              className="text-xs font-bold text-center py-6"
+              className="text-base font-bold text-center py-6"
               style={{ color: N }}
             >
               正在加载角色…
@@ -485,7 +432,7 @@ export function RoleMaintenancePanel({
           )}
           {listStatus === "error" && (
             <div
-              className="text-xs font-bold text-center py-6"
+              className="text-base font-bold text-center py-6"
               style={{ color: DANGER }}
             >
               {listError ?? "加载失败"}
@@ -493,7 +440,7 @@ export function RoleMaintenancePanel({
           )}
           {listStatus === "success" && data.items.length === 0 && (
             <div
-              className="text-xs font-bold text-center py-6"
+              className="text-base font-bold text-center py-6"
               style={{ color: N }}
             >
               暂无角色,点击下方"新建角色"。
@@ -515,21 +462,21 @@ export function RoleMaintenancePanel({
                 <div className="flex-1 min-w-0">
                   <div className="flex items-center gap-1.5">
                     <span
-                      className="text-sm font-black truncate"
+                      className="text-base font-black truncate"
                       style={{ color: N }}
                     >
                       {role.roleName}
                     </span>
                     {role.isDefault && (
                       <span
-                        className="text-[10px] font-black px-1.5 py-0.5 rounded-sm flex-shrink-0"
+                        className="text-base font-black px-1.5 py-0.5 rounded-sm flex-shrink-0"
                         style={{ background: B, color: W }}
                       >
                         默认
                       </span>
                     )}
                     <span
-                      className="text-[10px] font-bold px-1.5 py-0.5 rounded-sm flex-shrink-0"
+                      className="text-base font-bold px-1.5 py-0.5 rounded-sm flex-shrink-0"
                       style={{
                         color:
                           role.status === EnabledStatus.ENABLED
@@ -545,7 +492,7 @@ export function RoleMaintenancePanel({
                     </span>
                   </div>
                   <div
-                    className="text-[11px] font-bold opacity-60 mt-0.5"
+                    className="text-base font-bold opacity-60 mt-0.5"
                     style={{ color: N }}
                   >
                     成员 {role.memberCount} 人 · 排序 {role.sortNo}
@@ -573,7 +520,7 @@ export function RoleMaintenancePanel({
           })}
           {data.items.length > 0 && (
             <div
-              className="flex items-center justify-between px-3 py-2 text-[11px] font-bold"
+              className="flex items-center justify-between px-3 py-2 text-base font-bold"
               style={{ color: N }}
             >
               <span>
@@ -623,14 +570,14 @@ export function RoleMaintenancePanel({
         </div>
       </div>
 
-      {/* 右侧:角色详情(可访问部门 + 权限矩阵) */}
+      {/* 右侧:角色详情(权限矩阵) */}
       <div className="flex-1 min-w-0 flex flex-col overflow-hidden">
         {!selected && (
           <div
-            className="flex-1 flex items-center justify-center text-sm font-bold"
+            className="flex-1 flex items-center justify-center text-base font-bold"
             style={{ color: N, background: PBG }}
           >
-            请在左侧选择一个角色查看可访问部门与权限矩阵。
+            请在左侧选择一个角色查看权限矩阵。
           </div>
         )}
         {selected && (
@@ -643,12 +590,12 @@ export function RoleMaintenancePanel({
               style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
             >
               <div className="flex-1">
-                <div className="text-sm font-black" style={{ color: N }}>
+                <div className="text-base font-black" style={{ color: N }}>
                   {selected.roleName}
                   {selected.isDefault && "(默认角色)"}
                 </div>
                 <div
-                  className="text-xs font-bold opacity-70 mt-0.5"
+                  className="text-base font-bold opacity-70 mt-0.5"
                   style={{ color: N }}
                 >
                   成员 {selected.memberCount} 人 · rowVersion{" "}
@@ -665,7 +612,7 @@ export function RoleMaintenancePanel({
 
             {detailLoading && (
               <div
-                className="text-sm font-bold text-center py-4"
+                className="text-base font-bold text-center py-4"
                 style={{ color: N }}
               >
                 正在加载角色配置…
@@ -673,7 +620,7 @@ export function RoleMaintenancePanel({
             )}
             {detailError && (
               <div
-                className="text-xs font-bold px-4 py-2 rounded-sm"
+                className="text-base font-bold px-4 py-2 rounded-sm"
                 style={{
                   color: DANGER,
                   background: "rgba(192,57,43,0.05)",
@@ -685,66 +632,6 @@ export function RoleMaintenancePanel({
 
             {!detailLoading && (
               <>
-                {/* 可访问部门 */}
-                <section
-                  className="rounded-sm border overflow-hidden"
-                  style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
-                >
-                  <div
-                    className="flex items-center justify-between px-4 py-2 border-b"
-                    style={{
-                      borderColor: "rgba(49,145,235,0.15)",
-                      background: PBG,
-                    }}
-                  >
-                    <span className="text-xs font-black" style={{ color: N }}>
-                      可访问部门(此处配置除所属部门外的其他部门权限。已选{" "}
-                      {deptDraft.size} 个)
-                    </span>
-                    <div className="flex items-center gap-1.5">
-                      {smallButton(false, {
-                        label: "刷新机构树",
-                        icon: <RefreshCw size={12} />,
-                        onClick: () => setOrgRetry((v) => v + 1),
-                      })}
-                      {smallButton(deptDirty, {
-                        label: saving === "departments" ? "保存中…" : "保存部门",
-                        icon: <Plus size={12} />,
-                        onClick: saveDepartments,
-                        disabled: saving !== null || !deptDirty,
-                      })}
-                    </div>
-                  </div>
-                  <div className="max-h-64 overflow-auto py-1">
-                    {tree.length === 0 && (
-                      <div
-                        className="text-xs font-bold text-center py-4"
-                        style={{ color: N }}
-                      >
-                        暂无机构数据。
-                      </div>
-                    )}
-                    {tree.map((node) => (
-                      <RoleDeptTreeRow
-                        key={node.id}
-                        node={node}
-                        depth={0}
-                        expanded={orgExpanded}
-                        onToggle={(id) =>
-                          setOrgExpanded((current) => {
-                            const next = new Set(current);
-                            if (next.has(id)) next.delete(id);
-                            else next.add(id);
-                            return next;
-                          })
-                        }
-                        selectedIds={deptDraft}
-                        onToggleCheck={toggleDepartment}
-                      />
-                    ))}
-                  </div>
-                </section>
-
                 {/* 权限矩阵 */}
                 <section
                   className="rounded-sm border overflow-hidden"
@@ -757,7 +644,7 @@ export function RoleMaintenancePanel({
                       background: PBG,
                     }}
                   >
-                    <span className="text-xs font-black" style={{ color: N }}>
+                    <span className="text-base font-black" style={{ color: N }}>
                       权限矩阵(密级 × 部门范围 × 操作)
                     </span>
                     {smallButton(matrixDirty, {
@@ -769,7 +656,7 @@ export function RoleMaintenancePanel({
                   </div>
                   <div className="overflow-auto">
                     <table
-                      className="w-full text-sm"
+                      className="w-full text-base"
                       style={{ borderCollapse: "collapse" }}
                     >
                       <thead>
@@ -922,7 +809,7 @@ export function RoleMaintenancePanel({
 
       {mutation.error && (
         <div
-          className="flex-shrink-0 px-6 py-2 text-xs font-bold border-t"
+          className="flex-shrink-0 px-6 py-2 text-base font-bold border-t"
           style={{
             color: DANGER,
             background: "rgba(192,57,43,0.05)",
@@ -936,75 +823,6 @@ export function RoleMaintenancePanel({
   );
 }
 
-function RoleDeptTreeRow({
-  node,
-  depth,
-  expanded,
-  onToggle,
-  selectedIds,
-  onToggleCheck,
-}: {
-  node: OrganizationNode;
-  depth: number;
-  expanded: ReadonlySet<string>;
-  onToggle: (id: string) => void;
-  selectedIds: ReadonlySet<string>;
-  onToggleCheck: (id: string) => void;
-}) {
-  // 防御性:避免极端深度时栈溢出(后端 _descendants 已做循环检测,此处为前端二次保护)
-  if (depth >= MAX_TREE_DEPTH) return null;
-  const isOpen = expanded.has(node.id);
-  const isChecked = selectedIds.has(node.id);
-  const hasChildren = node.children.length > 0;
-  return (
-    <>
-      <div
-        className="flex items-center gap-1.5 px-3 py-1.5"
-        style={{ paddingLeft: 12 + depth * 14 }}
-      >
-        {hasChildren ? (
-          <button
-            type="button"
-            onClick={() => onToggle(node.id)}
-            className="opacity-70 hover:opacity-100"
-            style={{ color: N }}
-          >
-            {isOpen ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
-          </button>
-        ) : (
-          <span className="inline-block w-[11px]" />
-        )}
-        <label
-          className="flex items-center gap-1.5 cursor-pointer flex-1 min-w-0"
-          style={{ color: N }}
-        >
-          <input
-            type="checkbox"
-            checked={isChecked}
-            onChange={() => onToggleCheck(node.id)}
-          />
-          <span className="text-xs font-bold truncate">{node.orgName}</span>
-          <span className="text-[10px] font-bold opacity-50 truncate">
-            ({node.orgCode})
-          </span>
-        </label>
-      </div>
-      {isOpen &&
-        node.children.map((child) => (
-          <RoleDeptTreeRow
-            key={child.id}
-            node={child}
-            depth={depth + 1}
-            expanded={expanded}
-            onToggle={onToggle}
-            selectedIds={selectedIds}
-            onToggleCheck={onToggleCheck}
-          />
-        ))}
-    </>
-  );
-}
-
 function RoleEditorModal({
   mode,
   target,
@@ -1055,7 +873,7 @@ function RoleEditorModal({
           placeholder="选填"
         />
         <label
-          className="flex items-center gap-2 text-xs font-bold cursor-pointer"
+          className="flex items-center gap-2 text-base font-bold cursor-pointer"
           style={{ color: N }}
         >
           <input

+ 0 - 22
frontend/src/features/directory/__tests__/roleApi.test.ts

@@ -111,28 +111,6 @@ describe("roleApi", () => {
     });
   });
 
-  it("getDepartments返回字符串数组", async () => {
-    const get = vi.fn().mockResolvedValue(result(["2", "7"]));
-    const api = createRoleApi({ get, post: noop, put: noop, delete: noop });
-    await expect(api.getDepartments("5")).resolves.toEqual(["2", "7"]);
-    expect(get).toHaveBeenCalledWith("roles/5/departments");
-  });
-
-  it("updateDepartments回传organizationIds与rowVersion", async () => {
-    const put = vi.fn().mockResolvedValue(
-      result({ id: "5", organizationIds: ["2", "7"], rowVersion: 3 }),
-    );
-    const api = createRoleApi({ get: noop, post: noop, put, delete: noop });
-    await expect(api.updateDepartments("5", ["2", "7"], 2)).resolves.toEqual({
-      organizationIds: ["2", "7"],
-      rowVersion: 3,
-    });
-    expect(put).toHaveBeenCalledWith("roles/5/departments", {
-      organizationIds: ["2", "7"],
-      rowVersion: 2,
-    });
-  });
-
   it("updatePermissions校验矩阵结构与rowVersion", async () => {
     const matrix = [matrixRow()];
     const put = vi.fn().mockResolvedValue(

+ 1 - 1
frontend/src/features/directory/__tests__/roleTypes.test.ts

@@ -20,7 +20,7 @@ const role = {
 
 const matrixRow = {
   securityLevel: "SECRET",
-  deptScope: "ASSIGNED",
+  deptScope: "OTHER",
   canView: true,
   canDownload: false,
   canEdit: false,

+ 1 - 0
frontend/src/features/directory/directoryTypes.ts

@@ -47,6 +47,7 @@ export interface OrganizationCreateRequest {
 }
 
 export interface OrganizationUpdateRequest {
+  orgCode: string;
   orgName: string;
   parentId: string | null;
   sortNo: number;

+ 6 - 6
frontend/src/features/directory/directoryUi.tsx

@@ -49,7 +49,7 @@ export function smallButton(
       type="button"
       onClick={onClick}
       disabled={disabled}
-      className="flex items-center gap-1 px-2.5 py-1 text-xs font-bold rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
+      className="flex items-center gap-1 px-2.5 py-1 text-base font-bold rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
       style={{
         color: active ? W : danger ? DANGER : N,
         background: active
@@ -88,7 +88,7 @@ export function Field({
   return (
     <div>
       <div
-        className="text-xs font-black mb-1 tracking-wide"
+        className="text-base font-black mb-1 tracking-wide"
         style={{ color: N }}
       >
         {label}
@@ -99,7 +99,7 @@ export function Field({
 }
 
 const inputClass =
-  "w-full px-3 py-1.5 text-sm border rounded-sm outline-none disabled:bg-gray-100";
+  "w-full px-3 py-1.5 text-base border rounded-sm outline-none disabled:bg-gray-100";
 const inputStyle = { borderColor: FIELD_BORDER, color: N } as const;
 
 export function LabeledInput({
@@ -217,7 +217,7 @@ export function ModalShell({
             color: W,
           }}
         >
-          <h3 className="text-sm font-black tracking-wide">{title}</h3>
+          <h3 className="text-base font-black tracking-wide">{title}</h3>
           <button
             type="button"
             onClick={onClose}
@@ -257,7 +257,7 @@ export function ModalFooter({
         type="button"
         onClick={onClose}
         disabled={submitting}
-        className="px-3 py-1.5 text-sm font-black rounded-sm disabled:opacity-50"
+        className="px-3 py-1.5 text-base font-black rounded-sm disabled:opacity-50"
         style={{ border: `1px solid ${B}`, color: N, background: W }}
         {...tipProps(secondaryTooltip)}
       >
@@ -267,7 +267,7 @@ export function ModalFooter({
         type="button"
         onClick={onSubmit}
         disabled={submitting}
-        className="px-3 py-1.5 text-sm font-black rounded-sm disabled:opacity-50"
+        className="px-3 py-1.5 text-base font-black rounded-sm disabled:opacity-50"
         style={{
           background: danger ? DANGER : B,
           color: W,

+ 0 - 37
frontend/src/features/directory/roleApi.ts

@@ -99,43 +99,6 @@ export function createRoleApi(client: RoleHttpClient = httpClient) {
       return { id: data.id };
     },
 
-    async getDepartments(id: string): Promise<string[]> {
-      const result = await client.get<unknown>(`roles/${id}/departments`);
-      if (
-        !Array.isArray(result.data) ||
-        !result.data.every((item) => typeof item === "string")
-      ) {
-        throw contractError(result, "角色部门响应不符合DMS接口契约");
-      }
-      return result.data;
-    },
-
-    async updateDepartments(
-      id: string,
-      organizationIds: string[],
-      rowVersion: number,
-    ): Promise<{ organizationIds: string[]; rowVersion: number }> {
-      const result = await client.put<unknown>(`roles/${id}/departments`, {
-        organizationIds,
-        rowVersion,
-      });
-      const data = result.data as {
-        organizationIds?: unknown;
-        rowVersion?: unknown;
-      };
-      if (
-        !Array.isArray(data?.organizationIds) ||
-        !data.organizationIds.every((item) => typeof item === "string") ||
-        typeof data?.rowVersion !== "number"
-      ) {
-        throw contractError(result, "保存角色部门响应不符合DMS接口契约");
-      }
-      return {
-        organizationIds: data.organizationIds,
-        rowVersion: data.rowVersion,
-      };
-    },
-
     async getPermissions(id: string): Promise<RoleMatrixRow[]> {
       const result = await client.get<unknown>(`roles/${id}/permissions`);
       if (!isRoleMatrix(result.data)) {

+ 0 - 6
frontend/src/features/directory/roleTypes.ts

@@ -63,12 +63,6 @@ export interface RoleUpdateRequest extends RoleCreateRequest {
   rowVersion: number;
 }
 
-export interface RoleDepartmentsPayload {
-  id: string;
-  organizationIds: string[];
-  rowVersion: number;
-}
-
 export interface RoleMatrixRow {
   securityLevel: SecurityLevelValue;
   deptScope: DeptScopeValue;

+ 6 - 6
frontend/src/features/documents/DocumentSearchToolbar.tsx

@@ -39,8 +39,8 @@ export function DocumentSearchToolbar({
 }) {
   const dictionaries = useDictionaries();
   const inputClass = compact
-    ? "h-8 px-2 text-xs font-bold border outline-none"
-    : "h-8 px-2 text-sm font-bold border outline-none";
+    ? "h-8 px-2 text-base font-bold border outline-none"
+    : "h-8 px-2 text-base font-bold border outline-none";
 
   return (
     <div
@@ -73,7 +73,7 @@ export function DocumentSearchToolbar({
           ))}
         </select>
       )}
-      <label className="text-[11px] font-bold">
+      <label className="text-base font-bold">
         <span className="block mb-1">更新时间从</span>
         <input
           aria-label={`${ariaLabel}更新时间从`}
@@ -84,7 +84,7 @@ export function DocumentSearchToolbar({
           style={{ borderColor: "rgba(40,118,193,.25)" }}
         />
       </label>
-      <label className="text-[11px] font-bold">
+      <label className="text-base font-bold">
         <span className="block mb-1">更新时间至</span>
         <input
           aria-label={`${ariaLabel}更新时间至`}
@@ -98,13 +98,13 @@ export function DocumentSearchToolbar({
       <button
         type="button"
         onClick={onClear}
-        className="h-8 px-3 text-xs font-black border"
+        className="h-8 px-3 text-base font-black border"
         style={{ color: "#1A5299", borderColor: "#3191eb" }}
       >
         <X size={12} className="inline mr-1" />清空条件
       </button>
       {error && (
-        <span role="alert" className="w-full text-xs font-black text-red-600">
+        <span role="alert" className="w-full text-base font-black text-red-600">
           {error}
         </span>
       )}

+ 6 - 6
frontend/src/features/documents/GlobalSearchBar.tsx

@@ -25,7 +25,7 @@ export function GlobalSearchBar({
   const state = useGlobalSearch();
   const dictionaries = useDictionaries();
 
-  const inputClass = "h-8 px-2 text-sm font-bold border outline-none";
+  const inputClass = "h-8 px-2 text-base font-bold border outline-none";
   const borderStyle = { borderColor: "rgba(40,118,193,.25)" };
 
   const securityOptions = dictionaries.options("securityLevels");
@@ -70,7 +70,7 @@ export function GlobalSearchBar({
           onClick={() => toggleAdvancedFilter()}
           aria-pressed={state.advancedOpen}
           aria-expanded={state.advancedOpen}
-          className="h-8 px-3 text-sm font-black border flex items-center gap-1"
+          className="h-8 px-3 text-base font-black border flex items-center gap-1"
           style={{
             color: "#1A5299",
             borderColor: state.advancedOpen ? "#3191eb" : "rgba(40,118,193,.4)",
@@ -90,7 +90,7 @@ export function GlobalSearchBar({
         <button
           type="button"
           onClick={resetGlobalSearch}
-          className="h-8 px-3 text-sm font-black border"
+          className="h-8 px-3 text-base font-black border"
           style={{ color: "#1A5299", borderColor: "#3191eb" }}
         >
           <X size={12} className="inline mr-1" />
@@ -103,7 +103,7 @@ export function GlobalSearchBar({
           className="flex flex-wrap items-center gap-4 p-2 border"
           style={{ borderColor: "rgba(40,118,193,.2)", background: "rgba(49,145,235,0.04)" }}
         >
-          <label className="flex items-center gap-2 text-sm font-bold">
+          <label className="flex items-center gap-2 text-base font-bold">
             <span>正文</span>
             <input
               aria-label={`${ariaLabel}正文关键词`}
@@ -122,7 +122,7 @@ export function GlobalSearchBar({
             />
           </label>
           {showSecurityLevels && (
-            <fieldset className="flex items-center gap-2 text-sm font-bold">
+            <fieldset className="flex items-center gap-2 text-base font-bold">
               <legend className="sr-only">密级</legend>
               <span>密级</span>
               {securityOptions.map((option) => {
@@ -146,7 +146,7 @@ export function GlobalSearchBar({
               })}
             </fieldset>
           )}
-          <label className="flex items-center gap-2 text-sm font-bold">
+          <label className="flex items-center gap-2 text-base font-bold">
             <span>提供单位</span>
             <input
               aria-label={`${ariaLabel}提供单位`}

+ 2 - 1
frontend/src/features/documents/__tests__/q2fScope.test.ts

@@ -25,7 +25,8 @@ describe("Q2-F检索范围与并发保护", () => {
   });
 
   it("筛选变化与主案切换重置页码和下方筛选", () => {
-    expect(app).toContain("setSubPage(1)");
+    // 阶段11:子案Tab并入附件挂载,仅保留挂载分页重置
+    expect(app).not.toContain("setSubPage");
     expect(app).toContain("setMountedPage(1)");
     expect(app).toContain("}, [selectedMainId]);");
     expect(app).toContain("}, [selected?.id]);");

+ 23 - 23
frontend/src/features/files/FilePreviewModal.tsx

@@ -499,7 +499,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
 
   const previewChildren = (
     <>
-      {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-sm font-black" style={{ color: "#1A5299", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
+      {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-base font-black" style={{ color: "#1A5299", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
       {pdfBlob && pdfViewerEngine === "pdfjs" && <PdfViewer blob={pdfBlob} documentName={current.documentName} onImageActivate={setLightboxSrc} />}
       {pdfUrl && pdfViewerEngine === "iframe" && (
         <iframe
@@ -534,9 +534,9 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
           内容区,流式提示会被 overflow-hidden 裁掉而不可见(403 无权/415 不支持等同理) */}
       {error && <div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-8 text-center" style={{ color: "#1A5299" }}>
         <FileWarning size={38} className="mx-auto mb-3 opacity-60" />
-        <div className="text-sm font-black leading-6">{errorText(error, current.fileExtension)}</div>
-        {error.requestId && <div className="text-xs font-bold opacity-60 mt-2">错误编号:{error.requestId}</div>}
-        {error.httpStatus !== 401 && <button onClick={() => setRetryVersion((value) => value + 1)} className="mt-4 px-3 py-1.5 text-xs font-black border" style={{ borderColor: "#3191eb" }}>重试预览</button>}
+        <div className="text-base font-black leading-6">{errorText(error, current.fileExtension)}</div>
+        {error.requestId && <div className="text-sm font-bold opacity-60 mt-2">错误编号:{error.requestId}</div>}
+        {error.httpStatus !== 401 && <button onClick={() => setRetryVersion((value) => value + 1)} className="mt-4 px-3 py-1.5 text-base font-black border" style={{ borderColor: "#3191eb" }}>重试预览</button>}
       </div>}
     </>
   );
@@ -552,7 +552,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
               value={descriptionDraft}
               onChange={(event) => setDescriptionDraft(event.target.value)}
               placeholder="输入文档补充说明…"
-              className="w-full h-28 px-2.5 py-2 text-xs font-bold leading-6 rounded-sm outline-none resize-none border"
+              className="w-full h-28 px-2.5 py-2 text-base font-bold leading-6 rounded-sm outline-none resize-none border"
               style={{ color: "#1A5299", borderColor: "rgba(40,118,193,.3)", background: "rgba(40,118,193,.03)" }}
             />
             <div className="flex items-center gap-2 mt-2">
@@ -560,7 +560,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
                 type="button"
                 onClick={() => void saveDescription()}
                 disabled={savingDescription}
-                className="px-3 py-1 text-xs font-black text-white rounded-sm disabled:opacity-60"
+                className="px-3 py-1 text-base font-black text-white rounded-sm disabled:opacity-60"
                 style={{ background: "#3191eb" }}
               >
                 {savingDescription ? "保存中…" : "保存说明"}
@@ -569,18 +569,18 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
                 type="button"
                 onClick={() => { setDescriptionDraft(description ?? ""); setDescriptionMessage(""); }}
                 disabled={savingDescription}
-                className="px-3 py-1 text-xs font-black border rounded-sm disabled:opacity-60"
+                className="px-3 py-1 text-base font-black border rounded-sm disabled:opacity-60"
                 style={{ color: "#1A5299", borderColor: "rgba(40,118,193,.35)" }}
               >
                 重置
               </button>
               {descriptionMessage && (
-                <span className="text-xs font-bold opacity-70 break-all" style={{ color: "#1A5299" }}>{descriptionMessage}</span>
+                <span className="text-base font-bold opacity-70 break-all" style={{ color: "#1A5299" }}>{descriptionMessage}</span>
               )}
             </div>
           </>
         ) : (
-          <div className="max-h-56 overflow-y-auto text-xs leading-6 whitespace-pre-wrap break-words" style={{ color: "#1A5299" }}>
+          <div className="max-h-56 overflow-y-auto text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "#1A5299" }}>
             {description?.trim() ? description : "暂无说明"}
           </div>
         )}
@@ -588,8 +588,8 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
       {/* 关联方案:modal 模式展示,点击后在本弹窗内跳转查看目标方案 */}
       {(relations.length > 0 || relationsError) && (
         <div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(40,118,193,.15)" }} data-testid="preview-relations">
-          <div className="text-sm font-black mb-2 flex items-center gap-1" style={{ color: "#1A5299" }}><Link2 size={13} />关联方案</div>
-          {relationsError && <div className="text-xs font-bold opacity-70 mb-1" style={{ color: "#1A5299" }}>{relationsError}</div>}
+          <div className="text-base font-black mb-2 flex items-center gap-1" style={{ color: "#1A5299" }}><Link2 size={13} />关联方案</div>
+          {relationsError && <div className="text-base font-bold opacity-70 mb-1" style={{ color: "#1A5299" }}>{relationsError}</div>}
           <div className="space-y-1">
             {relations.map((relation) => (
               <button
@@ -597,7 +597,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
                 type="button"
                 disabled={relationJumping}
                 onClick={() => void jumpToRelation(relation)}
-                className="block w-full text-left px-2.5 py-1.5 text-xs font-bold rounded-sm transition-colors hover:bg-[rgba(49,145,235,0.18)] disabled:opacity-60"
+                className="block w-full text-left px-2.5 py-1.5 text-base font-bold rounded-sm transition-colors hover:bg-[rgba(49,145,235,0.18)] disabled:opacity-60"
                 style={{ color: "#1A5299" }}
                 {...tipProps("点击查看该方案")}
               >
@@ -607,13 +607,13 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
           </div>
         </div>
       )}
-      <div className="px-4 py-3 text-sm font-black flex-shrink-0" style={{ color: "#1A5299", borderBottom: "1px solid rgba(40,118,193,.15)" }}>
+      <div className="px-4 py-3 text-base font-black flex-shrink-0" style={{ color: "#1A5299", borderBottom: "1px solid rgba(40,118,193,.15)" }}>
         提取文本
       </div>
       <div className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-2.5">
-        {contentLoading && <div className="text-xs font-bold opacity-60">正在加载正文…</div>}
+        {contentLoading && <div className="text-sm font-bold opacity-60">正在加载正文…</div>}
         {!contentLoading && paragraphs.length === 0 && (
-          <div className="text-xs font-bold opacity-60">暂无正文</div>
+          <div className="text-sm font-bold opacity-60">暂无正文</div>
         )}
         {search.paragraphMatches.map((info, index) => {
           const hasCurrent = info.globalIndices.includes(search.currentIndex);
@@ -623,10 +623,10 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
               className={`rounded-sm px-2.5 py-2 ${hasCurrent ? "ring-1 ring-orange-400" : ""}`}
               style={{ background: "rgba(40,118,193,.04)" }}
             >
-              <div className="text-xs leading-6 whitespace-pre-wrap break-words" style={{ color: "#1A5299" }}>
+              <div className="text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "#1A5299" }}>
                 {renderParagraphWithHighlights(info, search.currentIndex)}
               </div>
-              <div className="mt-1.5 text-[10px] font-black opacity-50" style={{ color: "#1A5299" }}>
+              <div className="mt-1.5 text-base font-black opacity-50" style={{ color: "#1A5299" }}>
                 第 {index + 1} 段 · 共 {info.text.length} 字{info.ranges.length > 0 && ` · 命中 ${info.ranges.length} 处`}
               </div>
             </div>
@@ -711,14 +711,14 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
               onClick={() => setNavigated(null)}
               aria-label="返回原方案"
               {...tipProps(`返回原方案:${item.documentName}`)}
-              className="flex-shrink-0 flex items-center gap-1 px-2 py-1 text-xs font-black border border-white/40 rounded-sm"
+              className="flex-shrink-0 flex items-center gap-1 px-2 py-1 text-base font-black border border-white/40 rounded-sm"
             >
               <ArrowLeft size={13} />返回
             </button>
           )}
           <div className="min-w-0 flex-1">
-            <div className="text-sm font-black truncate" {...tipProps(current.documentName)}>{mode === "modal" ? `文件查看 · ${current.documentName}` : current.documentName}</div>
-            <div className="text-[11px] font-bold opacity-70 mt-1">{current.fileExtension?.toUpperCase() || "无扩展名"} · {new Date(current.updatedAt).toLocaleString("zh-CN")}</div>
+            <div className="text-base font-black truncate" {...tipProps(current.documentName)}>{mode === "modal" ? `文件查看 · ${current.documentName}` : current.documentName}</div>
+            <div className="text-base font-bold opacity-70 mt-1">{current.fileExtension?.toUpperCase() || "无扩展名"} · {new Date(current.updatedAt).toLocaleString("zh-CN")}</div>
           </div>
           {isModal && (
             <div className="flex items-center gap-1.5 flex-shrink-0">
@@ -738,10 +738,10 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
                     }
                   }}
                   placeholder="搜索正文"
-                  className="w-52 pl-6 pr-2 py-1 text-xs rounded-sm bg-white/95 placeholder-black/45 text-black"
+                  className="w-52 pl-6 pr-2 py-1 text-base rounded-sm bg-white/95 placeholder-black/45 text-black"
                 />
               </div>
-              <span className="text-[11px] font-bold opacity-90 whitespace-nowrap min-w-[44px] text-center">
+              <span className="text-base font-bold opacity-90 whitespace-nowrap min-w-[44px] text-center">
                 {search.keyword.trim() ? `${search.totalCount === 0 ? 0 : search.currentIndex + 1} / ${search.totalCount}` : ""}
               </span>
               <button onClick={search.gotoPrev} disabled={search.totalCount === 0} aria-label="上一处命中" className="disabled:opacity-40"><ChevronUp size={16} /></button>
@@ -770,7 +770,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
         <button
           type="button"
           onClick={() => setWindowState("normal")}
-          className="absolute top-3 right-4 z-30 flex items-center gap-1.5 px-3 py-1.5 text-xs font-black text-white rounded-sm shadow-lg"
+          className="absolute top-3 right-4 z-30 flex items-center gap-1.5 px-3 py-1.5 text-base font-black text-white rounded-sm shadow-lg"
           style={{ background: "rgba(26,82,153,.85)" }}
         >
           <Shrink size={14} />退出全屏

+ 2 - 2
frontend/src/features/files/PdfViewer.tsx

@@ -373,14 +373,14 @@ export function PdfViewer({ blob, documentName, onImageActivate }: {
       />
       {status === "loading" && (
         <div
-          className="text-center text-sm font-black py-6"
+          className="text-center text-base font-black py-6"
           style={{ color: "#1A5299" }}
         >
           正在渲染 PDF…
         </div>
       )}
       {status === "error" && (
-        <div className="text-center text-sm py-6 text-red-600">{errorMessage}</div>
+        <div className="text-center text-base py-6 text-red-600">{errorMessage}</div>
       )}
     </div>
   );

+ 5 - 2
frontend/src/features/files/__tests__/q3fScope.test.ts

@@ -96,9 +96,12 @@ describe("Q3-F 1.4 business alignment", () => {
     expect(mainPage).toContain("RELATION_TABS");
   });
 
-  it("USER页面无继承权限按钮,管理员显示只读继承说明", () => {
+  it("USER页面无继承权限按钮;配套方案已并轨为第四类附件Tab", () => {
     expect(mainPage).not.toContain("onPermission={setPermission}");
-    expect(app).toContain("权限继承自主案:{selected.documentName}");
+    // 阶段11:配套方案并入共享资料库(ACCOMPANYING_PLAN),子案专属链路退役
+    expect(app).not.toContain("useSubPlanList");
+    expect(app).not.toContain("SubPlanBindingModal");
+    expect(app).toContain("AttachmentType.ACCOMPANYING_PLAN");
   });
 
   it("全局检索栏提示按标题匹配,高级检索覆盖正文关键词", () => {

+ 47 - 45
frontend/src/features/mutations/MutationModals.tsx

@@ -129,7 +129,7 @@ function ErrorBlock({ error }: { error: HttpError | null }) {
     : null;
   const plans = Array.isArray(details?.mainPlans) ? details.mainPlans : [];
   return (
-    <div className="p-3 text-xs font-bold leading-5" style={{ background: "rgba(26,82,153,.08)", color: NAVY }}>
+    <div className="p-3 text-base font-bold leading-5" style={{ background: "rgba(26,82,153,.08)", color: NAVY }}>
       <div>{mutationMessage(error)}</div>
       {typeof details?.mountedPlanCount === "number" && <div>当前挂载主案数:{details.mountedPlanCount}</div>}
       {typeof details?.childCategoryCount === "number" && <div>下级分类数:{details.childCategoryCount}</div>}
@@ -162,10 +162,10 @@ function Modal({ title, children, onClose, wide = false }: {
 }
 
 function Field({ label, children }: { label: string; children: React.ReactNode }) {
-  return <label className="block"><span className="block text-xs font-black mb-1" style={{ color: NAVY }}>{label}</span>{children}</label>;
+  return <label className="block"><span className="block text-base font-black mb-1" style={{ color: NAVY }}>{label}</span>{children}</label>;
 }
 
-const inputClass = "w-full h-9 px-3 text-sm font-bold border outline-none";
+const inputClass = "w-full h-9 px-3 text-base font-bold border outline-none";
 const selectStyle = { color: NAVY, borderColor: "rgba(26,82,153,.25)" };
 
 function useReferenceData() {
@@ -200,10 +200,12 @@ function defaultDocumentMetadata(
   };
 }
 
-export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAIN", initialParentId, initialAttachmentType, mainOnly = false, onUploaded, onClose }: {
+export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAIN", initialParentId, initialCategoryId, initialAttachmentType, mainOnly = false, onUploaded, onClose }: {
   kind: "document" | "attachment";
   documentType?: "MAIN" | "SUB_PLAN";
   initialParentId?: string;
+  /** 上传主体方案时的默认分类:传入当前页面选中分类,未选分类则不指定由用户选择。 */
+  initialCategoryId?: string;
   /** 附件类型预置值(管理页 Tab 页"上传"入口按当前页签类型上传共享资料)。 */
   initialAttachmentType?: AttachmentTypeCode;
   /** 仅允许上传主案:隐藏方案类型切换,documentType 锁定为 MAIN。 */
@@ -220,7 +222,7 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
     initialParentId ? "SUB_PLAN" : fixedDocumentType,
   );
   const [parentDocumentId, setParentDocumentId] = useState(initialParentId ?? "");
-  const [categoryId, setCategoryId] = useState("");
+  const [categoryId, setCategoryId] = useState(initialCategoryId ?? "");
   const [securityLevel, setSecurityLevel] = useState(SecurityLevel.INTERNAL);
   const [attachmentType, setAttachmentType] = useState<AttachmentTypeCode>(initialAttachmentType ?? AttachmentType.SUPPORT_ACHIEVEMENT);
   const [submitting, setSubmitting] = useState(false);
@@ -334,13 +336,13 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
           <Field label="附件类型 *"><select value={attachmentType} disabled={submitting} onChange={(event) => setAttachmentType(event.target.value as typeof attachmentType)} className={inputClass} style={selectStyle}>{dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
         ) : (
           <>
-            {documentType === "MAIN" ? <Field label="方案分类 *"><select value={categoryId} disabled={submitting || references.categoryLoading} onChange={(event) => { setCategoryId(event.target.value); setOverwrite(null); }} className={inputClass} style={selectStyle}><option value="">请选择最末级分类</option>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-xs font-bold" style={{ color: NAVY, background: "#EBF4FD" }}>方案分类继承自主案:{references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryPath ?? references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryName ?? "请选择所属主案"}</div>}
+            {documentType === "MAIN" ? <Field label="方案分类 *"><select value={categoryId} disabled={submitting || references.categoryLoading} onChange={(event) => { setCategoryId(event.target.value); setOverwrite(null); }} className={inputClass} style={selectStyle}><option value="">请选择最末级分类</option>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-base font-bold" style={{ color: NAVY, background: "#EBF4FD" }}>方案分类继承自主案:{references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryPath ?? references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryName ?? "请选择所属主案"}</div>}
             <div className="grid grid-cols-1 gap-3">
               <Field label="密级 *"><select value={securityLevel} onChange={(event) => setSecurityLevel(event.target.value as typeof securityLevel)} className={inputClass} style={selectStyle}>{dictionaries.options("securityLevels").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
             </div>
             {documentType === "MAIN" && !overwrite && (
               <div className="border" style={{ borderColor: "rgba(26,82,153,.18)" }}>
-                <button type="button" disabled={submitting} onClick={() => setAdvancedOpen((open) => !open)} className="w-full px-3 h-9 flex items-center justify-between text-xs font-black" style={{ color: NAVY }}>
+                <button type="button" disabled={submitting} onClick={() => setAdvancedOpen((open) => !open)} className="w-full px-3 h-9 flex items-center justify-between text-base font-black" style={{ color: NAVY }}>
                   <span className="flex items-center gap-1"><Lock size={13} />高级设置 · 方案密码锁(可选)</span>
                   <span className="opacity-70">{advancedOpen ? "收起" : "展开"}</span>
                 </button>
@@ -350,17 +352,17 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
                       <input type="password" value={password} disabled={submitting} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} maxLength={128} placeholder="6位以上,设置后受保护操作需输入该密码" className={inputClass} style={selectStyle} />
                     </Field>
                     <div>
-                      <span className="block text-xs font-black mb-1" style={{ color: NAVY }}>需密码保护的操作 *(密码非空时至少勾选一项)</span>
+                      <span className="block text-base font-black mb-1" style={{ color: NAVY }}>需密码保护的操作 *(密码非空时至少勾选一项)</span>
                       <div className="grid grid-cols-4 gap-1.5">
                         {ALL_PASSWORD_ACTIONS.map((code) => (
-                          <label key={code} className="flex items-center gap-1.5 text-xs font-bold" style={{ color: NAVY }}>
+                          <label key={code} className="flex items-center gap-1.5 text-base font-bold" style={{ color: NAVY }}>
                             <input type="checkbox" checked={protectedActions.includes(code)} disabled={submitting} onChange={() => setProtectedActions((current) => current.includes(code) ? current.filter((item) => item !== code) : [...current, code])} />
                             {PASSWORD_ACTION_LABELS[code]}
                           </label>
                         ))}
                       </div>
                     </div>
-                    <div className="text-xs opacity-70" style={{ color: NAVY }}>管理员对设密主案的查看、下载、编辑等操作也需输入密码(删除除外);修改或清除密码需提供原密码。</div>
+                    <div className="text-base opacity-70" style={{ color: NAVY }}>管理员对设密主案的查看、下载、编辑等操作也需输入密码(删除除外);修改或清除密码需提供原密码。</div>
                   </div>
                 )}
               </div>
@@ -368,10 +370,10 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
           </>
         )}
         {/* 概述/标签输入框已按要求隐藏:创建时提交空值,后续可在编辑弹窗维护 */}
-        {formError && <div className="text-xs font-bold" style={{ color: NAVY }}>{formError}</div>}
+        {formError && <div className="text-base font-bold" style={{ color: NAVY }}>{formError}</div>}
         <ErrorBlock error={error} />
-        {overwrite && <div role="alertdialog" className="p-4 text-sm font-bold border" style={{ color: NAVY, borderColor: BLUE, background: "#EBF4FD" }}><div>已存在同名{documentType === "MAIN" ? "主案" : "子案"}“{overwrite.existingDocumentName}”,是否覆盖?</div><div className="mt-1 text-xs opacity-70">覆盖将保留原记录、权限及关联关系,仅替换文件和本次填写的元数据。</div><div className="flex justify-end gap-2 mt-3"><button type="button" onClick={() => setOverwrite(null)} className="px-3 py-1.5 border" style={selectStyle}>取消覆盖</button><button type="button" disabled={submitting} onClick={() => void submit(true)} className="px-3 py-1.5 text-white" style={{ background: BLUE }}>确认覆盖</button></div></div>}
-        <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={selectStyle}>取消</button><button onClick={() => void submit()} disabled={submitting || dictionaries.status !== "success"} className="px-4 py-2 text-sm font-black text-white disabled:opacity-60" style={{ background: BLUE }}>{submitting ? <><RefreshCw size={13} className="inline animate-spin mr-1" />正在上传/处理中</> : <><Upload size={13} className="inline mr-1" />确认上传</>}</button></div>
+        {overwrite && <div role="alertdialog" className="p-4 text-base font-bold border" style={{ color: NAVY, borderColor: BLUE, background: "#EBF4FD" }}><div>已存在同名{documentType === "MAIN" ? "主案" : "子案"}“{overwrite.existingDocumentName}”,是否覆盖?</div><div className="mt-1 text-base opacity-70">覆盖将保留原记录、权限及关联关系,仅替换文件和本次填写的元数据。</div><div className="flex justify-end gap-2 mt-3"><button type="button" onClick={() => setOverwrite(null)} className="px-3 py-1.5 border" style={selectStyle}>取消覆盖</button><button type="button" disabled={submitting} onClick={() => void submit(true)} className="px-3 py-1.5 text-white" style={{ background: BLUE }}>确认覆盖</button></div></div>}
+        <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={selectStyle}>取消</button><button onClick={() => void submit()} disabled={submitting || dictionaries.status !== "success"} className="px-4 py-2 text-base font-black text-white disabled:opacity-60" style={{ background: BLUE }}>{submitting ? <><RefreshCw size={13} className="inline animate-spin mr-1" />正在上传/处理中</> : <><Upload size={13} className="inline mr-1" />确认上传</>}</button></div>
       </div>
     </Modal>
   );
@@ -475,7 +477,7 @@ export function BatchImportModal({ kind, onClose }: { kind: BatchKind; onClose:
     <Modal title={kind === "attachment" ? "批量导入配套资料" : "批量导入方案文档"} onClose={onClose} wide>
       <div className="p-5 space-y-3">
         <div onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault(); addFiles(Array.from(event.dataTransfer.files)); }}
-          className="p-5 text-center border-2 border-dashed text-sm font-black" style={{ color: NAVY, borderColor: "rgba(49,145,235,.45)", background: "#EBF4FD" }}>
+          className="p-5 text-center border-2 border-dashed text-base font-black" style={{ color: NAVY, borderColor: "rgba(49,145,235,.45)", background: "#EBF4FD" }}>
           拖入多个文件,或 <label className="underline cursor-pointer"><input type="file" multiple accept={ACCEPT} className="hidden" onChange={(event) => addFiles(Array.from(event.target.files ?? []))} />点击选择</label>
         </div>
         {drafts.map((draft, index) => {
@@ -484,31 +486,31 @@ export function BatchImportModal({ kind, onClose }: { kind: BatchKind; onClose:
           const attachmentMetadata = kind === "attachment" ? metadata as AttachmentCreateMetadata : null;
           return (
             <div key={draft.key} className="p-3 border grid grid-cols-12 gap-2 items-end" style={{ borderColor: "rgba(26,82,153,.18)" }}>
-              <div className="col-span-2 text-xs font-black truncate" {...tipProps(draft.file.name)}>{index + 1}. {draft.file.name}</div>
+              <div className="col-span-2 text-base font-black truncate" {...tipProps(draft.file.name)}>{index + 1}. {draft.file.name}</div>
               <div className="col-span-2"><input value={metadata.documentName} onChange={(event) => updateMetadata(draft.key, { documentName: event.target.value })} className={inputClass} style={selectStyle} /></div>
               {kind === "document" ? (
                 <>
                   <div className="col-span-1"><select value={documentMetadata!.documentType} onChange={(event) => changeDocumentType(draft.key, event.target.value as "MAIN" | "SUB_PLAN")} className={inputClass} style={selectStyle}>{dictionaries.options("documentTypes").filter((option) => option.code !== "ATTACHMENT").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></div>
-                  <div className="col-span-2">{documentMetadata!.documentType === "MAIN" ? <select value={documentMetadata!.categoryId} onChange={(event) => updateMetadata(draft.key, { categoryId: event.target.value } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}</option>)}</select> : <div className="h-9 px-2 flex items-center text-xs font-bold" style={{ background: "#EBF4FD", color: NAVY }}>分类继承自主案</div>}</div>
+                  <div className="col-span-2">{documentMetadata!.documentType === "MAIN" ? <select value={documentMetadata!.categoryId} onChange={(event) => updateMetadata(draft.key, { categoryId: event.target.value } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}</option>)}</select> : <div className="h-9 px-2 flex items-center text-base font-bold" style={{ background: "#EBF4FD", color: NAVY }}>分类继承自主案</div>}</div>
                   <div className="col-span-2"><select disabled={documentMetadata!.documentType === "MAIN"} value={documentMetadata!.documentType === "SUB_PLAN" ? documentMetadata!.parentDocumentId : ""} onChange={(event) => updateMetadata(draft.key, { parentDocumentId: event.target.value } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}><option value="">父主案</option>{references.mainPlans.map((plan) => <option key={plan.id} value={plan.id}>{plan.documentName}</option>)}</select></div>
                 </>
               ) : (
                 <div className="col-span-2"><select value={attachmentMetadata!.attachmentType} onChange={(event) => updateMetadata(draft.key, { attachmentType: event.target.value as AttachmentCreateMetadata["attachmentType"] })} className={inputClass} style={selectStyle}>{dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></div>
               )}
-              <button disabled={submitting} onClick={() => setDrafts((current) => current.filter((item) => item.key !== draft.key))} className="col-span-1 h-9 text-xs font-black" style={{ color: NAVY }}><Trash2 size={13} className="inline" />移除</button>
+              <button disabled={submitting} onClick={() => setDrafts((current) => current.filter((item) => item.key !== draft.key))} className="col-span-1 h-9 text-base font-black" style={{ color: NAVY }}><Trash2 size={13} className="inline" />移除</button>
               {documentMetadata && <>
                 <div className="col-span-2"><select value={documentMetadata.securityLevel} onChange={(event) => updateMetadata(draft.key, { securityLevel: event.target.value as DocumentCreateMetadata["securityLevel"] } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{dictionaries.options("securityLevels").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></div>
               </>}
-              {draft.result && <div className="col-span-12 text-xs font-bold" style={{ color: draft.result.success ? BLUE : NAVY }}>{draft.result.success ? `成功:${draft.result.document.documentName}` : `失败:${draft.result.errorCode} · ${draft.result.errorMessage}`}</div>}
+              {draft.result && <div className="col-span-12 text-base font-bold" style={{ color: draft.result.success ? BLUE : NAVY }}>{draft.result.success ? `成功:${draft.result.document.documentName}` : `失败:${draft.result.errorCode} · ${draft.result.errorMessage}`}</div>}
             </div>
           );
         })}
-        {summary && <div className="text-sm font-black" style={{ color: NAVY }}>总数 {summary.total} · 成功 {summary.successCount} · 失败 {summary.failureCount}</div>}
+        {summary && <div className="text-base font-black" style={{ color: NAVY }}>总数 {summary.total} · 成功 {summary.successCount} · 失败 {summary.failureCount}</div>}
         <ErrorBlock error={error} />
         <div className="flex justify-end gap-2">
-          {summary?.failureCount ? <button onClick={() => { setDrafts((current) => retainFailedDrafts(current)); setSummary(null); }} className="px-4 py-2 text-sm font-black border" style={selectStyle}>仅保留失败项重试</button> : null}
-          <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={selectStyle}>关闭</button>
-          <button onClick={() => void submit()} disabled={submitting || dictionaries.status !== "success" || drafts.length === 0 || drafts.every((draft) => draft.result?.success)} className="px-4 py-2 text-sm font-black text-white disabled:opacity-50" style={{ background: BLUE }}><FilePlus2 size={13} className="inline mr-1" />{submitting ? "正在上传/处理中" : "提交当前批次"}</button>
+          {summary?.failureCount ? <button onClick={() => { setDrafts((current) => retainFailedDrafts(current)); setSummary(null); }} className="px-4 py-2 text-base font-black border" style={selectStyle}>仅保留失败项重试</button> : null}
+          <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={selectStyle}>关闭</button>
+          <button onClick={() => void submit()} disabled={submitting || dictionaries.status !== "success" || drafts.length === 0 || drafts.every((draft) => draft.result?.success)} className="px-4 py-2 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}><FilePlus2 size={13} className="inline mr-1" />{submitting ? "正在上传/处理中" : "提交当前批次"}</button>
         </div>
       </div>
     </Modal>
@@ -849,17 +851,17 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
   return (
     <Modal title={`编辑 · ${item.documentName}`} onClose={onClose}>
       <div className="p-6 space-y-3">
-        {loading && <div className="text-sm font-bold" style={{ color: NAVY }}><RefreshCw size={14} className="inline animate-spin mr-2" />正在加载最新详情…</div>}
+        {loading && <div className="text-base font-bold" style={{ color: NAVY }}><RefreshCw size={14} className="inline animate-spin mr-2" />正在加载最新详情…</div>}
         {detail && <>
           <Field label="名称 *"><input value={documentName} onChange={(event) => setDocumentName(event.target.value)} className={inputClass} style={selectStyle} /></Field>
           {attachment ? <Field label="附件类型"><select value={attachmentType} onChange={(event) => setAttachmentType(event.target.value as typeof attachmentType)} className={inputClass} style={selectStyle}>{dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field> : <>
-            {item.documentType === DocumentType.MAIN ? <Field label="分类"><select value={categoryId} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-xs font-bold" style={{ background: "#EBF4FD", color: NAVY }}>方案分类继承自主案:{detail.categoryPath ?? detail.categoryName ?? "未分类"}</div>}
+            {item.documentType === DocumentType.MAIN ? <Field label="分类"><select value={categoryId} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-base font-bold" style={{ background: "#EBF4FD", color: NAVY }}>方案分类继承自主案:{detail.categoryPath ?? detail.categoryName ?? "未分类"}</div>}
             <div className="grid grid-cols-1 gap-3">
               <Field label="密级"><select value={securityLevel} onChange={(event) => setSecurityLevel(event.target.value as typeof securityLevel)} className={inputClass} style={selectStyle}>{dictionaries.options("securityLevels").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
             </div>
             {mainPlan && canManagePassword && (
               <div className="border p-3 space-y-3" style={{ borderColor: "rgba(26,82,153,.18)", background: "#F5FAFF" }}>
-                <div className="text-xs font-black flex items-center gap-1" style={{ color: NAVY }}>
+                <div className="text-base font-black flex items-center gap-1" style={{ color: NAVY }}>
                   <Lock size={13} />方案密码锁:
                   {detail.hasPassword
                     ? `已设置(当前保护:${(detail.passwordProtectedActions ?? []).map((code) => PASSWORD_ACTION_LABELS[code]).join("、") || "无"})`
@@ -874,10 +876,10 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
                   <input type="password" value={pwNew} disabled={pwSubmitting} autoComplete="new-password" onChange={(event) => setPwNew(event.target.value)} maxLength={128} placeholder="留空且不点击保存则不修改" className={inputClass} style={selectStyle} />
                 </Field>
                 <div>
-                  <span className="block text-xs font-black mb-1" style={{ color: NAVY }}>需密码保护的操作 *</span>
+                  <span className="block text-base font-black mb-1" style={{ color: NAVY }}>需密码保护的操作 *</span>
                   <div className="grid grid-cols-4 gap-1.5">
                     {ALL_PASSWORD_ACTIONS.map((code) => (
-                      <label key={code} className="flex items-center gap-1.5 text-xs font-bold" style={{ color: NAVY }}>
+                      <label key={code} className="flex items-center gap-1.5 text-base font-bold" style={{ color: NAVY }}>
                         <input type="checkbox" checked={pwActions.includes(code)} disabled={pwSubmitting} onChange={() => setPwActions((current) => current.includes(code) ? current.filter((item) => item !== code) : [...current, code])} />
                         {PASSWORD_ACTION_LABELS[code]}
                       </label>
@@ -886,18 +888,18 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
                 </div>
                 <ErrorBlock error={pwError} />
                 <div className="flex justify-end gap-2">
-                  {detail.hasPassword && <button type="button" onClick={() => void removePasswordLock()} disabled={pwSubmitting || !pwOld} className="px-3 py-1.5 text-xs font-black border" style={selectStyle}>清除密码</button>}
-                  <button type="button" onClick={() => void savePasswordLock()} disabled={pwSubmitting || !pwNew || pwActions.length === 0} className="px-3 py-1.5 text-xs font-black text-white disabled:opacity-50" style={{ background: BLUE }}>{pwSubmitting ? "保存中" : "保存密码设置"}</button>
+                  {detail.hasPassword && <button type="button" onClick={() => void removePasswordLock()} disabled={pwSubmitting || !pwOld} className="px-3 py-1.5 text-base font-black border" style={selectStyle}>清除密码</button>}
+                  <button type="button" onClick={() => void savePasswordLock()} disabled={pwSubmitting || !pwNew || pwActions.length === 0} className="px-3 py-1.5 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}>{pwSubmitting ? "保存中" : "保存密码设置"}</button>
                 </div>
               </div>
             )}
             {canManageRelations ? (
               <div className="border p-3 space-y-2" style={{ borderColor: "rgba(26,82,153,.18)", background: "#F5FAFF" }} data-testid="document-relations-editor">
-                <div className="text-xs font-black flex items-center gap-1" style={{ color: NAVY }}>
+                <div className="text-base font-black flex items-center gap-1" style={{ color: NAVY }}>
                   <Link2 size={13} />关联方案(仅可关联主案,保存后整体替换)
                 </div>
                 {relationsLoading && relationRows.length === 0 && (
-                  <div className="text-xs font-bold" style={{ color: NAVY }}><RefreshCw size={12} className="inline animate-spin mr-1" />正在加载关联…</div>
+                  <div className="text-base font-bold" style={{ color: NAVY }}><RefreshCw size={12} className="inline animate-spin mr-1" />正在加载关联…</div>
                 )}
                 {relationRows.map((row, index) => {
                   const presetCode = relationPresets ? matchPresetCode(row.relationType, relationPresets) : null;
@@ -936,23 +938,23 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
                           {missingTarget ? <option value={row.relatedDocumentId}>{relationNames[row.relatedDocumentId] ?? `已关联主案 ${row.relatedDocumentId}`}</option> : null}
                         </select>
                       </div>
-                      <button type="button" disabled={relationSubmitting} onClick={() => setRelationRows((current) => current.filter((_, rowIndex) => rowIndex !== index))} className="col-span-1 h-9 text-xs font-black" style={{ color: NAVY }}><Trash2 size={13} className="inline" /></button>
+                      <button type="button" disabled={relationSubmitting} onClick={() => setRelationRows((current) => current.filter((_, rowIndex) => rowIndex !== index))} className="col-span-1 h-9 text-base font-black" style={{ color: NAVY }}><Trash2 size={13} className="inline" /></button>
                     </div>
                   );
                 })}
-                <button type="button" disabled={relationSubmitting || relationPresets === null || relationRows.length >= 50} onClick={() => setRelationRows((current) => [...current, { relatedDocumentId: "", relationType: relationPresets?.[0]?.label ?? "" }])} className="px-3 py-1.5 text-xs font-black border disabled:opacity-50" style={selectStyle}>添加关联</button>
-                {relationFormError && <div className="text-xs font-bold" style={{ color: NAVY }}>{relationFormError}</div>}
+                <button type="button" disabled={relationSubmitting || relationPresets === null || relationRows.length >= 50} onClick={() => setRelationRows((current) => [...current, { relatedDocumentId: "", relationType: relationPresets?.[0]?.label ?? "" }])} className="px-3 py-1.5 text-base font-black border disabled:opacity-50" style={selectStyle}>添加关联</button>
+                {relationFormError && <div className="text-base font-bold" style={{ color: NAVY }}>{relationFormError}</div>}
                 <ErrorBlock error={relationError} />
                 <div className="flex justify-end">
-                  <button type="button" onClick={() => void saveRelations()} disabled={relationSubmitting || !detail || relationPresets === null} className="px-3 py-1.5 text-xs font-black text-white disabled:opacity-50" style={{ background: BLUE }}>{relationSubmitting ? "保存中" : "保存关联"}</button>
+                  <button type="button" onClick={() => void saveRelations()} disabled={relationSubmitting || !detail || relationPresets === null} className="px-3 py-1.5 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}>{relationSubmitting ? "保存中" : "保存关联"}</button>
                 </div>
               </div>
             ) : (
               relationRows.length > 0 && (
                 <div className="border p-3 space-y-1" style={{ borderColor: "rgba(26,82,153,.18)" }} data-testid="document-relations-readonly">
-                  <div className="text-xs font-black flex items-center gap-1" style={{ color: NAVY }}><Link2 size={13} />关联方案</div>
+                  <div className="text-base font-black flex items-center gap-1" style={{ color: NAVY }}><Link2 size={13} />关联方案</div>
                   {relationRows.map((row) => (
-                    <div key={row.relatedDocumentId} className="text-xs font-bold" style={{ color: NAVY }}>· {row.relationType} · {relationNames[row.relatedDocumentId] ?? references.mainPlans.find((plan) => plan.id === row.relatedDocumentId)?.documentName ?? row.relatedDocumentId}</div>
+                    <div key={row.relatedDocumentId} className="text-base font-bold" style={{ color: NAVY }}>· {row.relationType} · {relationNames[row.relatedDocumentId] ?? references.mainPlans.find((plan) => plan.id === row.relatedDocumentId)?.documentName ?? row.relatedDocumentId}</div>
                   ))}
                 </div>
               )
@@ -960,21 +962,21 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
           </>}
           {detail.contentExtractStatus !== undefined && (
             <div className="border p-3 space-y-2" style={{ borderColor: detail.contentExtractStatus === ContentExtractStatus.FAILED ? "rgba(200,30,30,.35)" : "rgba(26,82,153,.18)", background: detail.contentExtractStatus === ContentExtractStatus.FAILED ? "#FFF5F5" : "#F5FAFF" }} data-testid="content-extract-panel">
-              <div className="text-xs font-black flex items-center gap-1" style={{ color: NAVY }}>
+              <div className="text-base font-black flex items-center gap-1" style={{ color: NAVY }}>
                 <FileSearch size={13} />正文解析:{EXTRACT_STATUS_LABELS[detail.contentExtractStatus]}
                 {detail.contentExtractedAt ? <span className="font-bold opacity-70">({detail.contentExtractedAt.replace("T", " ").replace("Z", " UTC")})</span> : null}
               </div>
               {detail.contentExtractStatus === ContentExtractStatus.FAILED && (
-                <div className="text-xs font-bold" style={{ color: "#B91C1C" }} data-testid="content-extract-error">
+                <div className="text-base font-bold" style={{ color: "#B91C1C" }} data-testid="content-extract-error">
                   失败原因:{detail.contentExtractError ?? "未知错误"}。可点击"重新解析"重试,若持续失败请检查文件是否损坏或加密。
                 </div>
               )}
               {detail.contentExtractStatus === ContentExtractStatus.EMPTY && (
-                <div className="text-xs font-bold" style={{ color: NAVY }}>未提取到正文文本,全文检索与正文预览不可用;若为扫描件属预期行为。</div>
+                <div className="text-sm font-bold" style={{ color: NAVY }}>未提取到正文文本,全文检索与正文预览不可用;若为扫描件属预期行为。</div>
               )}
               <ErrorBlock error={reExtractError} />
               <div className="flex justify-end">
-                <button type="button" onClick={() => void reExtract()} disabled={reExtracting} className="px-3 py-1.5 text-xs font-black text-white disabled:opacity-50" style={{ background: BLUE }} data-testid="re-extract-button">
+                <button type="button" onClick={() => void reExtract()} disabled={reExtracting} className="px-3 py-1.5 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }} data-testid="re-extract-button">
                   <RefreshCw size={12} className={reExtracting ? "inline animate-spin mr-1" : "inline mr-1"} />{reExtracting ? "解析中…" : "重新解析"}
                 </button>
               </div>
@@ -992,12 +994,12 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
               {organizationId === "" && <option value="">未设置单位</option>}
               {organizationOptions.map((option) => <option key={option.id} value={option.id} title={option.label}>{option.label}</option>)}
             </select>
-            {!canManageOrganization && <span className="block mt-1 text-xs opacity-70" style={{ color: NAVY }}>调整所属单位需管理员权限</span>}
+            {!canManageOrganization && <span className="block mt-1 text-base opacity-70" style={{ color: NAVY }}>调整所属单位需管理员权限</span>}
           </Field>
           {/* 概述/标签栏已按要求隐藏:state 保留服务端原值,保存时原样提交,不改动既有数据 */}
         </>}
         <ErrorBlock error={error} />
-        <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={selectStyle}>取消</button><button onClick={() => void submit()} disabled={!detail || submitting || dictionaries.status !== "success"} className="px-4 py-2 text-sm font-black text-white disabled:opacity-50" style={{ background: BLUE }}><Check size={13} className="inline mr-1" />{submitting ? "提交中" : "保存元数据"}</button></div>
+        <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={selectStyle}>取消</button><button onClick={() => void submit()} disabled={!detail || submitting || dictionaries.status !== "success"} className="px-4 py-2 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}><Check size={13} className="inline mr-1" />{submitting ? "提交中" : "保存元数据"}</button></div>
       </div>
     </Modal>
   );
@@ -1055,9 +1057,9 @@ export function DeleteDocumentModal({ item, onClose, onDeleted }: {
   return (
     <Modal title={`删除 · ${item.documentName}`} onClose={onClose}>
       <div className="p-6 space-y-4" style={{ color: NAVY }}>
-        {loading ? <div className="text-sm font-bold"><RefreshCw size={14} className="inline animate-spin mr-2" />正在加载…</div> : <div className="text-sm font-bold leading-7">确认删除该{attachment ? "配套资料" : item.documentType === "MAIN" ? "主案" : "子方案"}?</div>}
+        {loading ? <div className="text-base font-bold"><RefreshCw size={14} className="inline animate-spin mr-2" />正在加载…</div> : <div className="text-base font-bold leading-7">确认删除该{attachment ? "配套资料" : item.documentType === "MAIN" ? "主案" : "子方案"}?</div>}
         <ErrorBlock error={error} />
-        <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={selectStyle}>取消</button><button onClick={() => void remove()} disabled={!detail || submitting} className="px-4 py-2 text-sm font-black text-white disabled:opacity-50" style={{ background: NAVY }}><Trash2 size={13} className="inline mr-1" />{submitting ? "删除中" : "确认删除"}</button></div>
+        <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={selectStyle}>取消</button><button onClick={() => void remove()} disabled={!detail || submitting} className="px-4 py-2 text-base font-black text-white disabled:opacity-50" style={{ background: NAVY }}><Trash2 size={13} className="inline mr-1" />{submitting ? "删除中" : "确认删除"}</button></div>
       </div>
     </Modal>
   );

+ 44 - 40
frontend/src/features/permissions/PermissionModal.tsx

@@ -1,4 +1,5 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useConfirm } from "../../shared/ui/ConfirmDialog";
 import {
   AlertCircle,
   Building,
@@ -119,12 +120,12 @@ function OrganizationTreePicker({
                 {open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
               </button>
             ) : <span className="w-[13px]" />}
-            <span className="text-xs font-bold flex-1">{node.orgName}({node.orgCode})</span>
+            <span className="text-base font-bold flex-1">{node.orgName}({node.orgCode})</span>
             <button
               type="button"
               disabled={existing.has(node.id)}
               onClick={() => onAdd(node)}
-              className="text-xs font-black disabled:opacity-40"
+              className="text-base font-black disabled:opacity-40"
               style={{ color: BLUE }}
             >
               {existing.has(node.id) ? "已添加" : "添加"}
@@ -143,6 +144,7 @@ export function PermissionModal({
   onChanged,
 }: PermissionModalProps) {
   const dictionaries = useDictionaries();
+  const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
   const isAttachment = document.documentType === DocumentType.ATTACHMENT;
   const isSubPlan = document.documentType === DocumentType.SUB_PLAN;
   const editable =
@@ -236,7 +238,7 @@ export function PermissionModal({
     [organizationState.tree],
   );
 
-  const changeVisibility = (visibilityType: VisibilityTypeCode) => {
+  const changeVisibility = async (visibilityType: VisibilityTypeCode) => {
     if (!draft || draft.visibilityType === visibilityType) return;
     const removedCount = visibilityType === VisibilityType.ALL_AUTHENTICATED
       ? draft.entries.length
@@ -245,11 +247,12 @@ export function PermissionModal({
         : 0;
     if (
       removedCount > 0 &&
-      !window.confirm(
-        visibilityType === VisibilityType.ALL_AUTHENTICATED
+      !(await confirmDialog({
+        title: "切换可见范围",
+        message: visibilityType === VisibilityType.ALL_AUTHENTICATED
           ? `切换后将清除当前 ${removedCount} 条权限项,是否继续?`
           : `切换后将移除当前 ${removedCount} 条人员权限,只保留组织权限,是否继续?`,
-      )
+      }))
     ) {
       return;
     }
@@ -347,11 +350,12 @@ export function PermissionModal({
 
   return (
     <div className="fixed inset-0 z-[80] flex items-center justify-center p-5" style={{ background: "rgba(26,82,153,.55)" }}>
+      {confirmDialogNode}
       <div className="w-[1080px] max-w-full h-[86vh] flex flex-col shadow-2xl" style={{ background: WHITE, color: NAVY }}>
         <div className="h-12 px-5 flex items-center justify-between text-white" style={{ background: NAVY }}>
           <div>
             <span className="font-black">{isSubPlan ? "继承权限" : isAttachment ? "配套资料权限" : "主案权限配置"}</span>
-            <span className="ml-3 text-xs opacity-75">{document.documentName}</span>
+            <span className="ml-3 text-base opacity-75">{document.documentName}</span>
           </div>
           <button type="button" onClick={onClose} disabled={saving}><X size={18} /></button>
         </div>
@@ -368,28 +372,28 @@ export function PermissionModal({
           <div className="flex-1 flex flex-col items-center justify-center font-bold">
             <AlertCircle size={22} className="mb-3" />
             <span>{errorDescription(error)}</span>
-            {error.requestId && <span className="text-xs mt-2">错误编号:{error.requestId}</span>}
-            <button className="mt-4 px-4 py-2 text-white text-sm font-black" style={{ background: BLUE }} onClick={() => setRetryVersion((value) => value + 1)}>重试</button>
+            {error.requestId && <span className="text-sm mt-2">错误编号:{error.requestId}</span>}
+            <button className="mt-4 px-4 py-2 text-white text-base font-black" style={{ background: BLUE }} onClick={() => setRetryVersion((value) => value + 1)}>重试</button>
           </div>
         ) : draft && detail ? (
           <>
-            <div className="px-5 py-3 border-b flex items-center gap-5 text-sm font-bold" style={{ borderColor: "rgba(49,145,235,.2)", background: PALE }}>
+            <div className="px-5 py-3 border-b flex items-center gap-5 text-base font-bold" style={{ borderColor: "rgba(49,145,235,.2)", background: PALE }}>
               <span>组织 {orgCount} 项</span>
               <span>人员 {userCount} 项</span>
               <span className="ml-auto">{detail.inherited ? "动态继承" : "主案自有权限"}</span>
             </div>
             {isSubPlan && (
-              <div className="mx-5 mt-4 p-3 text-sm font-black" style={{ background: PALE }}>
+              <div className="mx-5 mt-4 p-3 text-base font-black" style={{ background: PALE }}>
                 子方案权限继承所属主案,不能单独配置。
               </div>
             )}
 
             <div className="px-5 py-4 flex items-center gap-3">
-              <span className="text-sm font-black">可见范围</span>
+              <span className="text-base font-black">可见范围</span>
               {dictionaries.options("visibilityTypes").map((option) => {
                 const value = option.code as VisibilityTypeCode;
                 return (
-                <label key={option.code} className="flex items-center gap-1.5 text-sm font-bold">
+                <label key={option.code} className="flex items-center gap-1.5 text-base font-bold">
                   <input
                     type="radio"
                     checked={draft.visibilityType === value}
@@ -400,7 +404,7 @@ export function PermissionModal({
                 </label>
                 );
               })}
-              <span className="ml-auto text-xs font-bold opacity-65">
+              <span className="ml-auto text-base font-bold opacity-65">
                 {draft.visibilityType === VisibilityType.ALL_AUTHENTICATED
                   ? "通过状态和密级前置检查的全部登录用户可访问。"
                   : draft.visibilityType === VisibilityType.ORGANIZATION
@@ -411,11 +415,11 @@ export function PermissionModal({
 
             {editable && !detail.inherited && draft.visibilityType !== VisibilityType.ALL_AUTHENTICATED && (
               <div className="px-5 pb-3 flex gap-2">
-                <button onClick={() => setShowOrganizations((value) => !value)} className="px-3 py-2 text-xs font-black border flex items-center gap-1" style={{ borderColor: BLUE }}>
+                <button onClick={() => setShowOrganizations((value) => !value)} className="px-3 py-2 text-base font-black border flex items-center gap-1" style={{ borderColor: BLUE }}>
                   <Building size={14} />选择组织
                 </button>
                 {draft.visibilityType === VisibilityType.CUSTOM && (
-                  <button onClick={() => setShowUsers((value) => !value)} className="px-3 py-2 text-xs font-black border flex items-center gap-1" style={{ borderColor: BLUE }}>
+                  <button onClick={() => setShowUsers((value) => !value)} className="px-3 py-2 text-base font-black border flex items-center gap-1" style={{ borderColor: BLUE }}>
                     <UserPlus size={14} />选择人员
                   </button>
                 )}
@@ -427,12 +431,12 @@ export function PermissionModal({
                 {showOrganizations && (
                   <div className="border p-3 overflow-auto">
                     <div className="flex gap-2 mb-2">
-                      <input value={organizationKeyword} onChange={(event) => setOrganizationKeyword(event.target.value)} placeholder="组织名称或编码" className="flex-1 border px-2 h-8 text-xs font-bold outline-none" />
+                      <input value={organizationKeyword} onChange={(event) => setOrganizationKeyword(event.target.value)} placeholder="组织名称或编码" className="flex-1 border px-2 h-8 text-base font-bold outline-none" />
                       <button onClick={() => setOrganizationRetry((value) => value + 1)}><RefreshCw size={14} /></button>
                     </div>
-                    {organizationState.status === "loading" && <div className="text-xs font-bold">正在加载组织树…</div>}
-                    {organizationState.status === "error" && <div className="text-xs font-bold">{organizationState.error?.message} {organizationState.error?.requestId}</div>}
-                    {organizationState.status === "empty" && <div className="text-xs font-bold opacity-60">无匹配组织</div>}
+                    {organizationState.status === "loading" && <div className="text-base font-bold">正在加载组织树…</div>}
+                    {organizationState.status === "error" && <div className="text-base font-bold">{organizationState.error?.message} {organizationState.error?.requestId}</div>}
+                    {organizationState.status === "empty" && <div className="text-base font-bold opacity-60">无匹配组织</div>}
                     <OrganizationTreePicker
                       nodes={organizationState.tree}
                       existing={new Set(draft.entries.filter((entry) => entry.subjectType === SubjectType.ORG).map((entry) => entry.subjectId))}
@@ -443,30 +447,30 @@ export function PermissionModal({
                 {showUsers && draft.visibilityType === VisibilityType.CUSTOM && (
                   <div className="border p-3 overflow-auto">
                     <div className="grid grid-cols-2 gap-2 mb-2">
-                      <input value={userKeyword} onChange={(event) => { setUserKeyword(event.target.value); setUserPage(1); }} placeholder="姓名或用户名" className="border px-2 h-8 text-xs font-bold outline-none" />
-                      <select value={userOrganizationId} onChange={(event) => { setUserOrganizationId(event.target.value); setUserPage(1); }} className="border px-2 text-xs font-bold">
+                      <input value={userKeyword} onChange={(event) => { setUserKeyword(event.target.value); setUserPage(1); }} placeholder="姓名或用户名" className="border px-2 h-8 text-base font-bold outline-none" />
+                      <select value={userOrganizationId} onChange={(event) => { setUserOrganizationId(event.target.value); setUserPage(1); }} className="border px-2 text-base font-bold">
                         <option value="">全部组织</option>
                         {organizationOptions.map((org) => <option key={org.id} value={org.id}>{org.orgName}</option>)}
                       </select>
                     </div>
-                    <label className="text-xs font-bold flex items-center gap-1 mb-2"><input type="checkbox" checked={includeDescendants} onChange={(event) => setIncludeDescendants(event.target.checked)} />包含下级组织人员</label>
-                    {userState.status === "loading" && <div className="text-xs font-bold">正在加载人员…</div>}
-                    {userState.status === "error" && <div className="text-xs font-bold">{userState.error?.message} {userState.error?.requestId}<button onClick={() => setUserRetry((value) => value + 1)}>重试</button></div>}
+                    <label className="text-base font-bold flex items-center gap-1 mb-2"><input type="checkbox" checked={includeDescendants} onChange={(event) => setIncludeDescendants(event.target.checked)} />包含下级组织人员</label>
+                    {userState.status === "loading" && <div className="text-base font-bold">正在加载人员…</div>}
+                    {userState.status === "error" && <div className="text-base font-bold">{userState.error?.message} {userState.error?.requestId}<button onClick={() => setUserRetry((value) => value + 1)}>重试</button></div>}
                     {userState.data.items.map((user) => (
                       <div key={user.id} className="flex items-center gap-2 py-1.5 border-b">
                         <Users size={13} />
-                        <span className="text-xs font-bold flex-1">{user.realName}({user.username})· {user.organizationName}</span>
+                        <span className="text-base font-bold flex-1">{user.realName}({user.username})· {user.organizationName}</span>
                         <button
                           disabled={existingSubjects.has(`${SubjectType.USER}:${user.id}`)}
                           onClick={() => addSubject(SubjectType.USER, user.id, user.realName)}
-                          className="text-xs font-black disabled:opacity-40"
+                          className="text-base font-black disabled:opacity-40"
                           style={{ color: BLUE }}
                         >
                           {existingSubjects.has(`${SubjectType.USER}:${user.id}`) ? "已添加" : "添加"}
                         </button>
                       </div>
                     ))}
-                    {userState.data.totalPages > 1 && <div className="flex justify-end gap-2 mt-2 text-xs font-black"><button disabled={userPage <= 1} onClick={() => setUserPage((value) => value - 1)}>上页</button><span>{userPage}/{userState.data.totalPages}</span><button disabled={userPage >= userState.data.totalPages} onClick={() => setUserPage((value) => value + 1)}>下页</button></div>}
+                    {userState.data.totalPages > 1 && <div className="flex justify-end gap-2 mt-2 text-base font-black"><button disabled={userPage <= 1} onClick={() => setUserPage((value) => value - 1)}>上页</button><span>{userPage}/{userState.data.totalPages}</span><button disabled={userPage >= userState.data.totalPages} onClick={() => setUserPage((value) => value + 1)}>下页</button></div>}
                   </div>
                 )}
               </div>
@@ -474,7 +478,7 @@ export function PermissionModal({
 
             <div className="flex-1 overflow-auto px-5">
               {draft.entries.length === 0 ? (
-                <div className="p-10 text-center text-sm font-bold opacity-55">
+                <div className="p-10 text-center text-base font-bold opacity-55">
                   {draft.visibilityType === VisibilityType.ALL_AUTHENTICATED
                     ? "当前无需添加权限对象"
                     : "当前权限集合为空"}
@@ -483,15 +487,15 @@ export function PermissionModal({
                 <table className="w-full border-collapse">
                   <thead className="sticky top-0">
                     <tr style={{ background: NAVY, color: WHITE }}>
-                      <th className="px-3 py-2 text-left text-xs font-black">主体</th>
-                      {ACTIONS.map((action) => <th key={action.key} className="px-3 py-2 text-center text-xs font-black">{dictionaries.label("allowedActions", action.code)}</th>)}
-                      <th className="px-3 py-2 text-center text-xs font-black">操作</th>
+                      <th className="px-3 py-2 text-left text-base font-black">主体</th>
+                      {ACTIONS.map((action) => <th key={action.key} className="px-3 py-2 text-center text-base font-black">{dictionaries.label("allowedActions", action.code)}</th>)}
+                      <th className="px-3 py-2 text-center text-base font-black">操作</th>
                     </tr>
                   </thead>
                   <tbody>
                     {draft.entries.map((entry, index) => (
                       <tr key={`${entry.subjectType}:${entry.subjectId}`} className="border-b" style={{ borderColor: "rgba(49,145,235,.12)" }}>
-                        <td className="px-3 py-2"><div className="text-sm font-black">{entry.subjectName}</div><div className="text-[11px] opacity-60">{dictionaries.label("subjectTypes", entry.subjectType)}</div></td>
+                        <td className="px-3 py-2"><div className="text-base font-black">{entry.subjectName}</div><div className="text-base opacity-60">{dictionaries.label("subjectTypes", entry.subjectType)}</div></td>
                         {ACTIONS.map((action) => (
                           <td key={action.key} className="px-3 py-2 text-center">
                             <input
@@ -504,8 +508,8 @@ export function PermissionModal({
                         ))}
                         <td className="px-3 py-2 text-center">
                           {editable && !detail.inherited
-                            ? <button onClick={() => setDraft({ ...draft, entries: draft.entries.filter((_, itemIndex) => itemIndex !== index) })} className="text-xs font-black" style={{ color: BLUE }}>移除</button>
-                            : <span className="text-xs font-bold opacity-55">只读</span>}
+                            ? <button onClick={() => setDraft({ ...draft, entries: draft.entries.filter((_, itemIndex) => itemIndex !== index) })} className="text-base font-black" style={{ color: BLUE }}>移除</button>
+                            : <span className="text-base font-bold opacity-55">只读</span>}
                         </td>
                       </tr>
                     ))}
@@ -515,26 +519,26 @@ export function PermissionModal({
             </div>
 
             <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(49,145,235,.2)" }}>
-              {error && <div className="mb-2 text-sm font-black"><AlertCircle size={14} className="inline mr-1" />{errorDescription(error)}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
-              {notice && <div className="mb-2 text-sm font-black">{notice}</div>}
+              {error && <div className="mb-2 text-base font-black"><AlertCircle size={14} className="inline mr-1" />{errorDescription(error)}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
+              {notice && <div className="mb-2 text-base font-black">{notice}</div>}
               <div className="flex justify-end gap-2">
                 {editable && !detail.inherited && (
                   <>
                     <button
                       onClick={() => { if (baseline) setDraft({ ...baseline, entries: baseline.entries.map((entry) => ({ ...entry })) }); setError(null); setNotice("已重置尚未保存的修改。"); }}
                       disabled={saving}
-                      className="px-4 py-2 text-sm font-black border flex items-center gap-1"
+                      className="px-4 py-2 text-base font-black border flex items-center gap-1"
                       style={{ borderColor: BLUE }}
                     >
                       <RotateCcw size={14} />重置本地表单
                     </button>
-                    <button onClick={() => void savePermissions()} disabled={saving || dictionaries.status !== "success"} className="px-4 py-2 text-sm font-black text-white flex items-center gap-1 disabled:opacity-50" style={{ background: BLUE }}>
+                    <button onClick={() => void savePermissions()} disabled={saving || dictionaries.status !== "success"} className="px-4 py-2 text-base font-black text-white flex items-center gap-1 disabled:opacity-50" style={{ background: BLUE }}>
                       {saving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
                       {saving ? "正在保存…" : "保存权限全集"}
                     </button>
                   </>
                 )}
-                <button onClick={onClose} disabled={saving} className="px-4 py-2 text-sm font-black border" style={{ borderColor: BLUE }}>关闭</button>
+                <button onClick={onClose} disabled={saving} className="px-4 py-2 text-base font-black border" style={{ borderColor: BLUE }}>关闭</button>
               </div>
             </div>
           </>

+ 24 - 24
frontend/src/features/recycle-bin/RecycleBinModal.tsx

@@ -99,7 +99,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
           <span className="font-black">文档回收站</span>
           <button
             onClick={onClose}
-            className="ml-auto flex items-center gap-1 text-sm font-black"
+            className="ml-auto flex items-center gap-1 text-base font-black"
           >
             <X size={16} />
             返回后台管理
@@ -119,7 +119,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
                 resetPage();
               }}
               placeholder="搜索文档或原文件名"
-              className="min-w-0 flex-1 text-sm font-bold outline-none"
+              className="min-w-0 flex-1 text-base font-bold outline-none"
             />
           </label>
           <select
@@ -128,7 +128,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
               setDocumentType(event.target.value);
               resetPage();
             }}
-            className="col-span-1 h-9 border px-2 text-xs font-bold"
+            className="col-span-1 h-9 border px-2 text-base font-bold"
           >
             <option value="">全部类型</option>
             {dictionaries.options("documentTypes").map((option) => (
@@ -143,7 +143,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
               setCategoryId(event.target.value);
               resetPage();
             }}
-            className="col-span-2 h-9 border px-2 text-xs font-bold"
+            className="col-span-2 h-9 border px-2 text-base font-bold"
           >
             <option value="">全部分类</option>
             {flatCategories.map((category) => (
@@ -160,7 +160,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
               setDeletedFromLocal(event.target.value);
               resetPage();
             }}
-            className="col-span-2 h-9 border px-2 text-xs font-bold"
+            className="col-span-2 h-9 border px-2 text-base font-bold"
           />
           <input
             type="datetime-local"
@@ -170,7 +170,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
               setDeletedToLocal(event.target.value);
               resetPage();
             }}
-            className="col-span-2 h-9 border px-2 text-xs font-bold"
+            className="col-span-2 h-9 border px-2 text-base font-bold"
           />
           <select
             value={sortField}
@@ -178,7 +178,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
               setSortField(event.target.value as typeof sortField);
               resetPage();
             }}
-            className="col-span-1 h-9 border px-2 text-xs font-bold"
+            className="col-span-1 h-9 border px-2 text-base font-bold"
           >
             <option value="deletedAt">删除时间</option>
             <option value="documentName">文档名称</option>
@@ -191,7 +191,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
               setSortDirection(event.target.value as "asc" | "desc");
               resetPage();
             }}
-            className="col-span-1 h-9 border px-2 text-xs font-bold"
+            className="col-span-1 h-9 border px-2 text-base font-bold"
           >
             <option value="desc">降序</option>
             <option value="asc">升序</option>
@@ -218,7 +218,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
 
         {lastRestoredName && (
           <div
-            className="px-4 py-2 text-xs font-bold"
+            className="px-4 py-2 text-base font-bold"
             style={{ color: NAVY, background: "#EBF4FD" }}
           >
             已恢复:{lastRestoredName}。后台统计、分类、文档与附件数据已请求刷新。
@@ -240,13 +240,13 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
                   : list.error.message}
               </div>
               {list.error.requestId && (
-                <div className="text-xs opacity-60 mt-1">
+                <div className="text-sm opacity-60 mt-1">
                   错误编号:{list.error.requestId}
                 </div>
               )}
               <button
                 onClick={list.refresh}
-                className="mt-3 px-4 py-2 text-sm text-white"
+                className="mt-3 px-4 py-2 text-base text-white"
                 style={{ background: BLUE }}
               >
                 重试
@@ -275,7 +275,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
                     "删除人",
                     "操作",
                   ].map((heading) => (
-                    <th key={heading} className="px-3 py-3 text-left text-xs font-black">
+                    <th key={heading} className="px-3 py-3 text-left text-base font-black">
                       {heading}
                     </th>
                   ))}
@@ -291,43 +291,43 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
                       borderColor: "rgba(40,118,193,.1)",
                     }}
                   >
-                    <td className="px-3 py-2 text-xs font-black">
+                    <td className="px-3 py-2 text-base font-black">
                       {(list.data!.page - 1) * list.data!.pageSize + index + 1}
                     </td>
-                    <td className="px-3 py-2 text-sm font-black">
+                    <td className="px-3 py-2 text-base font-black">
                       {item.documentName}
                     </td>
-                    <td className="px-3 py-2 text-xs font-bold">
+                    <td className="px-3 py-2 text-base font-bold">
                       {dictionaries.label("documentTypes", item.documentType)}
                     </td>
-                    <td className="px-3 py-2 text-xs font-bold max-w-[220px] truncate">
+                    <td className="px-3 py-2 text-base font-bold max-w-[220px] truncate">
                       {item.originalFileName}
                     </td>
-                    <td className="px-3 py-2 text-xs font-bold">
+                    <td className="px-3 py-2 text-base font-bold">
                       {item.categoryName ?? "—"}
                     </td>
-                    <td className="px-3 py-2 text-xs font-mono">
+                    <td className="px-3 py-2 text-base font-mono">
                       {item.parentDocumentId ?? "—"}
                     </td>
                     <td
-                      className="px-3 py-2 text-xs font-bold"
+                      className="px-3 py-2 text-base font-bold"
                       style={{ color: securityLevelStyle(dictionaries.label("securityLevels", item.securityLevel)).text }}
                     >
                       {dictionaries.label("securityLevels", item.securityLevel)}
                     </td>
-                    <td className="px-3 py-2 text-xs font-bold">
+                    <td className="px-3 py-2 text-base font-bold">
                       {dictionaries.label("documentStatuses", item.documentStatus)}
                     </td>
-                    <td className="px-3 py-2 text-xs font-bold whitespace-nowrap">
+                    <td className="px-3 py-2 text-base font-bold whitespace-nowrap">
                       {formatLocalDate(item.deletedAt)}
                     </td>
-                    <td className="px-3 py-2 text-xs font-bold">
+                    <td className="px-3 py-2 text-base font-bold">
                       {deletedByLabel(item.deletedBy)}
                     </td>
                     <td className="px-3 py-2">
                       <button
                         onClick={() => setRestoring(item)}
-                        className="px-3 py-1.5 text-xs font-black text-white"
+                        className="px-3 py-1.5 text-base font-black text-white"
                         style={{ background: BLUE }}
                       >
                         <RotateCcw size={12} className="inline mr-1" />
@@ -342,7 +342,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
         </div>
 
         <div
-          className="h-12 px-4 flex items-center justify-center gap-3 border-t text-xs font-bold"
+          className="h-12 px-4 flex items-center justify-center gap-3 border-t text-base font-bold"
           style={{ color: NAVY, borderColor: "rgba(40,118,193,.15)" }}
         >
           <span>共 {list.data?.total ?? 0} 条</span>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini