Sfoglia il codice sorgente

1. 实现了正文解析失败可视化与重新解析功能,失败原因脱敏落库(0019 迁移),管理员编辑界面展示解析状态与失败原因并可一键重新解析;
2. 优化了PDF正文提取的段落识别,优先按版面块边界切分段落(标题、落款独立成段,块内字号突变切分),扫描件等退化版面回退启发式合并段内软换行;
3. 实现了xls、xlsx、et及常用图片类型的上传与预览,与doc、docx、pdf、wps处理逻辑一致,修复了Office类型(doc/wps/xls/xlsx/et)上传时因转换分支缺少 Path 导入导致正文提取恒失败的问题;
4. 修复了分类树括号计数口径,按本分类及全部子分类下所有主体方案统计,不再按当前用户密级与权限过滤;
5. 优化了全局检索体验,定位时左侧分类同步跳转至所属分类,点击分类自动清空检索条件,无配套关系的结果不再弹出配套面板,命中文字与列表悬停高亮更加醒目,检索时不再因无权限报错;
6. 重构了浏览与管理界面的操作分布,查看下载归浏览界面、全部操作归管理界面,管理菜单按用户实际文档操作权限计算显示,无权按钮置灰并悬停提示密级或权限不足原因,无权查看的方案右侧给出明确提示;
7. 实现了按用户文档操作权限动态显示管理菜单,只要对任意文档拥有编辑、删除、授权等权限即显示管理入口;
8. 实现了置灰操作按钮的悬浮原因提示,后端统一下发 allowedActions 并区分密级不匹配与权限不匹配两种原因;
9. 统一了全部悬浮提示为深蓝边白底深蓝字风格,分类提示仅显示本级名称,并修复浏览界面提示位置偏移的问题;
10. 优化了界面布局,左侧分类面板支持拖动调宽,实现了中间区域上下分栏比例拖动调整,默认 1 比 1、滚轮缩放后比例保持、拖动结束后再渲染,检索框上移至页签上方,配套资料库更名为共享资料库,页签按钮统一为上传与挂载,一级分类竖排标签括号改为竖向样式;
11. 实现了子方案跨主案挂载功能,主体方案可将其他主案的子案挂载到配套方案页签,支持挂载关系的维护与展示(0018 迁移);
12. 实现了顶级分类方案类型显示名称功能,可按分类自定义检索结果中方案的类型徽标(0017 迁移);
13. 实现了页面防泄密措施,全局禁止选中与复制文字,叠加淡淡的斜向水印(用户名、登录IP、登录时间),系统截图流出后可溯源责任人;
14. 实现了方案编辑所属单位功能,主案与子案均可在编辑时修改归属组织;
15. 优化了分类悬浮提示仅显示本分类名称,并移除预览界面“当前:XXXX”冗余信息;
16. 优化了审计日志契约校验为行级容错,个别不符合契约的日志行仅原地标记错误,不再导致整页数据无法显示;
17. 实现了登录界面自定义背景图功能,用户将 background.jpg 或 background.png 放至运行目录即可自动加载并拉伸铺满,无图回退纯色背景;
18. 完善了数据库升级工具对0017至0019迁移的支持,生产库可自0016版本无损升级。

weisijie 1 mese fa
parent
commit
bad04bc
100 ha cambiato i file con 4782 aggiunte e 489 eliminazioni
  1. 61 11
      DMS_API_CONTRACT.md
  2. 22 6
      DMS_FUNCTION_CONTRACT.md
  3. 17 3
      FUNCTION_AND_API_SPECIFICATION.md
  4. 5 0
      backend/app.py
  5. 4 2
      backend/dms/api/v1/auth.py
  6. 1 0
      backend/dms/api/v1/blueprint.py
  7. 36 14
      backend/dms/api/v1/documents.py
  8. 59 0
      backend/dms/api/v1/login_background.py
  9. 5 1
      backend/dms/api/v1/users.py
  10. 8 2
      backend/dms/backfill_preview_cache.py
  11. 7 0
      backend/dms/common/errors.py
  12. 2 0
      backend/dms/models/__init__.py
  13. 7 0
      backend/dms/models/category.py
  14. 5 0
      backend/dms/models/document.py
  15. 53 0
      backend/dms/models/sub_plan_binding.py
  16. 8 1
      backend/dms/services/attachment_binding_service.py
  17. 29 5
      backend/dms/services/attachment_query_service.py
  18. 179 1
      backend/dms/services/authorization_service.py
  19. 61 64
      backend/dms/services/category_service.py
  20. 182 3
      backend/dms/services/document_content_service.py
  21. 102 1
      backend/dms/services/document_converter.py
  22. 152 38
      backend/dms/services/document_mutation_service.py
  23. 6 0
      backend/dms/services/document_password_service.py
  24. 87 30
      backend/dms/services/document_query_service.py
  25. 3 0
      backend/dms/services/document_relation_service.py
  26. 15 5
      backend/dms/services/file_read_service.py
  27. 19 1
      backend/dms/services/permission_service.py
  28. 3 2
      backend/dms/services/portal_auth_service.py
  29. 24 5
      backend/dms/services/preview_service.py
  30. 327 0
      backend/dms/services/sub_plan_binding_service.py
  31. 40 2
      backend/dms/services/user_service.py
  32. 46 4
      backend/dms/storage/uploads.py
  33. 5 4
      backend/migrations/versions/0006_document_organization.py
  34. 2 1
      backend/migrations/versions/0012_role_management.py
  35. 48 0
      backend/migrations/versions/0017_category_type_display_name.py
  36. 132 0
      backend/migrations/versions/0018_sub_plan_binding.py
  37. 58 0
      backend/migrations/versions/0019_content_extract_error.py
  38. 26 1
      backend/openapi/components/schemas.yaml
  39. 14 2
      backend/openapi/openapi.yaml
  40. 79 0
      backend/openapi/paths/auth.yaml
  41. 38 0
      backend/openapi/paths/configuration.yaml
  42. 106 1
      backend/openapi/paths/documents.yaml
  43. 14 1
      backend/tests/dms/conftest.py
  44. 76 0
      backend/tests/dms/services/test_category_type_display_name.py
  45. 181 0
      backend/tests/dms/services/test_document_content_service.py
  46. 101 0
      backend/tests/dms/services/test_document_converter.py
  47. 22 1
      backend/tests/dms/services/test_preview_service.py
  48. 57 0
      backend/tests/dms/services/test_sub_plan_binding_service.py
  49. 12 7
      backend/tests/dms/test_db_upgrade.py
  50. 118 0
      backend/tests/dms/test_document_organization.py
  51. 35 8
      backend/tests/dms/test_http.py
  52. 66 0
      backend/tests/dms/test_login_background.py
  53. 61 1
      backend/tests/dms/test_migration.py
  54. 19 1
      backend/tests/dms/test_models.py
  55. 7 1
      backend/tests/dms/test_mysql_integration.py
  56. 7 1
      backend/tests/dms/test_openapi_storage.py
  57. 1 1
      backend/tests/dms/test_q1b_ui_dictionaries.py
  58. 1 1
      backend/tests/dms/test_q2b_updated_range.py
  59. 16 7
      backend/tests/dms/test_q3b_business_content_preview.py
  60. 1 1
      backend/tests/dms/test_q3f_tag_aggregation.py
  61. 221 1
      backend/tests/dms/test_role_authorization.py
  62. 444 127
      frontend/src/app/App.tsx
  63. 21 5
      frontend/src/features/audit/AuditDashboard.tsx
  64. 88 0
      frontend/src/features/audit/__tests__/auditApi.test.ts
  65. 2 1
      frontend/src/features/audit/__tests__/f7Scope.test.ts
  66. 21 14
      frontend/src/features/audit/auditApi.ts
  67. 2 2
      frontend/src/features/audit/auditState.ts
  68. 85 41
      frontend/src/features/audit/auditTypes.ts
  69. 6 3
      frontend/src/features/bindings/AttachmentBindingModal.tsx
  70. 249 0
      frontend/src/features/bindings/SubPlanBindingModal.tsx
  71. 147 0
      frontend/src/features/bindings/subPlanBindingApi.ts
  72. 4 0
      frontend/src/features/categories/__tests__/categoryApi.test.ts
  73. 3 3
      frontend/src/features/categories/__tests__/categoryTree.test.ts
  74. 2 0
      frontend/src/features/categories/__tests__/categoryTypes.test.ts
  75. 68 0
      frontend/src/features/categories/categoryState.ts
  76. 5 0
      frontend/src/features/categories/categoryTypes.ts
  77. 3 4
      frontend/src/features/directory/DirectoryPage.tsx
  78. 1 1
      frontend/src/features/directory/IpEntryMaintenancePanel.tsx
  79. 1 1
      frontend/src/features/directory/RoleMaintenancePanel.tsx
  80. 2 1
      frontend/src/features/directory/directoryUi.tsx
  81. 78 0
      frontend/src/features/documents/__tests__/actionPermission.test.ts
  82. 2 1
      frontend/src/features/documents/__tests__/f4Scope.test.ts
  83. 11 3
      frontend/src/features/documents/__tests__/searchTransitionScope.test.ts
  84. 72 0
      frontend/src/features/documents/actionPermission.ts
  85. 14 2
      frontend/src/features/documents/documentTypes.ts
  86. 47 11
      frontend/src/features/files/FilePreviewModal.tsx
  87. 4 1
      frontend/src/features/files/__tests__/fileApi.test.ts
  88. 15 2
      frontend/src/features/files/__tests__/q3fScope.test.ts
  89. 6 2
      frontend/src/features/files/filename.ts
  90. 118 5
      frontend/src/features/mutations/MutationModals.tsx
  91. 42 0
      frontend/src/features/mutations/__tests__/mutationApi.test.ts
  92. 44 0
      frontend/src/features/mutations/__tests__/organizationEditScope.test.ts
  93. 5 3
      frontend/src/features/mutations/__tests__/passwordScope.test.ts
  94. 4 2
      frontend/src/features/mutations/__tests__/relationScope.test.ts
  95. 7 0
      frontend/src/features/mutations/documentMutationApi.ts
  96. 23 2
      frontend/src/features/mutations/mutationTypes.ts
  97. 5 1
      frontend/src/features/permissions/__tests__/f6Scope.test.ts
  98. 6 8
      frontend/src/features/recycle-bin/RecycleBinModal.tsx
  99. 2 1
      frontend/src/features/recycle-bin/__tests__/f8Scope.test.ts
  100. 24 0
      frontend/src/shared/api/enums.ts

+ 61 - 11
DMS_API_CONTRACT.md

@@ -310,10 +310,11 @@ ATTACHMENT_BINDING
 }
 ```
 
-第一阶段模块映射固定为:
+模块映射(`allowedModules`)按“用户对文档的实际权限”动态计算:
 
-- `USER`:`DOCUMENT_BROWSER`
-- `ADMIN`:`DOCUMENT_BROWSER`、`BACKEND_MANAGEMENT`、`AUDIT_LOG`
+- `USER`:始终包含 `DOCUMENT_BROWSER`;当角色权限矩阵对任一已发布主案命中管理位
+  (编辑/删除/授权/挂载/解除挂载,且该主案对用户可查看)时,追加 `BACKEND_MANAGEMENT`
+- `ADMIN`:`DOCUMENT_BROWSER`、`BACKEND_MANAGEMENT`、`AUDIT_LOG`、`ORGANIZATION_USER_MANAGEMENT`
 
 前端必须使用后端返回的 `allowedModules`,不得根据角色自行补全模块。
 
@@ -387,10 +388,20 @@ ATTACHMENT_BINDING
     "organizationCount": 2,
     "userCount": 3,
     "inheritedFromMainPlan": false
-  }
+  },
+  "contentExtractStatus": "SUCCESS",
+  "contentExtractError": null,
+  "contentExtractedAt": "2026-08-27T03:00:00.000Z"
 }
 ```
 
+`contentExtractStatus/contentExtractError/contentExtractedAt` 仅管理员(`ADMIN`)详情响应返回,用于"编辑"界面排障与重新解析:
+
+- `contentExtractStatus`:`SUCCESS/EMPTY/UNSUPPORTED/FAILED` 之一;
+- `contentExtractError`:提取失败的脱敏原因(异常类型+首行信息,已去除绝对路径),非 `FAILED` 时为 `null`;
+- `contentExtractedAt`:最近一次正文提取时间(UTC),未提取过为 `null`;
+- 非管理员详情响应不包含这三个字段,前端必须按可选字段处理。
+
 ### 4.5 RecycleBinDocumentSummary
 
 ```json
@@ -558,6 +569,8 @@ Token有效期固定为:
 
 返回 `CategoryNode[]`。
 
+`documentCount` 表示该分类完整有效子树内所有有效主体方案(MAIN)数量,含本分类与全部后代分类,不按当前用户密级/权限过滤,所有登录用户口径一致;计数沿父链穿过停用分类节点累加。
+
 ### 8.2 `POST /api/v1/categories`
 
 管理员接口。
@@ -719,7 +732,7 @@ rowVersion=<integer>
 
 ### 9.5 `PUT /api/v1/documents/{id}`
 
-管理员接口,只编辑元数据,不替换文件。
+只编辑元数据,不替换文件。按文档权限校验:需通过密级检查且具备该文档对应操作权限(管理员恒通过;普通用户由角色矩阵×密级×部门范围与文档授权 OR 叠合决定)。设密主案另需通过对应操作密码锁。(`EDIT`)
 
 ```json
 {
@@ -730,14 +743,21 @@ rowVersion=<integer>
   "visibilityType": "CUSTOM",
   "status": "PUBLISHED",
   "tags": ["标签1", "标签2"],
-  "rowVersion": 2
+  "rowVersion": 2,
+  "organizationId": "10"
 }
 ```
 
 不允许修改 `documentType`、`parentDocumentId`、`rootDocumentId` 和文件身份字段。
 
+`organizationId` 可选,用于调整文档归属部门(所属单位):缺省沿用现有归属;原样回传现有归属视为未变更;
+调整到其他部门仅 `ADMIN` 可执行(普通用户提交其他部门返回 `400 INVALID_ARGUMENT`),目标部门必须存在且启用
+(否则 `404 RESOURCE_NOT_FOUND`),子方案与共享附件编辑遵循同一规则。
+
 ### 9.6 `DELETE /api/v1/documents/{id}`
 
+按文档权限校验:需通过密级检查且具备该文档对应操作权限(管理员恒通过;普通用户由角色矩阵×密级×部门范围与文档授权 OR 叠合决定)。设密主案另需通过对应操作密码锁。(`DELETE`)
+
 查询参数:
 
 ```text
@@ -768,6 +788,22 @@ rowVersion=<integer>
 
 时间范围作用于直属子方案自身的 `updated_at`,语义和错误规则与9.1相同。返回 `PageResult<DocumentSummary>`。`id` 不是有效主案时返回 `404 MAIN_PLAN_NOT_FOUND`。
 
+### 9.8 `POST /api/v1/documents/{id}/re-extract`
+
+管理员接口(`ADMIN`),主案/子案/共享附件通用,无请求体。
+
+按当前存储文件重新解析正文并回写提取结果:
+
+- Office 类型(`doc/wps/xls/xlsx/et`)先经 LibreOffice 转 PDF 再提取;`pdf/docx` 直接提取;其余类型恒为 `UNSUPPORTED`;
+- 覆盖写回 `contentText`、`contentExtractStatus`、`contentExtractError`、`contentExtractedAt`,并同步重建 `search_text`(名称+概述+标签+正文);
+- 提取异常不返回 5xx,而是落库为 `FAILED` 并携带脱敏失败原因(异常类型+首行信息,已去除绝对路径);
+- `rowVersion` 递增,审计复用 `EDIT_DOCUMENT`(`detail.scope=reExtractContent`);
+- 文件在存储中缺失返回 `404 FILE_NOT_FOUND`。
+
+成功返回 `DocumentDetail`,HTTP `200`。
+
+**PDF 提取的换行处理**:PDF 复制文本时会在段内软换行处插入 `\n`。服务端提取时优先按版面结构识别段落——利用 PyMuPDF 的块(block)边界作为段落边界(标题、落款等因字号或间距不同独立成块,不依赖标点猜测),块内合并软换行(相邻行主字号相对差 ≥20% 视为段落边界);当整页为逐行等距同字号的退化网格(如扫描件文本层逐行定位)时回退纯文本启发式——空行、句末标点结尾、条目/章节编号开头视为段落边界保留换行,其余视为段内软换行直接合并(合并处两侧均为 ASCII 字母数字时补一个空格,中文文本直接相连)。页与页之间以空行分隔。
+
 ## 10. 共享附件
 
 ### 10.1 `GET /api/v1/attachments`
@@ -786,6 +822,8 @@ rowVersion=<integer>
 
 返回 `PageResult<DocumentSummary>`,只包含有效 `ATTACHMENT`。
 
+该列表对所有登录用户开放且全量可见(支撑全局检索按标题命中全部附件)。普通用户返回的 `allowedActions` 为空集,且不返回 `description` 与 `contentSnippet`;上传、编辑、删除等写操作仍仅 ADMIN,附件内容访问由详情、预览、下载接口按挂载主案权限独立拦截。
+
 ### 10.2 `POST /api/v1/attachments`
 
 管理员接口,`multipart/form-data`:
@@ -823,7 +861,7 @@ categoryId=null
 
 ### 10.5 `PUT /api/v1/attachments/{id}`
 
-管理员接口:
+按文档权限校验(`EDIT`;共享附件仅管理员可维护):
 
 ```json
 {
@@ -831,11 +869,13 @@ categoryId=null
   "attachmentType": "WORK_STANDARD",
   "summary": "新说明",
   "tags": ["规范"],
-  "rowVersion": 1
+  "rowVersion": 1,
+  "organizationId": "10"
 }
 ```
 
-不得修改附件固定密级、固定可见范围和文件身份。
+不得修改附件固定密级、固定可见范围和文件身份。`organizationId` 可选,语义与9.5一致:缺省沿用现有归属,
+调整到其他部门仅 `ADMIN` 可执行。
 
 ### 10.6 `DELETE /api/v1/attachments/{id}`
 
@@ -899,7 +939,7 @@ details.mainPlans
 
 ### 11.2 `POST /api/v1/main-plans/{id}/attachments/bind`
 
-管理员接口:
+按文档权限校验:需通过密级检查且具备该文档对应操作权限(管理员恒通过;普通用户由角色矩阵×密级×部门范围与文档授权 OR 叠合决定)。设密主案另需通过对应操作密码锁。(主案 `BIND_ATTACHMENT`):
 
 ```json
 {
@@ -921,6 +961,8 @@ details.mainPlans
 
 ### 11.3 `DELETE /api/v1/main-plans/{id}/attachments/{attachmentId}`
 
+按文档权限校验:需通过密级检查且具备该文档对应操作权限(管理员恒通过;普通用户由角色矩阵×密级×部门范围与文档授权 OR 叠合决定)。设密主案另需通过对应操作密码锁。(主案 `UNBIND_ATTACHMENT`)
+
 查询参数:
 
 ```text
@@ -933,6 +975,8 @@ mainPlanRowVersion=<integer>
 
 ### 12.1 `GET /api/v1/documents/{id}/permissions`
 
+按文档权限校验:需通过密级检查且具备该文档对应操作权限(管理员恒通过;普通用户由角色矩阵×密级×部门范围与文档授权 OR 叠合决定)。设密主案另需通过对应操作密码锁。(`CONFIG_PERMISSION`)
+
 主案返回:
 
 ```json
@@ -967,6 +1011,8 @@ mainPlanRowVersion=<integer>
 
 ### 12.2 `PUT /api/v1/documents/{id}/permissions`
 
+按文档权限校验:需通过密级检查且具备该文档对应操作权限(管理员恒通过;普通用户由角色矩阵×密级×部门范围与文档授权 OR 叠合决定)。设密主案另需通过对应操作密码锁。(`CONFIG_PERMISSION`)
+
 只允许对 `MAIN` 调用。子方案返回 `409 SUB_PLAN_PERMISSION_INHERITED`。
 
 ```json
@@ -1410,6 +1456,10 @@ GET /api/v1/config/ui-dictionaries
 
 版本1.3经统领会话批准,为方案、直属子方案和已挂载附件三个既有查询接口统一增加文档更新时间闭区间筛选;不新增路径、DTO、数据库字段或迁移,OpenAPI同步升级为 `1.3.0`。Office预览使用1.4或后续版本。
 
+版本1.12经统领会话批准,新增 `POST /api/v1/documents/{id}/re-extract` 管理员接口;`DocumentDetail` 增加仅管理员可见的 `contentExtractStatus/contentExtractError/contentExtractedAt` 可选字段;PDF 正文提取合并段内软换行;数据库新增 `doc_document.content_extract_error` 列(迁移 `0019_content_extract_error`),OpenAPI同步升级为 `1.12.0`。
+
+版本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`。
+
 ## 20. Q3-B 1.4接口补充
 
 1. 正式`RoleCode`仅为`ADMIN`、`USER`。四个审计查询接口与文档统计接口均要求ADMIN。历史AUDITOR不能登录,旧Token必须因authVersion或用户状态校验返回401。
@@ -1417,7 +1467,7 @@ GET /api/v1/config/ui-dictionaries
 3. MAIN创建metadata仅接受`documentName`、`documentType=MAIN`、`categoryId`、`securityLevel`、`summary`、`tags`。SUB_PLAN创建仅接受`documentName`、`documentType=SUB_PLAN`、`parentDocumentId`、`securityLevel`、`summary`、`tags`。未知字段严格拒绝。单文件和批量导入一致。
 4. MAIN编辑仅接受`documentName`、`summary`、`categoryId`、`securityLevel`、`tags`、`rowVersion`;SUB_PLAN编辑不含`categoryId`。两者均不接受status或visibilityType。
 5. 非叶子分类用于MAIN创建或编辑时返回`409 CATEGORY_NOT_LEAF`,details至少包含字符串`categoryId`和整数`childCategoryCount`。
-6. `GET /api/v1/attachments`仅ADMIN可访问。附件详情、统一preview/download路径对USER执行“至少一个有效挂载主案及对应VIEW/DOWNLOAD权限”检查。
+6. `GET /api/v1/attachments`对所有登录用户开放且全量可见(支撑全局检索按标题命中全部附件);普通用户返回的`allowedActions`为空集,且不返回`description`与`contentSnippet`;上传、编辑、删除等写操作仍仅ADMIN。附件详情、统一preview/download路径对USER执行“至少一个有效挂载主案及对应VIEW/DOWNLOAD权限”检查。
 7. `GET /api/v1/documents/{id}/preview`的200响应支持`application/pdf`与`application/vnd.openxmlformats-officedocument.wordprocessingml.document`,返回原文件并携带inline Content-Disposition、X-Content-Type-Options、Cache-Control和X-Request-Id。DOC、XLS、XLSX返回统一JSON `415 PREVIEW_UNAVAILABLE`。
 8. keyword继续检索名称、摘要和标签,并扩展检索数据库中的正文组合字段;LIKE通配字符必须转义,过滤在分页前执行,不在查询时逐文件读取磁盘。
 9. 本版本不新增路径;OpenAPI `info.version`为`1.4.0`,迁移为`0003_q3_business_alignment_and_content_search`。

+ 22 - 6
DMS_FUNCTION_CONTRACT.md

@@ -106,7 +106,7 @@
 - 可见范围固定为 `ALL_AUTHENTICATED`;
 - 密级固定为 `PUBLIC`,前端不得修改;
 - 所有状态正常的已登录用户均可查看和下载;
-- 只有管理员可以上传、编辑、删除和管理挂载。
+- 上传、编辑、删除和管理挂载仅管理员可执行(普通用户矩阵不授予附件维护位)。
 
 ### 5.4 挂载关系
 
@@ -150,7 +150,7 @@
 → 用户有效状态
 → 文档有效状态和发布状态
 → 密级
-→ 管理员业务特权
+→ 角色身份(ADMIN 业务特权 / USER 矩阵判定)
 → 可见范围与 ACL
 → 具体操作权限
 ```
@@ -248,7 +248,7 @@
 - 单文件上传在同一界面选择“主案”或“子案”;选择子案后必须选择所属主案,分类由该主案自动继承;
 - 有效主案名称全局唯一;有效子案名称在同一所属主案内唯一;不同主案允许存在同名子案;
 - 上传发生名称冲突时先返回冲突信息,由用户明确确认后覆盖;覆盖保留原文档ID、权限、挂载及父子关系,仅替换文件与本次填写的元数据;
-- 编辑元数据;
+- 编辑元数据;编辑弹窗可维护所属单位(归属部门):缺省沿用现有归属,原样回传视为未变更,调整到其他部门仅管理员可执行;
 - 查看、下载;
 - 配置主案权限;
 - 逻辑删除;
@@ -260,7 +260,7 @@
 - 独立附件库;
 - 上传单个附件;
 - 拖入或选择多个文件批量导入;
-- 编辑附件名称、类型、概述和标签;
+- 编辑附件名称、类型、概述和标签;附件编辑同样支持维护所属单位,规则与方案编辑一致;
 - 查看挂载到哪些主案;
 - 将一个或多个附件挂载到当前主案;
 - 解除当前主案与附件的挂载;
@@ -549,14 +549,30 @@ DOC 正文检索规则:
 
 Q2-B实现时必须同步将OpenAPI `info.version`升级为 `1.3.0`。
 
+版本1.12经统领会话批准,引入正文提取状态可见与重新解析能力:
+
+1. `doc_document` 新增 `content_extract_error` 列,记录正文提取失败的脱敏原因(异常类型+首行信息,去除绝对路径,最长512字符);成功/空文本/不支持时为 `NULL`。
+2. 文档与附件详情响应仅对 `ADMIN` 返回 `contentExtractStatus/contentExtractError/contentExtractedAt` 三个可选字段;普通用户不返回。
+3. 管理员"编辑"界面展示正文解析状态:FAILED 时红底展示失败原因,EMPTY 时提示可能是扫描件或无文本层;并提供"重新解析"按钮。
+4. 新增 `POST /api/v1/documents/{id}/re-extract`(仅 ADMIN,主案/子案/附件通用):按当前存储文件重新解析并覆盖写回提取结果与 `search_text`,`rowVersion` 递增,审计复用 `EDIT_DOCUMENT`(`detail.scope=reExtractContent`);文件缺失返回 `404 FILE_NOT_FOUND`;提取异常不返回 5xx,落库为 `FAILED` 携带脱敏原因。
+5. PDF 正文提取合并段内软换行:优先按 PyMuPDF 版面块边界识别段落(标题/落款独立成块,不依赖标点),块内合并软换行且相邻行主字号相对差 ≥20% 视为段落边界;整页为逐行等距同字号退化网格时回退纯文本启发式(空行、句末标点结尾、条目/章节编号开头保留换行视为段落边界,其余合并,两侧均为 ASCII 字母数字时补空格,中文直接相连);页间以空行分隔。
+6. 数据库迁移为 `0019_content_extract_error`,OpenAPI `info.version` 升级为 `1.12.0`。
+
+版本1.13经统领会话批准,引入登录界面自定义背景图:
+
+1. 新增公开接口 `GET /api/v1/config/login-background`,登录前无凭证可访问;按序探测服务端运行目录下 `background.jpg`、`background.png`(jpg优先),命中即返回图片二进制(`image/jpeg`/`image/png`,含 `nosniff` 与 `public, max-age=60` 缓存头)。
+2. 均不存在时返回 `404 FILE_NOT_FOUND` 标准错误体,前端回退默认纯色背景,不弹错误提示。
+3. 前端账号登录、门户跳转登录、认证状态页三类登录类界面自动探测该接口:有图时拉伸铺满(cover 居中不重复,保留纯色兜底),同一会话只探测一次并缓存结果。
+4. 用户通过在运行目录放置/替换/删除 `background.jpg` 或 `background.png` 自定义背景,重启或刷新后生效;无数据库变更,OpenAPI `info.version` 升级为 `1.13.0`。
+
 ## 18. Q3-B 1.4业务对齐
 
 1. 正式角色仅保留 `ADMIN` 和 `USER`。`ADMIN`返回`DOCUMENT_BROWSER`、`BACKEND_MANAGEMENT`、`AUDIT_LOG`,`USER`只返回`DOCUMENT_BROWSER`。四个审计查询接口和文档统计接口仅ADMIN可访问。历史AUDITOR必须失效旧Token、停用并逻辑删除,不得提升为ADMIN。
 2. 只有有效叶子分类可直接存放MAIN。选择存在有效子分类的分类创建或编辑MAIN时返回`409 CATEGORY_NOT_LEAF`,并在details返回`categoryId`和`childCategoryCount`。已直接存放有效MAIN的叶子分类不得新增有效子分类,也不得成为分类移动目标。
-3. `CategoryNode.documentCount`表示当前JWT用户在该分类完整有效子树内有权查看的有效MAIN数量。关键词过滤保留祖先节点时,计数仍基于完整业务子树,不按过滤后的可见节点截断。
+3. `CategoryNode.documentCount`表示该分类完整有效子树内所有有效MAIN数量(含本分类与全部后代分类),不按当前用户密级/权限过滤,所有登录用户口径一致;计数沿父链穿过停用分类节点累加。关键词过滤保留祖先节点时,计数仍基于完整业务子树,不按过滤后的可见节点截断。
 4. `GET /api/v1/documents`支持`includeDescendants`。未传`categoryId`时查询全部可见MAIN;传分类并设置true时查询该分类及全部有效后代;false或缺省时精确匹配分类。
 5. MAIN创建不接收status和visibilityType,服务端固定为`PUBLISHED`、`CUSTOM`且初始ACL为空。SUB_PLAN创建不接收categoryId、status或visibilityType,分类继承父MAIN,状态固定`PUBLISHED`,可见范围动态继承父MAIN。编辑同样不接收status和visibilityType,SUB_PLAN也不接收categoryId;主案改分类时同步有效子方案的分类冗余。
-6. 独立共享附件库仅ADMIN可枚举和管理。USER仅能通过至少一个有VIEW或DOWNLOAD权限的有效已挂载主案访问对应附件详情、预览或下载。
+6. 独立共享附件库的列表查询对所有登录用户开放且全量可见(支撑全局检索按标题命中全部附件),普通用户返回的`allowedActions`为空集且不返回`description`与`contentSnippet`;上传、编辑、删除等管理操作仍仅ADMIN。USER仅能通过至少一个有VIEW或DOWNLOAD权限的有效已挂载主案访问对应附件详情、预览或下载。
 7. PDF与DOCX预览返回原文件二进制及准确MIME,使用inline Content-Disposition、`nosniff`和`private, no-store`,并执行授权、相对路径、普通文件、存在性、大小、SHA-256、扩展名、文件头及Office ZIP结构校验。DOC、XLS、XLSX返回`415 PREVIEW_UNAVAILABLE`。预览不增加downloadCount且不写DOWNLOAD_DOCUMENT审计。
 8. `doc_document`保存`content_text`、`content_extract_status`和`content_extracted_at`;`search_text`升级为LONGTEXT并组合名称、摘要、标签和正文。DOCX提取段落、表格、页眉页脚,PDF提取文本层;DOC为UNSUPPORTED。提取失败不丢失原文件。回填命令只允许显式连接`dms_test`且不得自动执行。
 9. 业务时区来自`DMS_BUSINESS_TIMEZONE`,默认`Asia/Shanghai`,非法值在应用配置加载阶段失败。本版本数据库迁移为`0003_q3_business_alignment_and_content_search`,OpenAPI版本为`1.4.0`。

+ 17 - 3
FUNCTION_AND_API_SPECIFICATION.md

@@ -182,6 +182,11 @@
 存在有效子分类或有效文档时拒绝删除;否则进行逻辑删除。
 
 ## 6.3 主案管理
+> **写操作权限口径(2026-08 更新)**:上传/批量导入仍为管理员专属;
+> 编辑、删除、授权、密码锁、挂载/解除挂载与排序改为按文档权限校验——
+> 密级前置 + 角色矩阵(密级×部门范围×操作位,与文档授权 OR 叠合),
+> 管理员恒通过,普通用户命中对应操作位即可执行。设密主案另需通过操作密码锁。
+
 
 ### F-MAIN-001 查询主案列表
 
@@ -221,11 +226,11 @@
 
 ### F-MAIN-004 编辑主案
 
-管理员修改文档名称、概述、分类、密级、可见范围、标签和状态,不直接在线编辑文件内容。
+按文档权限(`EDIT` 位)修改文档名称、概述、分类、密级、可见范围、标签和状态,不直接在线编辑文件内容。非管理员不可调整文档归属部门。
 
 ### F-MAIN-005 删除与恢复主案
 
-- 删除使用逻辑删除。
+- 删除使用逻辑删除(按文档权限 `DELETE` 位)。
 - 存在有效子方案时拒绝删除。
 - 删除时只删除主案与附件的挂载关系,不删除共享附件。
 - 恢复只使用 `/api/v1/recycle-bin/documents/{id}/restore`;旧 `/api/v1/documents/{id}/restore` 永久废止。
@@ -909,4 +914,13 @@ HTTP状态、message和details结构以 `DMS_API_CONTRACT.md` 1.1为准,响应
 
 ## 22. Q3-B 1.4同步说明
 
-Q3-B将正式角色收敛为ADMIN和USER,审计能力归入ADMIN;分类仅允许叶子节点直接存放MAIN,分类树计数按当前用户在完整子树中的可见MAIN计算;文档列表支持`includeDescendants`。MAIN/SUB_PLAN创建和编辑不再接收status、visibilityType,SUB_PLAN分类继承主案。独立附件库仅ADMIN可枚举,USER按已挂载主案权限访问附件。统一preview路径原样返回PDF或DOCX,DOC/XLS/XLSX返回415,不调用Office类软件。`0003_q3_business_alignment_and_content_search`增加正文提取字段并将search_text扩展为LONGTEXT,keyword组合检索元数据与正文;当前参数化LIKE适用于现阶段规模,但数据量增长后存在全表/大文本扫描风险,后续索引或搜索引擎方案必须另行评审。OpenAPI为1.4.0。
+Q3-B将正式角色收敛为ADMIN和USER,审计能力归入ADMIN;分类仅允许叶子节点直接存放MAIN,分类树计数按完整有效子树内全部有效MAIN计算(不按当前用户密级/权限过滤,所有登录用户口径一致);文档列表支持`includeDescendants`。MAIN/SUB_PLAN创建和编辑不再接收status、visibilityType,SUB_PLAN分类继承主案。独立附件库列表对所有登录用户开放且全量可见(普通用户allowedActions为空、不返回description与正文片段,管理操作仍仅ADMIN),USER按已挂载主案权限访问附件内容。统一preview路径原样返回PDF或DOCX,DOC/XLS/XLSX返回415,不调用Office类软件。`0003_q3_business_alignment_and_content_search`增加正文提取字段并将search_text扩展为LONGTEXT,keyword组合检索元数据与正文;当前参数化LIKE适用于现阶段规模,但数据量增长后存在全表/大文本扫描风险,后续索引或搜索引擎方案必须另行评审。OpenAPI为1.4.0。
+
+## 23. 1.12正文提取状态与重新解析同步说明
+
+针对部分文档上传后无解析文本的问题:`doc_document`新增`content_extract_error`列记录脱敏失败原因(迁移`0019_content_extract_error`);文档/附件详情仅对ADMIN返回`contentExtractStatus/contentExtractError/contentExtractedAt`;管理员"编辑"界面展示解析状态与失败原因并提供"重新解析"按钮,调用新增的`POST /api/v1/documents/{id}/re-extract`(主案/子案/附件通用,审计复用EDIT_DOCUMENT且`detail.scope=reExtractContent`)。同时修复Office类型(doc/wps/xls/xlsx/et)上传时因转换分支缺少`pathlib.Path`导入导致正文提取恒失败的问题,上传与重新解析共用同一提取逻辑。PDF提取合并段内软换行:优先按PyMuPDF版面块边界识别段落(标题/落款独立成块,不依赖标点),块内合并且相邻行主字号相对差≥20%视为段落边界;整页为逐行等距同字号退化网格时回退纯文本启发式(空行、句末标点、条目/章节编号保留换行,其余合并,英文单词间补空格)。OpenAPI为1.12.0。
+
+## 24. 1.13登录界面自定义背景图同步说明
+
+支持用户自定义登录界面背景:新增公开接口`GET /api/v1/config/login-background`(登录前无凭证可访问),按序探测服务端运行目录下`background.jpg`、`background.png`(jpg优先),命中即返回图片二进制(`image/jpeg`/`image/png`,含`nosniff`与`public, max-age=60`缓存头);均不存在时返回`404 FILE_NOT_FOUND`标准错误体。前端账号登录、门户跳转登录、认证状态页三类界面自动探测该接口:有图时拉伸铺满(cover居中不重复,保留纯色兜底),无图回退默认纯色背景;同一会话只探测一次并缓存objectURL。用户通过在运行目录放置/替换/删除`background.jpg`或`background.png`自定义背景,重启或刷新后生效。无数据库变更,OpenAPI为1.13.0。
+

+ 5 - 0
backend/app.py

@@ -23,6 +23,7 @@ from typing import Any, Iterator
 
 from flask import Flask, Response, jsonify, request, send_from_directory
 from flask_cors import CORS
+from werkzeug.exceptions import NotFound
 
 from dms import init_dms
 
@@ -74,6 +75,10 @@ if FRONTEND_DIST is not None:
     def serve_spa(path: str):
         # /api/* 已由上方蓝图和显式路由优先匹配;漏网的 api 请求返回 404
         if path.startswith("api/"):
+            # /api/v1 漏网请求抛出路由404,交给DMS统一错误处理器,
+            # 保持错误结构与X-Request-Id响应头一致(一体化部署下同样生效)
+            if path == "api/v1" or path.startswith("api/v1/"):
+                raise NotFound()
             return jsonify({"error": "Not found"}), 404
         candidate = FRONTEND_DIST / path
         if path and candidate.is_file():

+ 4 - 2
backend/dms/api/v1/auth.py

@@ -25,7 +25,7 @@ from dms.security.jwt_tokens import issue_access_token
 from dms.security.passwords import verify_password
 from dms.services.audit_service import auth_audit, client_ip, record_login_failure
 from dms.services.ip_entry_service import verify_login_ip
-from dms.services.user_service import user_summary
+from dms.services.user_service import allowed_modules_for_user, user_summary
 
 
 def _login_payload() -> tuple[str, str, bool]:
@@ -103,7 +103,9 @@ def login():
             "accessToken": token,
             "tokenType": "Bearer",
             "expiresIn": expires_in,
-            "user": user_summary(user),
+            "user": user_summary(
+                user, allowed_modules=allowed_modules_for_user(user)
+            ),
         }
     )
 

+ 1 - 0
backend/dms/api/v1/blueprint.py

@@ -120,6 +120,7 @@ from dms.api.v1 import (  # noqa: E402,F401
     categories,
     documents,
     ip_entries,
+    login_background,
     organizations,
     portal_auth,
     recycle_bin,

+ 36 - 14
backend/dms/api/v1/documents.py

@@ -44,6 +44,7 @@ from dms.services.document_mutation_service import (
     create_document,
     delete_attachment,
     delete_document,
+    re_extract_content,
     reorder_documents,
     update_attachment,
     update_document,
@@ -52,6 +53,10 @@ from dms.services.document_mutation_service import (
 from dms.services.document_password_service import save_document_password
 from dms.services.file_read_service import download_document, preview_document
 from dms.services.permission_service import get_permissions, save_permissions
+from dms.services.sub_plan_binding_service import (
+    bind_sub_plans,
+    unbind_sub_plan,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -195,7 +200,6 @@ def document_relations_route(id: str):
 
 @api_v1.put("/documents/<id>/relations")
 @bearer_auth_required
-@admin_required
 def save_document_relations_route(id: str):
     """整体替换方案的关联清单(管理员;设密主案需通过EDIT密码锁)。"""
     return success_response(
@@ -205,7 +209,6 @@ def save_document_relations_route(id: str):
 
 @api_v1.put("/documents/reorder")
 @bearer_auth_required
-@admin_required
 def documents_reorder_route():
     """管理员拖动重排主案/子方案(主案全局排序,子方案在所属主案内排序)。"""
     return success_response(reorder_documents(_json_body()))
@@ -237,7 +240,6 @@ def document_detail_route(id: str):
 
 @api_v1.put("/documents/<id>")
 @bearer_auth_required
-@admin_required
 def update_document_route(id: str):
     return success_response(
         update_document(parse_string_id(id), _json_body())
@@ -246,7 +248,6 @@ def update_document_route(id: str):
 
 @api_v1.put("/documents/<id>/description")
 @bearer_auth_required
-@admin_required
 def update_document_description_route(id: str):
     """维护文档详细说明("查看"对话框右侧备注区)。"""
     return success_response(
@@ -256,13 +257,20 @@ def update_document_description_route(id: str):
 
 @api_v1.delete("/documents/<id>")
 @bearer_auth_required
-@admin_required
 def delete_document_route(id: str):
     return success_response(
         delete_document(parse_string_id(id), _row_version())
     )
 
 
+@api_v1.post("/documents/<id>/re-extract")
+@bearer_auth_required
+@admin_required
+def re_extract_document_route(id: str):
+    """管理员对存量文档重新提取正文(修复提取失败/空文本)。"""
+    return success_response(re_extract_content(parse_string_id(id)))
+
+
 @api_v1.get("/documents/<id>/preview")
 @bearer_auth_required
 def preview_document_route(id: str):
@@ -281,10 +289,32 @@ def sub_plans_list_route(id: str):
     return success_response(list_sub_plans(parse_string_id(id), request.args))
 
 
+@api_v1.post("/main-plans/<id>/sub-plans/bind")
+@bearer_auth_required
+def bind_main_plan_sub_plans_route(id: str):
+    """挂载其他主案的子案到当前主案(建立展示关系,权限仍继承原主案)。"""
+    return success_response(
+        bind_sub_plans(parse_string_id(id), _json_body())
+    )
+
+
+@api_v1.delete("/main-plans/<id>/sub-plans/<subPlanId>")
+@bearer_auth_required
+def unbind_main_plan_sub_plan_route(id: str, subPlanId: str):
+    return success_response(
+        unbind_sub_plan(
+            parse_string_id(id),
+            parse_string_id(subPlanId, field="subPlanId"),
+            _main_plan_row_version(),
+        )
+    )
+
+
 @api_v1.get("/attachments")
 @bearer_auth_required
-@admin_required
 def attachments_list_route():
+    # 全量可见:普通用户全局检索依赖附件标题命中;allowedActions 为空集,
+    # 内容访问由详情/预览/下载接口按挂载主案权限独立拦截。
     return success_response(list_attachments(request.args))
 
 
@@ -314,7 +344,6 @@ def attachment_detail_route(id: str):
 
 @api_v1.put("/attachments/<id>")
 @bearer_auth_required
-@admin_required
 def update_attachment_route(id: str):
     return success_response(
         update_attachment(parse_string_id(id), _json_body())
@@ -323,7 +352,6 @@ def update_attachment_route(id: str):
 
 @api_v1.delete("/attachments/<id>")
 @bearer_auth_required
-@admin_required
 def delete_attachment_route(id: str):
     return success_response(
         delete_attachment(parse_string_id(id), _row_version())
@@ -348,7 +376,6 @@ def main_plan_attachments_route(id: str):
 
 @api_v1.post("/main-plans/<id>/attachments/bind")
 @bearer_auth_required
-@admin_required
 def bind_main_plan_attachments_route(id: str):
     return success_response(
         bind_attachments(parse_string_id(id), _json_body())
@@ -357,7 +384,6 @@ def bind_main_plan_attachments_route(id: str):
 
 @api_v1.put("/main-plans/<id>/attachments/reorder")
 @bearer_auth_required
-@admin_required
 def reorder_main_plan_attachments_route(id: str):
     """调整主案下挂载附件的显示顺序(顺序存于挂载关系,各主案独立)。"""
     return success_response(
@@ -367,7 +393,6 @@ def reorder_main_plan_attachments_route(id: str):
 
 @api_v1.delete("/main-plans/<id>/attachments/<attachmentId>")
 @bearer_auth_required
-@admin_required
 def unbind_main_plan_attachment_route(id: str, attachmentId: str):
     return success_response(
         unbind_attachment(
@@ -380,14 +405,12 @@ def unbind_main_plan_attachment_route(id: str, attachmentId: str):
 
 @api_v1.get("/documents/<id>/permissions")
 @bearer_auth_required
-@admin_required
 def document_permissions_route(id: str):
     return success_response(get_permissions(parse_string_id(id)))
 
 
 @api_v1.put("/documents/<id>/permissions")
 @bearer_auth_required
-@admin_required
 def save_document_permissions_route(id: str):
     return success_response(
         save_permissions(parse_string_id(id), _json_body())
@@ -396,7 +419,6 @@ def save_document_permissions_route(id: str):
 
 @api_v1.put("/documents/<id>/password")
 @bearer_auth_required
-@admin_required
 def save_document_password_route(id: str):
     return success_response(
         save_document_password(parse_string_id(id), _json_body())

+ 59 - 0
backend/dms/api/v1/login_background.py

@@ -0,0 +1,59 @@
+"""登录界面自定义背景图接口。
+
+用户将 background.jpg 或 background.png 放到服务端运行目录
+(打包模式为 exe 同级目录,开发模式为仓库根目录)即可让登录界面
+自动展示自定义背景;两个文件同时存在时优先 jpg。
+
+安全说明:
+- 接口公开(登录前无凭证可访问),不返回任何敏感信息;
+- 候选文件名为固定白名单,不接收路径参数,无目录穿越风险。
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+from flask import current_app, send_file
+
+from dms.api.v1.blueprint import api_v1
+from dms.common.errors import FileNotFoundError as DmsFileNotFoundError
+
+# 候选文件名(固定白名单):同时存在时按列表顺序优先
+_BACKGROUND_FILES: dict[str, str] = {
+    "background.jpg": "image/jpeg",
+    "background.png": "image/png",
+}
+
+
+def _runtime_root() -> Path:
+    """服务端运行目录,与 launcher._app_root 同语义。
+
+    - PyInstaller 打包模式:exe 同级目录(用户可见、可写)
+    - 开发模式:本文件位于 backend/dms/api/v1/ 下,仓库根在上级第四层
+    """
+    if getattr(sys, "frozen", False):
+        return Path(sys.executable).resolve().parent
+    return Path(__file__).resolve().parents[4]
+
+
+def _candidate_paths() -> list[tuple[Path, str]]:
+    # 测试与特殊部署可通过 DMS_LOGIN_BACKGROUND_DIR 覆盖运行目录
+    root = Path(
+        current_app.config.get("DMS_LOGIN_BACKGROUND_DIR") or _runtime_root()
+    )
+    return [
+        (root / name, mime) for name, mime in _BACKGROUND_FILES.items()
+    ]
+
+
+@api_v1.get("/config/login-background")
+def get_login_background():
+    for path, mime in _candidate_paths():
+        if path.is_file():
+            response = send_file(path, mimetype=mime, conditional=True)
+            response.headers["X-Content-Type-Options"] = "nosniff"
+            # 背景图变更频率低,允许浏览器短缓存减少首屏请求
+            response.headers["Cache-Control"] = "public, max-age=60"
+            return response
+    raise DmsFileNotFoundError("运行目录下不存在 background.jpg/png 背景图")

+ 5 - 1
backend/dms/api/v1/users.py

@@ -14,6 +14,7 @@ from dms.models import User
 from dms.security.auth_context import get_auth_context
 from dms.security.decorators import admin_required, bearer_auth_required
 from dms.services.user_service import (
+    allowed_modules_for_user,
     create_user,
     delete_user,
     list_users,
@@ -74,7 +75,10 @@ def current_user():
     )
     if user is None:
         raise AuthenticationError()
-    return success_response(user_summary(user))
+    # 会话自描述接口:模块可见性按该用户对文档的实际权限动态计算。
+    return success_response(
+        user_summary(user, allowed_modules=allowed_modules_for_user(user))
+    )
 
 
 @api_v1.get("/users")

+ 8 - 2
backend/dms/backfill_preview_cache.py

@@ -25,7 +25,11 @@ from dms.common.enums import ContentExtractStatus
 from dms.extensions import db
 from dms.models import Document
 from dms.services.document_converter import create_converter
-from dms.services.preview_service import PreviewService, extract_doc_content_via_pdf
+from dms.services.preview_service import (
+    PreviewService,
+    extract_doc_content_via_pdf,
+    preview_cache_key,
+)
 from dms.services.document_content_service import build_search_text
 from dms.storage.paths import preview_cache_path, resolve_storage_path
 
@@ -105,7 +109,9 @@ def backfill(*, dry_run: bool = False, limit: int | None = None) -> dict[str, in
                 continue
 
             preview_service = PreviewService(storage_root, converter)
-            cache_path = preview_cache_path(document.file_hash, storage_root)
+            cache_path = preview_cache_path(
+                preview_cache_key(document.file_hash, extension), storage_root
+            )
             if preview_service.validate_cached_pdf(cache_path):
                 result["skipped"] += 1
             elif dry_run:

+ 7 - 0
backend/dms/common/errors.py

@@ -86,6 +86,13 @@ class DocumentDownloadForbiddenError(ForbiddenError):
     default_message = "无权下载该文档"
 
 
+class DocumentActionForbiddenError(ForbiddenError):
+    """写路径按文档权限(矩阵/ACL)拒绝:区别于密级不足与查看/下载拒绝。"""
+
+    code = "DOCUMENT_ACTION_FORBIDDEN"
+    default_message = "无权对该文档执行此操作"
+
+
 class ResourceNotFoundError(DmsError):
     code = "RESOURCE_NOT_FOUND"
     status_code = 404

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

@@ -9,6 +9,7 @@ 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.sub_plan_binding import SubPlanBinding
 from dms.models.user import User
 
 __all__ = [
@@ -24,5 +25,6 @@ __all__ = [
     "Role",
     "RoleDepartment",
     "RolePermission",
+    "SubPlanBinding",
     "User",
 ]

+ 7 - 0
backend/dms/models/category.py

@@ -93,6 +93,13 @@ class Category(BusinessTableMixin, db.Model):
         nullable=False,
         server_default=text("0"),
     )
+    # 类型显示名称:仅一级分类(parent_id IS NULL)有意义。
+    # 检索结果列表中,该分类(含子分类)下主体方案的类型徽标
+    # 显示该名称;NULL 表示未设置,回退显示分类名称。
+    type_display_name: Mapped[str | None] = mapped_column(
+        String(128),
+        nullable=True,
+    )
     active_marker: Mapped[int | None] = mapped_column(
         Integer,
         Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),

+ 5 - 0
backend/dms/models/document.py

@@ -232,6 +232,11 @@ class Document(BusinessTableMixin, db.Model):
         nullable=False,
         server_default=text("'UNSUPPORTED'"),
     )
+    # 提取失败原因(脱敏,仅供管理员排障展示;成功/空文本时为 NULL)
+    content_extract_error: Mapped[str | None] = mapped_column(
+        String(512),
+        nullable=True,
+    )
     content_extracted_at: Mapped[datetime | None] = mapped_column(
         mysql.DATETIME(fsp=3),
         nullable=True,

+ 53 - 0
backend/dms/models/sub_plan_binding.py

@@ -0,0 +1,53 @@
+"""主案与子案(跨主案)挂载关系模型。"""
+
+from __future__ import annotations
+
+from sqlalchemy import BigInteger, Computed, ForeignKey, Index, Integer, UniqueConstraint, text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from dms.database.base import BusinessTableMixin
+from dms.extensions import db
+
+
+class SubPlanBinding(BusinessTableMixin, db.Model):
+    __tablename__ = "doc_sub_plan_binding"
+    __table_args__ = (
+        UniqueConstraint(
+            "main_document_id",
+            "sub_document_id",
+            "active_marker",
+            name="uq_doc_sub_plan_binding_active",
+        ),
+        Index(
+            "ix_doc_sub_plan_binding_main_active_sort",
+            "main_document_id",
+            "is_deleted",
+            "sort_no",
+        ),
+        Index(
+            "ix_doc_sub_plan_binding_sub_active",
+            "sub_document_id",
+            "is_deleted",
+        ),
+    )
+
+    main_document_id: Mapped[int] = mapped_column(
+        BigInteger,
+        ForeignKey("doc_document.id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    sub_document_id: Mapped[int] = mapped_column(
+        BigInteger,
+        ForeignKey("doc_document.id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    sort_no: Mapped[int] = mapped_column(
+        Integer,
+        nullable=False,
+        server_default=text("0"),
+    )
+    active_marker: Mapped[int | None] = mapped_column(
+        Integer,
+        Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),
+        nullable=True,
+    )

+ 8 - 1
backend/dms/services/attachment_binding_service.py

@@ -20,7 +20,11 @@ from dms.extensions import db
 from dms.models import AttachmentBinding, Document
 from dms.security.auth_context import get_auth_context
 from dms.services.audit_service import business_audit
-from dms.services.authorization_service import require_security_clearance
+from dms.common.enums import AllowedAction
+from dms.services.authorization_service import (
+    require_plan_action,
+    require_security_clearance,
+)
 from dms.services.document_password_service import (
     DocumentPasswordAction,
     get_request_password,
@@ -136,6 +140,7 @@ def bind_attachments(main_document_id: int, payload: Any) -> dict[str, int]:
     def operation() -> dict[str, int]:
         with transaction() as session:
             main = _locked_main(session, main_document_id)
+            require_plan_action(main, context, AllowedAction.BIND_ATTACHMENT)
             require_document_password(
                 main,
                 DocumentPasswordAction.BIND_ATTACHMENT.value,
@@ -251,6 +256,7 @@ def unbind_attachment(
     def operation() -> dict[str, int]:
         with transaction() as session:
             main = _locked_main(session, main_document_id)
+            require_plan_action(main, context, AllowedAction.UNBIND_ATTACHMENT)
             require_document_password(
                 main,
                 DocumentPasswordAction.UNBIND_ATTACHMENT.value,
@@ -344,6 +350,7 @@ def reorder_main_plan_attachments(
     def operation() -> dict[str, int]:
         with transaction() as session:
             main = _locked_main(session, main_document_id)
+            require_plan_action(main, context, AllowedAction.BIND_ATTACHMENT)
             _check_version(main, expected_version)
             bindings = session.scalars(
                 select(AttachmentBinding)

+ 29 - 5
backend/dms/services/attachment_query_service.py

@@ -6,7 +6,7 @@ from typing import Mapping
 
 from sqlalchemy import func, select
 
-from dms.common.enums import AttachmentType, DocumentType
+from dms.common.enums import AttachmentType, DocumentType, RoleCode
 from dms.common.errors import ResourceNotFoundError
 from dms.common.pagination import page_result
 from dms.common.response import serialize_id
@@ -108,6 +108,19 @@ def attachment_detail(
             "contentText": attachment.content_text,
         }
     )
+    # 提取状态/失败原因仅供管理员排障("编辑"界面提示与重新解析)
+    if context.role_code == RoleCode.ADMIN:
+        result.update(
+            {
+                "contentExtractStatus": attachment.content_extract_status,
+                "contentExtractError": attachment.content_extract_error,
+                "contentExtractedAt": (
+                    _iso(attachment.content_extracted_at)
+                    if attachment.content_extracted_at
+                    else None
+                ),
+            }
+        )
     return result
 
 
@@ -161,11 +174,22 @@ def list_attachments(params: Mapping[str, str]) -> dict[str, object]:
     attachments = db.session.scalars(_filtered_attachments(params)).all()
     total = len(attachments)
     selected = attachments[page.offset : page.offset + page.page_size]
+    # 列表全量可见(全局检索需要):普通用户同样返回全部附件标题,
+    # 但 allowedActions 为空集,且不返回详细说明与正文片段;
+    # 内容访问由详情/预览/下载接口按挂载主案权限独立拦截。
+    is_admin = context.role_code == RoleCode.ADMIN
+    items: list[dict[str, object]] = []
+    for item in selected:
+        summary = attachment_summary(
+            item,
+            context,
+            keyword=params.get("contentKeyword") if is_admin else None,
+        )
+        if not is_admin:
+            summary["description"] = None
+        items.append(summary)
     return page_result(
-        [
-            attachment_summary(item, context, keyword=params.get("contentKeyword"))
-            for item in selected
-        ],
+        items,
         page=page.page,
         page_size=page.page_size,
         total=total,

+ 179 - 1
backend/dms/services/authorization_service.py

@@ -5,7 +5,7 @@ from __future__ import annotations
 import logging
 from dataclasses import dataclass
 
-from sqlalchemy import func, select
+from sqlalchemy import and_, exists, false, func, or_, select, true
 
 from dms.common.enums import (
     AllowedAction,
@@ -31,6 +31,7 @@ from dms.models import (
 )
 from dms.security.auth_context import AuthContext
 from dms.common.errors import (
+    DocumentActionForbiddenError,
     DocumentDownloadForbiddenError,
     DocumentViewForbiddenError,
     SecurityLevelForbiddenError,
@@ -77,6 +78,53 @@ def require_security_clearance(
         raise SecurityLevelForbiddenError()
 
 
+# 写路径操作码 → 中文动词(拒绝提示与前端口径一致)
+ACTION_LABELS: dict[str, str] = {
+    AllowedAction.VIEW.value: "查看",
+    AllowedAction.DOWNLOAD.value: "下载",
+    AllowedAction.EDIT.value: "编辑",
+    AllowedAction.DELETE.value: "删除",
+    AllowedAction.CONFIG_PERMISSION.value: "授权",
+    AllowedAction.BIND_ATTACHMENT.value: "挂载",
+    AllowedAction.UNBIND_ATTACHMENT.value: "解除挂载",
+}
+
+
+def require_plan_action(
+    document: Document,
+    context: AuthContext,
+    action: AllowedAction,
+) -> PlanAccess | None:
+    """写路径集中鉴权:密级前置 + 文档访问 + 操作位校验。
+
+    与读路径/前端按钮同源(evaluate_plan_access + plan_allowed_actions),
+    保证"按钮可点 ⟺ 后端放行":
+
+    - 密级不足:任何角色(含 ADMIN)一律拒绝;
+    - 共享附件:矩阵不授予编辑/删除/授权/挂载位,仅 ADMIN 可维护;
+    - 主案/子方案:ADMIN 恒过(密级通过后);USER 由矩阵操作位判定,
+      子方案自动过滤授权/挂载/解除挂载位(继承自主案)。
+    """
+    if not _security_allowed(document, context):
+        raise SecurityLevelForbiddenError()
+    label = ACTION_LABELS.get(action.value, "执行该操作")
+    if document.document_type == DocumentType.ATTACHMENT.value:
+        if context.role_code != RoleCode.ADMIN:
+            raise DocumentActionForbiddenError(
+                f"无权{label}该共享附件:仅管理员可维护共享附件"
+            )
+        return None
+    access = evaluate_plan_access(document, context)
+    if not access.allowed:
+        raise DocumentActionForbiddenError(f"无权{label}该文档")
+    actions = plan_allowed_actions(document, context, access)
+    if action.value not in actions:
+        raise DocumentActionForbiddenError(
+            f"无权{label}该文档:角色权限未授予该操作"
+        )
+    return access
+
+
 def _active_organization_ancestor_ids(
     organization_id: int | None,
 ) -> set[int]:
@@ -392,3 +440,133 @@ def require_attachment_access(
     if not download and not can_view:
         raise DocumentViewForbiddenError()
     return can_view, can_download
+
+
+# 管理类操作位:命中任一即视为“对该文档有管理能力”,用于动态展示后台管理菜单。
+MANAGE_MATRIX_COLUMNS: tuple[str, ...] = (
+    "can_edit",
+    "can_delete",
+    "can_config_permission",
+    "can_bind_attachment",
+    "can_unbind_attachment",
+)
+
+
+def _organization_subtree_ids(organization_id: int | None) -> set[int]:
+    """用户机构的下属子树(含自身),与 _dept_scope 的 OWN 判定同口径。"""
+    if organization_id is None:
+        return set()
+    organizations = db.session.scalars(
+        select(Organization).where(
+            Organization.is_deleted.is_(False),
+            Organization.status == EnabledStatus.ENABLED.value,
+        )
+    ).all()
+    by_id = {organization.id: organization for organization in organizations}
+    root = by_id.get(organization_id)
+    if root is None:
+        return set()
+    prefix = root.org_path if root.org_path.endswith("/") else root.org_path + "/"
+    return {
+        organization.id
+        for organization in organizations
+        if organization.id == organization_id
+        or (organization.org_path or "").startswith(prefix)
+    }
+
+
+def has_any_manage_action(context: AuthContext) -> bool:
+    """用户是否对任一已发布主案具备管理类操作(编辑/删除/授权/挂载/解除挂载)。
+
+    与 plan_allowed_actions 同源:ADMIN 恒真;USER 由角色权限矩阵推导——
+    矩阵行命中(密级×部门范围)且存在“可查看”的对应主案时管理位才生效。
+    只看主案即可完整覆盖子案:子案矩阵取自其根主案的同一行,主案命中
+    子案必命中。用于登录/会话接口动态计算 allowedModules 中的
+    BACKEND_MANAGEMENT,替代“管理员固定可见”的角色硬编码。
+    """
+    if context.role_code == RoleCode.ADMIN:
+        return True
+    role = _effective_role(context)
+    if role is None:
+        return False
+    rows = db.session.scalars(
+        select(RolePermission).where(
+            RolePermission.role_id == role.id,
+            RolePermission.is_deleted.is_(False),
+        )
+    ).all()
+    candidates = [
+        row
+        for row in rows
+        if any(getattr(row, column) for column in MANAGE_MATRIX_COLUMNS)
+    ]
+    if not candidates:
+        return False
+
+    # 部门范围三区集合: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_(
+        Permission.subject_type == SubjectType.ORG.value,
+        (
+            Permission.subject_id.in_(ancestor_ids)
+            if ancestor_ids
+            else false()
+        ),
+    )
+    user_subject = and_(
+        Permission.subject_type == SubjectType.USER.value,
+        Permission.subject_id == context.user_id,
+        Document.visibility_type == VisibilityType.CUSTOM.value,
+    )
+    acl_view_granted = exists(
+        select(Permission.id).where(
+            Permission.document_id == Document.id,
+            Permission.is_deleted.is_(False),
+            Permission.can_view.is_(True),
+            or_(org_subject, user_subject),
+        )
+    )
+
+    for row in candidates:
+        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),
+            )
+        # 查看权三来源(与 evaluate_plan_access 同口径):
+        # ① 本矩阵行勾选查看;② 全员可见;③ 文档级 ACL 授权查看。
+        view_sources: list = [
+            Document.visibility_type == VisibilityType.ALL_AUTHENTICATED.value,
+            acl_view_granted,
+        ]
+        if row.can_view:
+            view_sources.append(true())
+        hit = db.session.scalar(
+            select(Document.id).where(
+                Document.is_deleted.is_(False),
+                Document.document_status == DocumentStatus.PUBLISHED.value,
+                Document.document_type == DocumentType.MAIN.value,
+                Document.security_level == row.security_level,
+                scope_filter,
+                or_(*view_sources),
+            ).limit(1)
+        )
+        if hit is not None:
+            return True
+    return False

+ 61 - 64
backend/dms/services/category_service.py

@@ -6,21 +6,15 @@ from collections import deque
 from datetime import datetime, timezone
 from typing import Any
 
-from sqlalchemy import and_, exists, func, or_, select, update
+from sqlalchemy import exists, func, select, update
 from sqlalchemy.exc import IntegrityError
 
 from dms.common.enums import (
     AuditAction,
     AuditTarget,
     CategoryType,
-    DocumentStatus,
     DocumentType,
     EnabledStatus,
-    RoleCode,
-    SECURITY_LEVEL_VALUES,
-    SecurityLevel,
-    SubjectType,
-    VisibilityType,
 )
 from dms.common.errors import (
     CategoryFlatModeConflictError,
@@ -33,10 +27,9 @@ from dms.common.errors import (
 from dms.common.response import serialize_id
 from dms.database.transaction import execute_with_deadlock_retry, transaction
 from dms.extensions import db
-from dms.models import AttachmentBinding, Category, Document, Permission
+from dms.models import AttachmentBinding, Category, Document, SubPlanBinding
 from dms.security.auth_context import get_auth_context
 from dms.services.audit_service import business_audit
-from dms.services.authorization_service import _active_organization_ancestor_ids
 
 
 CREATE_FIELDS = {
@@ -46,6 +39,7 @@ CREATE_FIELDS = {
     "parentId",
     "sortNo",
     "flatMode",
+    "typeDisplayName",
 }
 UPDATE_FIELDS = {
     "categoryName",
@@ -54,6 +48,7 @@ UPDATE_FIELDS = {
     "sortNo",
     "rowVersion",
     "flatMode",
+    "typeDisplayName",
 }
 
 
@@ -110,6 +105,19 @@ def _bool_field(value: Any, *, field: str) -> bool:
     return value
 
 
+def _nullable_text(value: Any, *, field: str, max_length: int) -> str | None:
+    if value is None:
+        return None
+    if not isinstance(value, str):
+        raise InvalidArgumentError(f"{field}必须是字符串或null")
+    normalized = value.strip()
+    if not normalized:
+        return None
+    if len(normalized) > max_length:
+        raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
+    return normalized
+
+
 def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
     if not isinstance(payload, dict) or set(payload) != expected:
         raise InvalidArgumentError(
@@ -152,6 +160,7 @@ def _dto(
             else document_count
         ),
         "flatMode": bool(category.flat_mode),
+        "typeDisplayName": category.type_display_name,
         "rowVersion": category.row_version,
         "children": children or [],
     }
@@ -199,57 +208,18 @@ def category_tree(
         .where(Category.is_deleted.is_(False))
         .order_by(Category.sort_no.asc(), Category.id.asc())
     ).all()
-    context = get_auth_context()
-    visible_main = select(Document.category_id, func.count(Document.id)).where(
-        Document.category_id.is_not(None),
-        Document.document_type == DocumentType.MAIN.value,
-        Document.is_deleted.is_(False),
-    )
-    allowed_security_levels = [
-        level.value
-        for level, rank in SECURITY_LEVEL_VALUES.items()
-        if rank <= SECURITY_LEVEL_VALUES[context.security_level]
-    ]
-    visible_main = visible_main.where(
-        Document.security_level.in_(allowed_security_levels)
-    )
-    if context.role_code == RoleCode.USER:
-        ancestor_ids = _active_organization_ancestor_ids(context.organization_id)
-        org_permission = exists(
-            select(Permission.id).where(
-                Permission.document_id == Document.id,
-                Permission.is_deleted.is_(False),
-                Permission.can_view.is_(True),
-                Permission.subject_type == SubjectType.ORG.value,
-                Permission.subject_id.in_(ancestor_ids or {-1}),
-            )
-        )
-        user_permission = exists(
-            select(Permission.id).where(
-                Permission.document_id == Document.id,
-                Permission.is_deleted.is_(False),
-                Permission.can_view.is_(True),
-                Permission.subject_type == SubjectType.USER.value,
-                Permission.subject_id == context.user_id,
-            )
-        )
-        visible_main = visible_main.where(
-            Document.document_status == DocumentStatus.PUBLISHED.value,
-            or_(
-                Document.visibility_type == VisibilityType.ALL_AUTHENTICATED.value,
-                and_(
-                    Document.visibility_type == VisibilityType.ORGANIZATION.value,
-                    org_permission,
-                ),
-                and_(
-                    Document.visibility_type == VisibilityType.CUSTOM.value,
-                    or_(org_permission, user_permission),
-                ),
-            ),
-        )
+    # 计数口径:本分类与全部后代分类下的所有有效主体方案(MAIN),
+    # 不按密级/状态/可见性/授权过滤——与"列表全量可见、内容访问受控"一致,
+    # 括号数字反映分类真实挂载规模,无权限方案由列表/详情接口单独控制。
     direct_main_counts = dict(
         db.session.execute(
-            visible_main.group_by(Document.category_id)
+            select(Document.category_id, func.count(Document.id))
+            .where(
+                Document.category_id.is_not(None),
+                Document.document_type == DocumentType.MAIN.value,
+                Document.is_deleted.is_(False),
+            )
+            .group_by(Document.category_id)
         ).all()
     )
     by_id = {item.id: item for item in categories}
@@ -283,11 +253,8 @@ def category_tree(
     else:
         included_ids = eligible_ids
 
-    subtree_counts = {
-        category.id: 0
-        for category in categories
-        if category.status == EnabledStatus.ENABLED.value
-    }
+    # 沿父链向上累加时穿过停用分类节点,保证父分类计数 = 子树全部主案数。
+    subtree_counts = {category.id: 0 for category in categories}
     for category_id, count in direct_main_counts.items():
         current_id = category_id
         seen: set[int] = set()
@@ -376,6 +343,13 @@ def create_category(payload: Any) -> dict[str, object]:
     # flat_mode 仅一级分类(parent_id IS NULL)有效;子分类强制 False
     flat_mode_input = _bool_field(body["flatMode"], field="flatMode")
     effective_flat_mode = False if parent_id is not None else flat_mode_input
+    # type_display_name 仅一级分类有效;子分类强制 None
+    type_display_name_input = _nullable_text(
+        body["typeDisplayName"], field="typeDisplayName", max_length=128
+    )
+    effective_type_display_name = (
+        None if parent_id is not None else type_display_name_input
+    )
     actor_id = get_auth_context().user_id
 
     try:
@@ -395,6 +369,7 @@ def create_category(payload: Any) -> dict[str, object]:
                 document_count=0,
                 status=EnabledStatus.ENABLED.value,
                 flat_mode=effective_flat_mode,
+                type_display_name=effective_type_display_name,
                 created_by=actor_id,
                 updated_by=actor_id,
             )
@@ -413,6 +388,7 @@ def create_category(payload: Any) -> dict[str, object]:
                         "categoryPath": category.category_path,
                         "sortNo": category.sort_no,
                         "flatMode": bool(category.flat_mode),
+                        "typeDisplayName": category.type_display_name,
                     },
                 )
             )
@@ -441,6 +417,10 @@ def update_category(category_id: int, payload: Any) -> dict[str, object]:
     parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
     sort_no = _sort_no(body["sortNo"])
     flat_mode_input = _bool_field(body["flatMode"], field="flatMode")
+    # type_display_name 仅一级分类有效;子分类强制 None(与 flat_mode 口径一致)
+    type_display_name_input = _nullable_text(
+        body["typeDisplayName"], field="typeDisplayName", max_length=128
+    )
     expected_version = _row_version(body["rowVersion"])
     actor_id = get_auth_context().user_id
     now = _utc_now()
@@ -498,6 +478,9 @@ def update_category(category_id: int, payload: Any) -> dict[str, object]:
 
         # flat_mode 切换保护:仅一级分类可启用;false→true 时拒绝已存在挂载数据
         new_flat_mode = False if category.parent_id is not None else flat_mode_input
+        new_type_display_name = (
+            None if category.parent_id is not None else type_display_name_input
+        )
         if new_flat_mode and not category.flat_mode:
             check_category_ids = descendant_ids | {category.id}
             main_plan_ids_subq = select(Document.id).where(
@@ -524,12 +507,23 @@ def update_category(category_id: int, payload: Any) -> dict[str, object]:
                     )
                 )
             )
-            if has_sub_plan or has_attachment:
+            has_sub_mount = session.scalar(
+                select(
+                    exists().where(
+                        SubPlanBinding.is_deleted.is_(False),
+                        SubPlanBinding.main_document_id.in_(
+                            main_plan_ids_subq
+                        ),
+                    )
+                )
+            )
+            if has_sub_plan or has_attachment or has_sub_mount:
                 raise CategoryFlatModeConflictError(
                     details={
                         "categoryId": serialize_id(category.id),
                         "hasSubPlan": bool(has_sub_plan),
                         "hasAttachment": bool(has_attachment),
+                        "hasSubPlanMount": bool(has_sub_mount),
                     }
                 )
 
@@ -540,6 +534,7 @@ def update_category(category_id: int, payload: Any) -> dict[str, object]:
             "categoryPath": category.category_path,
             "sortNo": category.sort_no,
             "flatMode": bool(category.flat_mode),
+            "typeDisplayName": category.type_display_name,
             "rowVersion": category.row_version,
         }
         path_changed = (
@@ -551,6 +546,7 @@ def update_category(category_id: int, payload: Any) -> dict[str, object]:
         category.parent_id = parent_id
         category.category_path = _path(parent, category_name)
         category.flat_mode = new_flat_mode
+        category.type_display_name = new_type_display_name
         category.sort_no = sort_no
         category.updated_by = actor_id
         category.updated_at = now
@@ -601,6 +597,7 @@ def update_category(category_id: int, payload: Any) -> dict[str, object]:
             "categoryPath": category.category_path,
             "sortNo": category.sort_no,
             "flatMode": bool(category.flat_mode),
+            "typeDisplayName": category.type_display_name,
             "rowVersion": category.row_version,
         }
         session.add(

+ 182 - 3
backend/dms/services/document_content_service.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import logging
+import re
 from dataclasses import dataclass
 from datetime import datetime, timezone
 from pathlib import Path
@@ -20,6 +21,20 @@ class ContentExtraction:
     text: str | None
     status: str
     extracted_at: datetime | None
+    # 提取失败原因(脱敏:不含绝对路径,仅异常类型与首行信息),供管理员排障
+    error: str | None = None
+
+
+_ERROR_MAX_LENGTH = 480
+
+
+def sanitize_extract_error(exc: Exception) -> str:
+    message = str(exc).splitlines()[0] if str(exc).strip() else ""
+    # 去掉可能的绝对路径(Windows 盘符 / POSIX 根路径),避免泄露部署目录
+    message = re.sub(r"[A-Za-z]:[\\/][^\s,;]*", "<path>", message)
+    message = re.sub(r"(?<![\w])/[A-Za-z0-9_./-]+", "<path>", message)
+    text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
+    return text[:_ERROR_MAX_LENGTH]
 
 
 def build_search_text(
@@ -62,9 +77,166 @@ def _word_text(path: Path) -> str:
     return "\n".join(values).strip()
 
 
+# 块内相邻行主字号相对差达到该比例时视为段落边界(标题与正文同块的兜底)
+_FONT_SIZE_SPLIT_RATIO = 0.2
+
+
+def _line_main_size(line) -> float:
+    """取行内最大 span 字号作为该行主字号。"""
+    return max(
+        (span["size"] for span in line["spans"] if span["text"].strip()),
+        default=0.0,
+    )
+
+
+# 退化版面判定:逐行等距网格的间距容差(pt)与字号容差(pt)
+_DEGENERATE_GAP_TOLERANCE = 2.0
+_DEGENERATE_SIZE_TOLERANCE = 0.5
+
+
+def _pdf_page_text(page) -> str:
+    """按版面块识别段落:块边界即段落边界,块内合并软换行。
+
+    PyMuPDF 按版面邻近度将行分组为块,标题/落款等因字号或间距不同独立成块,
+    无需依赖标点猜测即可与正文分开。块内沿用启发式合并(句末标点/条目编号切分,
+    相邻行字号突变亦视为段落边界)。整页为逐行等距同字号的网格(如扫描件文本层
+    逐行定位)时块结构无分组价值,回退纯文本启发式。
+    """
+    data = page.get_text("dict", flags=fitz.TEXTFLAGS_TEXT)
+    # 每块:(行列表[(文本, 主字号)], bbox顶, bbox底)
+    blocks: list[tuple[list[tuple[str, float]], float, float]] = []
+    line_count = 0
+    for block in data["blocks"]:
+        if block.get("type") != 0:
+            continue
+        items = [
+            (text, _line_main_size(line))
+            for line in block["lines"]
+            if (text := "".join(span["text"] for span in line["spans"]).strip())
+        ]
+        if items:
+            blocks.append((items, block["bbox"][1], block["bbox"][3]))
+            line_count += len(items)
+    if line_count == 0:
+        return ""
+    if _is_degenerate_line_grid(blocks, line_count):
+        return _merge_wrapped_lines(page.get_text("text"))
+    paragraphs: list[str] = []
+    for items, _, _ in blocks:
+        paragraphs.extend(_merge_line_items(items))
+    return "\n".join(paragraphs)
+
+
+def _is_degenerate_line_grid(
+    blocks: list[tuple[list[tuple[str, float]], float, float]],
+    line_count: int,
+) -> bool:
+    """判断整页是否为"每行一块、等距同字号"的退化网格(扫描件文本层典型特征)。
+
+    需同时满足:每块仅一行、块数≥3(至少两个间距可比较均匀性)、
+    相邻块间距一致、各行字号一致。标题+正文等少量块或间距/字号有变化的
+    页面信任版面块结构,不视为退化。
+    """
+    if len(blocks) < 3 or len(blocks) < line_count:
+        return False
+    if any(len(items) != 1 for items, _, _ in blocks):
+        return False
+    gaps = [
+        blocks[index + 1][1] - top for index, (_, top, _) in enumerate(blocks[:-1])
+    ]
+    if max(gaps) - min(gaps) > _DEGENERATE_GAP_TOLERANCE:
+        return False
+    sizes = [items[0][1] for items, _, _ in blocks]
+    return max(sizes) - min(sizes) <= _DEGENERATE_SIZE_TOLERANCE
+
+
 def _pdf_text(path: Path) -> str:
     with fitz.open(stream=path.read_bytes(), filetype="pdf") as document:
-        return "\n".join(page.get_text("text") for page in document).strip()
+        pages = [_pdf_page_text(page) for page in document]
+    # 页与页之间以空行分隔(页边界即段落边界)
+    return "\n\n".join(page for page in pages if page).strip()
+
+
+# 句末标点:一行以此结尾视为自然段落结束,不再与下一行合并
+_SENTENCE_END_RE = re.compile(r"[。!?!?;;::…..\"”’」』))\]}>》]+$")
+# 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章" "一、")
+_LIST_START_RE = re.compile(
+    r"^(?:"
+    r"\d{1,3}[.、))]"          # 1. / 1、 / 1)
+    r"|[((][一二三四五六七八九十百\d]{1,4}[))]"  # (一) / (1)
+    r"|[一二三四五六七八九十]{1,3}、"   # 一、
+    r"|第[一二三四五六七八九十百千\d]{1,5}[章节条部分篇]"  # 第3章 / 第五节
+    r")"
+)
+
+
+def _join_lines(previous: str, following: str) -> str:
+    """合并段内被换行符断开的两行:两侧均为 ASCII 字母数字时补空格,其余直接相连。"""
+    if previous and following and previous[-1].isascii() and previous[-1].isalnum() \
+            and following[0].isascii() and following[0].isalnum():
+        return f"{previous} {following}"
+    return previous + following
+
+
+def _merge_line_items(items: list[tuple[str, float]]) -> list[str]:
+    """将一组行(文本、主字号)合并为段落列表。
+
+    新段落(切分)的情况:
+    1. 下一行以条目/章节编号开头;
+    2. 相邻行主字号相对差 ≥ _FONT_SIZE_SPLIT_RATIO(标题与正文同块的兜底,
+       字号为 0 表示无版面信息,不参与比较);
+    3. 当前缓冲以句末标点结尾(在追加该行后立即收尾)。
+    其余情况视为段内软换行,按 _join_lines 规则合并。
+    """
+    paragraphs: list[str] = []
+    buffer = ""
+    previous_size = 0.0
+    for text, size in items:
+        if buffer and _LIST_START_RE.match(text):
+            paragraphs.append(buffer)
+            buffer = text
+        elif (
+            buffer
+            and previous_size
+            and size
+            and abs(size - previous_size) / max(size, previous_size)
+            >= _FONT_SIZE_SPLIT_RATIO
+        ):
+            paragraphs.append(buffer)
+            buffer = text
+        elif buffer:
+            buffer = _join_lines(buffer, text)
+        else:
+            buffer = text
+        previous_size = size
+        if buffer and _SENTENCE_END_RE.search(buffer):
+            paragraphs.append(buffer)
+            buffer = ""
+    if buffer:
+        paragraphs.append(buffer)
+    return paragraphs
+
+
+def _merge_wrapped_lines(page_text: str) -> str:
+    """纯文本兜底:PDF 复制文本时会在段内软换行处插入 \\n,此处按启发式规则合并。
+
+    换行保留(视为新段落)的情况:
+    1. 空行(原始段落边界);
+    2. 上一行以句末标点结尾;
+    3. 下一行以条目/章节编号开头。
+    其余情况视为段内软换行,直接合并(中文不加空格,英文单词间补一个空格)。
+    """
+    paragraphs: list[str] = []
+    group: list[tuple[str, float]] = []
+    for raw_line in page_text.splitlines():
+        line = raw_line.strip()
+        if not line:
+            paragraphs.extend(_merge_line_items(group))
+            group = []
+        else:
+            group.append((line, 0.0))
+    paragraphs.extend(_merge_line_items(group))
+    return "\n".join(paragraphs)
 
 
 def extract_document_content(path: Path, extension: str) -> ContentExtraction:
@@ -78,12 +250,13 @@ def extract_document_content(path: Path, extension: str) -> ContentExtraction:
     extracted_at = datetime.now(timezone.utc).replace(tzinfo=None)
     try:
         text = _pdf_text(path) if normalized == "pdf" else _word_text(path)
-    except Exception:
+    except Exception as exc:
         logger.exception("文档正文提取失败:extension=%s", normalized)
         return ContentExtraction(
             text=None,
             status=ContentExtractStatus.FAILED.value,
             extracted_at=extracted_at,
+            error=sanitize_extract_error(exc),
         )
     return ContentExtraction(
         text=text or None,
@@ -93,7 +266,13 @@ def extract_document_content(path: Path, extension: str) -> ContentExtraction:
             else ContentExtractStatus.EMPTY.value
         ),
         extracted_at=extracted_at,
+        error=None,
     )
 
 
-__all__ = ["ContentExtraction", "build_search_text", "extract_document_content"]
+__all__ = [
+    "ContentExtraction",
+    "build_search_text",
+    "extract_document_content",
+    "sanitize_extract_error",
+]

+ 102 - 1
backend/dms/services/document_converter.py

@@ -4,10 +4,13 @@ from __future__ import annotations
 
 import logging
 import os
+import re
 import shutil
 import subprocess
+import sys
 import tempfile
 import threading
+import time
 from abc import ABC, abstractmethod
 from pathlib import Path
 from typing import Any
@@ -25,6 +28,23 @@ logger = logging.getLogger(__name__)
 
 _PDF_MAGIC = b"%PDF-"
 
+# Windows 下宿主进程无控制台(如打包 exe)时启动控制台子程序(soffice.com)
+# 会闪现黑框;CREATE_NO_WINDOW 使子进程在无窗口控制台运行,管道输出不受影响。
+_NO_WINDOW_FLAGS = (
+    subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
+)
+
+# 表格类扩展名:预览转 PDF 时启用 SinglePageSheets——每个工作表按实际
+# 内容区域导出为单页,忽略纸张版型(宽表不再被 A4 等纸张分页截断)。
+_SPREADSHEET_EXTENSIONS = {"xls", "xlsx", "et"}
+_SINGLE_PAGE_FILTER = (
+    'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":true}}'
+)
+
+# 版本探测失败后的重试间隔:失败结果只短期缓存,避免一次瞬时异常
+# (如残留的挂起 soffice 进程占用默认 profile)导致整个进程生命周期回退分页。
+_SINGLE_PAGE_DETECT_RETRY_SECONDS = 300.0
+
 
 class DocumentConverter(ABC):
     """文档转换器接口;业务层只依赖此接口,方便后续替换实现。"""
@@ -63,6 +83,9 @@ class LibreOfficeDocumentConverter(DocumentConverter):
         self._enabled = enabled
         self._in_flight: dict[str, LibreOfficeDocumentConverter._ConversionTask] = {}
         self._lock = threading.Lock()
+        self._version_lock = threading.Lock()
+        self._single_page_supported: bool | None = None
+        self._single_page_detected_at: float | None = None
 
     @property
     def available(self) -> bool:
@@ -72,6 +95,62 @@ class LibreOfficeDocumentConverter(DocumentConverter):
             return False
         return Path(self._executable).is_file()
 
+    def _version_probe_executable(self) -> str:
+        """--version 探测用可执行文件。
+
+        Windows 上 soffice.exe(GUI 子系统)执行 --version 会派生
+        soffice.bin(控制台子系统),父链无控制台可继承时新建可见控制台
+        窗口打印版本并停在 ``Press Enter to continue...`` 等待输入,
+        导致探测超时挂起并闪现黑框。soffice.com 为控制台包装器,stdout
+        走管道即打即退(配合 CREATE_NO_WINDOW 无窗口),故优先使用。
+        """
+        if sys.platform == "win32" and self._executable:
+            candidate = Path(self._executable).with_name("soffice.com")
+            if candidate.is_file():
+                return str(candidate)
+        return self._executable
+
+    def _supports_single_page_sheets(self) -> bool:
+        """检测 LibreOffice 是否支持 SinglePageSheets 滤镜(需 ≥7.4)。
+
+        首次调用执行 ``--version`` 探测并缓存结果;探测成功永久缓存,
+        失败结果仅缓存 ``_SINGLE_PAGE_DETECT_RETRY_SECONDS`` 秒后重试,
+        避免一次瞬时失败使整个进程生命周期回退标准纸张分页转换。
+        """
+        with self._version_lock:
+            if self._single_page_supported:
+                return True
+            if (
+                self._single_page_supported is False
+                and self._single_page_detected_at is not None
+                and time.monotonic() - self._single_page_detected_at
+                < _SINGLE_PAGE_DETECT_RETRY_SECONDS
+            ):
+                return False
+            supported = False
+            try:
+                completed = subprocess.run(
+                    [self._version_probe_executable(), "--version"],
+                    capture_output=True,
+                    text=True,
+                    timeout=15,
+                    check=False,
+                    creationflags=_NO_WINDOW_FLAGS,
+                )
+                output = (completed.stdout or "") + (completed.stderr or "")
+                supported = _version_at_least_7_4(output)
+            except Exception:
+                supported = False
+            self._single_page_supported = supported
+            self._single_page_detected_at = time.monotonic()
+            if not supported:
+                logger.info(
+                    "LibreOffice 不支持 SinglePageSheets(版本<7.4 或探测失败),"
+                    "表格预览回退标准纸张分页(%.0f 秒后重试探测)",
+                    _SINGLE_PAGE_DETECT_RETRY_SECONDS,
+                )
+            return supported
+
     class _ConversionTask:
         __slots__ = ("event", "result")
 
@@ -133,6 +212,15 @@ class LibreOfficeDocumentConverter(DocumentConverter):
             expected_output = output_dir / f"{base_name}.pdf"
 
             user_url = quote(str(user_dir.as_posix()), safe="/:")
+            # 表格类且 LibreOffice ≥7.4:按内容区域单页导出;其余维持标准 pdf 转换
+            extension = source_path.suffix.lower().lstrip(".")
+            if (
+                extension in _SPREADSHEET_EXTENSIONS
+                and self._supports_single_page_sheets()
+            ):
+                convert_target = _SINGLE_PAGE_FILTER
+            else:
+                convert_target = "pdf"
             args = [
                 self._executable,
                 "--headless",
@@ -141,7 +229,7 @@ class LibreOfficeDocumentConverter(DocumentConverter):
                 "--nofirststartwizard",
                 "--nolockcheck",
                 "--convert-to",
-                "pdf",
+                convert_target,
                 "--outdir",
                 str(output_dir),
                 f"-env:UserInstallation=file:///{user_url}",
@@ -155,6 +243,7 @@ class LibreOfficeDocumentConverter(DocumentConverter):
                         stdout=subprocess.PIPE,
                         stderr=subprocess.PIPE,
                         cwd=str(work_dir),
+                        creationflags=_NO_WINDOW_FLAGS,
                     )
                 except OSError as exc:
                     logger.error("启动 LibreOffice 失败:%s", exc.__class__.__name__)
@@ -197,6 +286,18 @@ class LibreOfficeDocumentConverter(DocumentConverter):
                 logger.exception("清理 LibreOffice 临时目录失败")
 
 
+def _version_at_least_7_4(output: str) -> bool:
+    """解析 ``soffice --version`` 输出,判定是否 ≥ 7.4(JSON 滤镜参数要求)。
+
+    新版日历式版本号(如 24.8 / 25.2)主版本大于 7,同样支持。
+    """
+    match = re.search(r"(\d+)\.(\d+)", output or "")
+    if not match:
+        return False
+    major, minor = int(match.group(1)), int(match.group(2))
+    return major > 7 or (major == 7 and minor >= 4)
+
+
 def _terminate_process(process: subprocess.Popen[Any]) -> None:
     """终止 LibreOffice 进程及其子进程。"""
     try:

+ 152 - 38
backend/dms/services/document_mutation_service.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 from datetime import datetime, timezone
 import logging
+from pathlib import Path
 import tempfile
 from typing import Any
 
@@ -12,12 +13,14 @@ from sqlalchemy import func, or_, select
 from werkzeug.datastructures import FileStorage
 
 from dms.common.enums import (
+    AllowedAction,
     AttachmentType,
     AuditAction,
     AuditTarget,
     DocumentStatus,
     DocumentType,
     EnabledStatus,
+    RoleCode,
     SecurityLevel,
     VisibilityType,
 )
@@ -26,6 +29,8 @@ from dms.common.errors import (
     CategoryNotLeafError,
     ConflictError,
     DocumentNameConflictError,
+    FileNotFoundError as DmsFileNotFoundError,
+    FilePathInvalidError,
     InvalidArgumentError,
     MainPlanHasChildrenError,
     ResourceNotFoundError,
@@ -37,7 +42,10 @@ from dms.models import AttachmentBinding, Category, Document, Organization, Perm
 from dms.security.auth_context import get_auth_context
 from dms.services.attachment_query_service import attachment_detail
 from dms.services.audit_service import business_audit
-from dms.services.authorization_service import evaluate_plan_access
+from dms.services.authorization_service import (
+    evaluate_plan_access,
+    require_plan_action,
+)
 from dms.services.document_password_service import (
     DocumentPasswordAction,
     _pop_password_spec,
@@ -50,6 +58,7 @@ from dms.services.document_content_service import (
     ContentExtraction,
     build_search_text,
     extract_document_content,
+    sanitize_extract_error,
 )
 from dms.services.preview_service import extract_doc_content_via_pdf
 from dms.storage.uploads import StagedUpload, stage_upload
@@ -187,20 +196,31 @@ def _resolve_organization(
     session,
     organization_id_input: int | None,
     context,
+    current: tuple[int | None, str | None] | None = None,
 ) -> tuple[int | None, str | None]:
     """解析文档归属部门。
 
-    - 输入为空:取当前用户的部门(与创建者当前部门一致)。
+    - 输入为空:新建(current为None)取当前用户的部门(与创建者当前部门一致);
+      编辑则沿用文档现有归属,不会被编辑人部门静默覆盖。
     - 输入与当前用户部门相同:直接复用上下文冗余字段,免一次查询。
-    - 输入为其他部门:按 ID 校验部门可用性并取最新名称。
+    - 输入与文档现有归属相同:原样回传视为未变更,免一次查询
+      (允许非管理员原样提交其他部门文档的归属)。
+    - 输入为其他部门:仅管理员可调整,按 ID 校验部门可用性并取最新名称
+      (防止借编辑调整文档的权限范围)。
     """
     if organization_id_input is None:
+        if current is not None:
+            return current
         return context.organization_id, context.organization_name
     if (
         context.organization_id is not None
         and organization_id_input == context.organization_id
     ):
         return context.organization_id, context.organization_name
+    if current is not None and organization_id_input == current[0]:
+        return current
+    if context.role_code != RoleCode.ADMIN:
+        raise InvalidArgumentError("仅管理员可将文档调整到其他部门")
     organization = session.scalar(
         select(Organization).where(
             Organization.id == organization_id_input,
@@ -220,6 +240,50 @@ def _serialize(document: Document) -> dict[str, object]:
     return document_detail(document, context, evaluate_plan_access(document, context))
 
 
+_OFFICE_CONVERT_EXTENSIONS = {"doc", "wps", "xls", "xlsx", "et"}
+
+
+def _extract_file_content(
+    file_path: Path, extension: str, file_hash: str
+) -> ContentExtraction:
+    """对落盘文件提取正文;Office 类型先经 LibreOffice 转 PDF 再提取。
+
+    上传(_create_record)与重新解析(re_extract_content)共用,保证口径一致。
+    任何异常都收敛为 FAILED 并携带脱敏错误信息,不向上抛出。
+    """
+    if extension not in _OFFICE_CONVERT_EXTENSIONS:
+        return extract_document_content(file_path, extension)
+    temp_pdf: Path | None = None
+    try:
+        from dms.services.document_converter import create_converter
+        converter = create_converter(current_app.config)
+        if not converter.available:
+            return ContentExtraction(
+                text=None,
+                status="FAILED",
+                extracted_at=_now(),
+                error="LibreOffice转换器不可用,无法解析Office文档",
+            )
+        pdf_bytes = converter.convert_to_pdf(file_path, file_hash)
+        with tempfile.NamedTemporaryFile(
+            suffix=".pdf", delete=False
+        ) as temp_file:
+            temp_file.write(pdf_bytes)
+            temp_pdf = Path(temp_file.name)
+        return extract_document_content(temp_pdf, "pdf")
+    except Exception as exc:
+        logger.exception("DOC/WPS/XLS/XLSX/ET转换PDF提取正文失败")
+        return ContentExtraction(
+            text=None,
+            status="FAILED",
+            extracted_at=_now(),
+            error=sanitize_extract_error(exc),
+        )
+    finally:
+        if temp_pdf is not None:
+            temp_pdf.unlink(missing_ok=True)
+
+
 def _create_record(
     upload: StagedUpload,
     payload: dict[str, Any],
@@ -298,39 +362,9 @@ def _create_record(
     name = _text(payload["documentName"], "documentName", 255)
     summary = _text(payload["summary"], "summary", 20000, nullable=True)
     tags = _tags(payload["tags"])
-    if upload.extension in {"doc", "wps"}:
-        temp_pdf: Path | None = None
-        try:
-            from dms.services.document_converter import create_converter
-            converter = create_converter(current_app.config)
-            if not converter.available:
-                extraction = ContentExtraction(
-                    text=None,
-                    status="FAILED",
-                    extracted_at=_now(),
-                )
-            else:
-                pdf_bytes = converter.convert_to_pdf(
-                    upload.temporary_path, upload.file_hash
-                )
-                with tempfile.NamedTemporaryFile(
-                    suffix=".pdf", delete=False
-                ) as temp_file:
-                    temp_file.write(pdf_bytes)
-                    temp_pdf = Path(temp_file.name)
-                extraction = extract_document_content(temp_pdf, "pdf")
-        except Exception:
-            logger.exception("DOC/WPS转换PDF提取正文失败")
-            extraction = ContentExtraction(
-                text=None,
-                status="FAILED",
-                extracted_at=_now(),
-            )
-        finally:
-            if temp_pdf is not None:
-                temp_pdf.unlink(missing_ok=True)
-    else:
-        extraction = extract_document_content(upload.temporary_path, upload.extension)
+    extraction = _extract_file_content(
+        upload.temporary_path, upload.extension, upload.file_hash
+    )
     final_created = False
     committed = False
     old_file_path = None
@@ -404,6 +438,7 @@ def _create_record(
                 document.file_hash = upload.file_hash
                 document.content_text = extraction.text
                 document.content_extract_status = extraction.status
+                document.content_extract_error = extraction.error
                 document.content_extracted_at = extraction.extracted_at
                 document.search_text = build_search_text(name, summary, tags, extraction.text)
                 document.updated_by = actor_id
@@ -488,6 +523,7 @@ def _create_record(
                 sort_no=next_sort_no,
                 content_text=extraction.text,
                 content_extract_status=extraction.status,
+                content_extract_error=extraction.error,
                 content_extracted_at=extraction.extracted_at,
                 search_text=build_search_text(
                     name, summary, tags, extraction.text
@@ -633,6 +669,9 @@ def reorder_documents(payload: Any) -> dict[str, int]:
                 for document_id in ordered_ids
             ):
                 raise ResourceNotFoundError("待排序方案不存在或已删除")
+            # 重排=改排序字段:对每个提交文档要求EDIT(密级+矩阵/ACL同源校验)
+            for document in submitted:
+                require_plan_action(document, context, AllowedAction.EDIT)
             first = by_id[ordered_ids[0]]
             document_type = first.document_type
             if document_type == DocumentType.MAIN.value:
@@ -727,6 +766,7 @@ def update_document(document_id: int, payload: dict[str, Any]):
         )
         if document is None:
             raise ResourceNotFoundError("方案文档不存在或类型不匹配")
+        require_plan_action(document, context, AllowedAction.EDIT)
         require_document_password(
             document,
             DocumentPasswordAction.EDIT.value,
@@ -741,7 +781,10 @@ def update_document(document_id: int, payload: dict[str, Any]):
         )
         _check_version(document, _version(payload["rowVersion"]))
         organization_id, organization_name = _resolve_organization(
-            session, organization_id_input, context
+            session,
+            organization_id_input,
+            context,
+            current=(document.organization_id, document.organization_name),
         )
         if document.document_type == DocumentType.MAIN.value:
             category = _leaf_category(
@@ -848,6 +891,7 @@ def update_document_description(document_id: int, payload: dict[str, Any]):
         )
         if document is None:
             raise ResourceNotFoundError("文档不存在或已删除")
+        require_plan_action(document, context, AllowedAction.EDIT)
         require_document_password(
             document,
             DocumentPasswordAction.EDIT.value,
@@ -891,10 +935,14 @@ def update_attachment(document_id: int, payload: dict[str, Any]):
         )
         if document is None:
             raise ResourceNotFoundError("共享附件不存在或类型不匹配")
+        require_plan_action(document, context, AllowedAction.EDIT)
         _exact(payload, ATTACHMENT_EDIT_FIELDS)
         _check_version(document, _version(payload["rowVersion"]))
         organization_id, organization_name = _resolve_organization(
-            session, organization_id_input, context
+            session,
+            organization_id_input,
+            context,
+            current=(document.organization_id, document.organization_name),
         )
         before = {
             "documentName": document.document_name,
@@ -935,6 +983,70 @@ def update_attachment(document_id: int, payload: dict[str, Any]):
     return _serialize(document)
 
 
+def re_extract_content(document_id: int):
+    """管理员对存量文档重新提取正文:按当前存储文件重新解析并回写提取结果。
+
+    主案/子案/共享附件通用;提取结果(含失败原因)覆盖写回,
+    search_text 同步重建,审计复用 EDIT_DOCUMENT(scope=reExtractContent)。
+    """
+    context = get_auth_context()
+    with transaction() as session:
+        document = session.scalar(
+            select(Document)
+            .where(
+                Document.id == document_id,
+                Document.is_deleted.is_(False),
+            )
+            .with_for_update()
+        )
+        if document is None:
+            raise ResourceNotFoundError("文档不存在")
+        try:
+            file_path = resolve_storage_path(
+                document.file_relative_path,
+                current_app.config["DMS_STORAGE_ROOT"],
+            )
+        except UnsafeStoragePathError as exc:
+            raise FilePathInvalidError() from exc
+        if not file_path.is_file():
+            raise DmsFileNotFoundError()
+        extraction = _extract_file_content(
+            file_path, document.file_extension, document.file_hash
+        )
+        document.content_text = extraction.text
+        document.content_extract_status = extraction.status
+        document.content_extract_error = extraction.error
+        document.content_extracted_at = extraction.extracted_at
+        document.search_text = build_search_text(
+            document.document_name,
+            document.summary,
+            document.tags,
+            extraction.text,
+        )
+        document.updated_by = context.user_id
+        document.updated_by_name = context.real_name
+        document.updated_at = _now()
+        document.row_version += 1
+        session.add(
+            business_audit(
+                action=AuditAction.EDIT_DOCUMENT,
+                target=(
+                    AuditTarget.ATTACHMENT
+                    if document.document_type == DocumentType.ATTACHMENT.value
+                    else AuditTarget.DOCUMENT
+                ),
+                target_id=document.id,
+                target_name=document.document_name,
+                detail={
+                    "scope": "reExtractContent",
+                    "contentExtractStatus": extraction.status,
+                    "rowVersion": document.row_version,
+                },
+            )
+        )
+    return _serialize(document)
+
+
 def delete_document(document_id: int, row_version: int):
     context = get_auth_context()
     now = _now()
@@ -952,6 +1064,7 @@ def delete_document(document_id: int, row_version: int):
         )
         if document is None:
             raise ResourceNotFoundError("方案文档不存在或类型不匹配")
+        require_plan_action(document, context, AllowedAction.DELETE)
         require_document_password(
             document,
             DocumentPasswordAction.DELETE.value,
@@ -1043,6 +1156,7 @@ def delete_attachment(document_id: int, row_version: int):
         )
         if document is None:
             raise ResourceNotFoundError("共享附件不存在或类型不匹配")
+        require_plan_action(document, context, AllowedAction.DELETE)
         _check_version(document, row_version)
         rows = session.execute(
             select(AttachmentBinding, Document)

+ 6 - 0
backend/dms/services/document_password_service.py

@@ -17,6 +17,7 @@ from flask import request
 from sqlalchemy import select
 
 from dms.common.enums import (
+    AllowedAction,
     AuditAction,
     AuditTarget,
     DocumentPasswordAction,
@@ -36,6 +37,7 @@ from dms.models import Document
 from dms.security.auth_context import AuthContext
 from dms.security.passwords import hash_password, verify_password
 from dms.services.audit_service import business_audit
+from dms.services.authorization_service import require_plan_action
 
 PASSWORD_HEADER = "X-DMS-Document-Password"
 PASSWORD_FIELDS = {"password", "protectedActions"}
@@ -245,6 +247,10 @@ def save_document_password(document_id: int, payload: dict[str, Any]) -> dict[st
     new_password = payload.get("password")
     with transaction() as session:
         document = _require_active_main_plan(session, document_id)
+        # 密码锁是文档级安全配置:密级+授权位(CONFIG_PERMISSION)前置校验
+        require_plan_action(
+            document, get_auth_context(), AllowedAction.CONFIG_PERMISSION
+        )
         if document.row_version != row_version:
             raise ConflictError(
                 "方案已被其他操作修改,请刷新后重试",

+ 87 - 30
backend/dms/services/document_query_service.py

@@ -30,7 +30,13 @@ from dms.common.response import serialize_id
 from dms.common.time_range import parse_datetime, parse_updated_range
 from dms.database.transaction import transaction
 from dms.extensions import db
-from dms.models import AttachmentBinding, Category, Document, Permission
+from dms.models import (
+    AttachmentBinding,
+    Category,
+    Document,
+    Permission,
+    SubPlanBinding,
+)
 from dms.security.auth_context import AuthContext, get_auth_context
 from dms.services.audit_service import business_audit
 from dms.services.authorization_service import (
@@ -254,6 +260,19 @@ def document_detail(
             "contentText": document.content_text if verified else None,
         }
     )
+    # 提取状态/失败原因仅供管理员排障("编辑"界面提示与重新解析)
+    if context.role_code == RoleCode.ADMIN:
+        result.update(
+            {
+                "contentExtractStatus": document.content_extract_status,
+                "contentExtractError": document.content_extract_error,
+                "contentExtractedAt": (
+                    _iso(document.content_extracted_at)
+                    if document.content_extracted_at
+                    else None
+                ),
+            }
+        )
     return result
 
 
@@ -601,41 +620,79 @@ def list_sub_plans(
     if main is None:
         raise MainPlanNotFoundError()
     # 主案无权限时也返回子案列表(列表全量可见,与主案列表语义一致);
-    # 子案权限继承自主案,allowedActions 自然为空集,内容由预览/详情接口拦截。
+    # 子案权限继承自原主案(root_document_id 不变),allowedActions 自然为
+    # 空集,内容由预览/详情接口拦截。
     evaluate_plan_access(main, context)
     page = _page(params)
-    statement = select(Document).where(
-        Document.parent_document_id == main.id,
-        Document.document_type == DocumentType.SUB_PLAN.value,
-        Document.is_deleted.is_(False),
-    )
-    statement = _title_keyword(statement, params.get("keyword"))
-    statement = _advanced_filters(statement, params)
-    status = _enum(params.get("status"), DocumentStatus, "status")
-    if status:
-        statement = statement.where(Document.document_status == status)
-    updated_from, updated_to = parse_updated_range(params)
-    if updated_from:
-        statement = statement.where(Document.updated_at >= updated_from)
-    if updated_to:
-        statement = statement.where(Document.updated_at <= updated_to)
+
+    def _apply_filters(statement):
+        statement = _title_keyword(statement, params.get("keyword"))
+        statement = _advanced_filters(statement, params)
+        status = _enum(params.get("status"), DocumentStatus, "status")
+        if status:
+            statement = statement.where(Document.document_status == status)
+        updated_from, updated_to = parse_updated_range(params)
+        if updated_from:
+            statement = statement.where(Document.updated_at >= updated_from)
+        if updated_to:
+            statement = statement.where(Document.updated_at <= updated_to)
+        return statement
+
     order, _ = _sort(params)
-    children = db.session.scalars(
-        statement.order_by(order, Document.id.asc())
+    # 自有子案:parent_document_id 指向该主案,按请求排序(默认 sortNo)。
+    own_children = db.session.scalars(
+        _apply_filters(
+            select(Document).where(
+                Document.parent_document_id == main.id,
+                Document.document_type == DocumentType.SUB_PLAN.value,
+                Document.is_deleted.is_(False),
+            )
+        ).order_by(order, Document.id.asc())
+    ).all()
+    # 挂载子案:其他主案的子案经 doc_sub_plan_binding 建立展示关系,
+    # 按挂载顺序(binding.sort_no)固定追加在自有子案之后,不参与拖动排序。
+    mount_rows = db.session.execute(
+        select(SubPlanBinding, Document)
+        .join(Document, Document.id == SubPlanBinding.sub_document_id)
+        .where(
+            SubPlanBinding.main_document_id == main.id,
+            SubPlanBinding.is_deleted.is_(False),
+            Document.is_deleted.is_(False),
+            Document.document_type == DocumentType.SUB_PLAN.value,
+        )
+        .order_by(SubPlanBinding.sort_no.asc(), SubPlanBinding.id.asc())
     ).all()
-    listed: list[tuple[Document, PlanAccess]] = [
-        (child, evaluate_plan_access(child, context)) for child in children
+    mounted: list[tuple[SubPlanBinding, Document]] = []
+    if mount_rows:
+        filtered_ids = set(
+            db.session.scalars(
+                _apply_filters(
+                    select(Document.id).where(
+                        Document.id.in_([doc.id for _, doc in mount_rows])
+                    )
+                )
+            ).all()
+        )
+        mounted = [row for row in mount_rows if row[0].sub_document_id in filtered_ids]
+
+    merged: list[tuple[Document, int | None]] = [
+        *[(child, None) for child in own_children],
+        *[(doc, binding.id) for binding, doc in mounted],
     ]
-    total = len(listed)
-    selected = listed[page.offset : page.offset + page.page_size]
+    total = len(merged)
+    selected = merged[page.offset : page.offset + page.page_size]
+    items: list[dict[str, object]] = []
+    for child, mount_id in selected:
+        access = evaluate_plan_access(child, context)
+        summary = document_summary(
+            child, context, access,
+            keyword=params.get("contentKeyword") if access.allowed else None,
+        )
+        if mount_id is not None:
+            summary["mountId"] = serialize_id(mount_id)
+        items.append(summary)
     return page_result(
-        [
-            document_summary(
-                child, context, access,
-                keyword=params.get("contentKeyword") if access.allowed else None,
-            )
-            for child, access in selected
-        ],
+        items,
         page=page.page,
         page_size=page.page_size,
         total=total,

+ 3 - 0
backend/dms/services/document_relation_service.py

@@ -25,8 +25,10 @@ from dms.extensions import db
 from dms.models import Document, DocumentRelation
 from dms.security.auth_context import get_auth_context
 from dms.services.audit_service import business_audit
+from dms.common.enums import AllowedAction
 from dms.services.authorization_service import (
     evaluate_plan_access,
+    require_plan_action,
     require_security_clearance,
 )
 from dms.services.document_password_service import (
@@ -288,6 +290,7 @@ def save_document_relations(
             )
             if document is None:
                 raise ResourceNotFoundError("方案文档不存在")
+            require_plan_action(document, context, AllowedAction.EDIT)
             require_security_clearance(document, context)
             require_document_password(
                 document,

+ 15 - 5
backend/dms/services/file_read_service.py

@@ -35,6 +35,7 @@ from dms.services.document_query_service import _require_plan_access
 from dms.services.preview_service import PreviewService
 from dms.services.recycle_bin_service import _verify_file
 from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
+from dms.storage.uploads import IMAGE_EXTENSIONS, MIME_TYPES as UPLOAD_MIME_TYPES
 
 logger = logging.getLogger(__name__)
 
@@ -126,16 +127,25 @@ def preview_document(document_id: int):
     try:
         path = verified.path
         extension = document.file_extension.lower().lstrip(".")
+        # 图片原图直出(MIME 必须准确,响应带 nosniff);其余统一以 PDF 预览
+        if extension in IMAGE_EXTENSIONS:
+            preview_name = _download_name(document.original_file_name)
+            preview_mime = UPLOAD_MIME_TYPES.get(
+                extension, "application/octet-stream"
+            )
+        else:
+            preview_name = _download_name(
+                f"{Path(document.original_file_name).stem}.pdf"
+            )
+            preview_mime = "application/pdf"
         preview_service = PreviewService.current()
         preview_path = preview_service.preview_path_for(document)
         verified.close()
         response = send_file(
             preview_path,
-            mimetype="application/pdf",
+            mimetype=preview_mime,
             as_attachment=False,
-            download_name=_download_name(
-                f"{Path(document.original_file_name).stem}.pdf"
-            ),
+            download_name=preview_name,
             conditional=True,
         )
     except Exception:
@@ -144,7 +154,7 @@ def preview_document(document_id: int):
     response.headers["X-Content-Type-Options"] = "nosniff"
     response.headers["Cache-Control"] = "private, no-store"
     response.headers["Content-Disposition"] = _inline_content_disposition(
-        f"{Path(document.original_file_name).stem}.pdf"
+        preview_name
     )
     return response
 

+ 19 - 1
backend/dms/services/permission_service.py

@@ -9,6 +9,7 @@ from sqlalchemy import select
 from sqlalchemy.exc import IntegrityError
 
 from dms.common.enums import (
+    AllowedAction,
     AuditAction,
     AuditTarget,
     DocumentType,
@@ -30,12 +31,15 @@ from dms.extensions import db
 from dms.models import Document, Organization, Permission, User
 from dms.security.auth_context import get_auth_context
 from dms.services.audit_service import business_audit
-from dms.services.authorization_service import require_security_clearance
 from dms.services.document_password_service import (
     DocumentPasswordAction,
     get_request_password,
     require_document_password,
 )
+from dms.services.authorization_service import (
+    require_plan_action,
+    require_security_clearance,
+)
 
 PERMISSION_FIELDS = {
     "subjectType",
@@ -90,6 +94,10 @@ def _permission_source(document: Document, *, lock: bool = False) -> Document:
         raise AttachmentHasNoAclError()
     require_security_clearance(document, get_auth_context())
     if document.document_type == DocumentType.MAIN.value:
+        # 读取与写入同口径:仅授予授权位(或管理员)可见授权清单
+        require_plan_action(
+            document, get_auth_context(), AllowedAction.CONFIG_PERMISSION
+        )
         return document
     statement = select(Document).where(
         Document.id == document.root_document_id,
@@ -306,6 +314,16 @@ def save_permissions(document_id: int, payload: Any) -> dict[str, object]:
             require_security_clearance(document, context)
             if document.document_type == DocumentType.SUB_PLAN.value:
                 raise SubPlanPermissionInheritedError()
+            require_plan_action(
+                document, context, AllowedAction.CONFIG_PERMISSION
+            )
+            # 设密主案的授权操作仍受密码锁保护(与前端弹窗口径一致)
+            require_document_password(
+                document,
+                DocumentPasswordAction.CONFIG_PERMISSION.value,
+                get_request_password(),
+                context,
+            )
             if document.document_type != DocumentType.MAIN.value:
                 raise ResourceNotFoundError("主案不存在或类型不匹配")
             if document.row_version != expected_version:

+ 3 - 2
backend/dms/services/portal_auth_service.py

@@ -41,7 +41,7 @@ from dms.services.portal_provider import (
     extract_token,
     resolve_portal_identity,
 )
-from dms.services.user_service import user_summary
+from dms.services.user_service import allowed_modules_for_user, user_summary
 
 SETUP_TICKET_TTL_SECONDS = 300
 _TICKET_PURPOSE = "PORTAL_SETUP"
@@ -257,5 +257,6 @@ def setup_portal_password(
         "accessToken": token,
         "tokenType": "Bearer",
         "expiresIn": expires_in,
-        "user": user_summary(user),
+        # 门户登录同样按文档权限动态计算模块可见性。
+        "user": user_summary(user, allowed_modules=allowed_modules_for_user(user)),
     }

+ 24 - 5
backend/dms/services/preview_service.py

@@ -17,17 +17,33 @@ from dms.common.errors import (
     PreviewConverterUnavailableError,
     PreviewUnavailableError,
 )
-from dms.services.document_converter import DocumentConverter, create_converter
+from dms.services.document_converter import (
+    DocumentConverter,
+    _SPREADSHEET_EXTENSIONS,
+    create_converter,
+)
 from dms.storage.paths import (
     UnsafeStoragePathError,
     is_path_inside_preview,
     preview_cache_path,
     resolve_storage_path,
 )
+from dms.storage.uploads import IMAGE_EXTENSIONS
 
 logger = logging.getLogger(__name__)
 
-_CONVERTIBLE_EXTENSIONS = {"doc", "docx", "wps"}
+_CONVERTIBLE_EXTENSIONS = {"doc", "docx", "wps", "xls", "xlsx", "et"}
+
+# 表格类预览缓存键版本后缀:表格转换策略变更(如启用 SinglePageSheets
+# 按内容单页导出)时递增,使旧策略生成的分页缓存自动失效并重新转换。
+_SPREADSHEET_CACHE_VERSION = "sps1"
+
+
+def preview_cache_key(file_hash: str, extension: str) -> str:
+    """预览缓存键:表格类附加转换策略版本后缀,其余直接使用文件哈希。"""
+    if extension.lower().lstrip(".") in _SPREADSHEET_EXTENSIONS:
+        return f"{file_hash}-{_SPREADSHEET_CACHE_VERSION}"
+    return file_hash
 
 
 def _get_converter() -> DocumentConverter:
@@ -57,7 +73,8 @@ class PreviewService:
         """返回最终用于发送文件的磁盘路径。
 
         - PDF: 原始文件路径
-        - DOC/DOCX/WPS: 缓存的 PDF 路径(按需转换)
+        - 图片: 原始文件路径(原图直出,不做转换)
+        - DOC/DOCX/WPS/XLS/XLSX/ET: 缓存的 PDF 路径(按需转换)
         - 其他: 直接抛出 PreviewUnavailableError
         """
         extension = document.file_extension.lower().lstrip(".")
@@ -66,7 +83,7 @@ class PreviewService:
             self.storage_root,
         )
 
-        if extension == "pdf":
+        if extension == "pdf" or extension in IMAGE_EXTENSIONS:
             return original
 
         if extension not in _CONVERTIBLE_EXTENSIONS:
@@ -82,7 +99,9 @@ class PreviewService:
         if not self.converter.available:
             raise PreviewConverterUnavailableError()
 
-        cache = preview_cache_path(file_hash, self.storage_root)
+        cache = preview_cache_path(
+            preview_cache_key(file_hash, extension), self.storage_root
+        )
         if self.validate_cached_pdf(cache):
             return cache
 

+ 327 - 0
backend/dms/services/sub_plan_binding_service.py

@@ -0,0 +1,327 @@
+"""子案跨主案挂载与解除挂载事务。
+
+与附件挂载(attachment_binding_service)同范式:主案行锁 + 密码动作 +
+乐观锁 + 步长10排序 + 审计(复用 BIND_ATTACHMENT / UNBIND_ATTACHMENT
+动作,detail 标注 scope=subPlanMount,不扩展 sys_audit_log 的 CHECK)。
+子案权限仍继承其原主案(root_document_id 不变),挂载仅建立展示关系。
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+
+from sqlalchemy import func, select
+from sqlalchemy.exc import IntegrityError
+
+from dms.common.enums import AuditAction, AuditTarget, DocumentType
+from dms.common.errors import (
+    ConflictError,
+    InvalidArgumentError,
+    ResourceNotFoundError,
+)
+from dms.common.response import serialize_id
+from dms.database.transaction import execute_with_deadlock_retry, transaction
+from dms.extensions import db
+from dms.models import Document, SubPlanBinding
+from dms.security.auth_context import get_auth_context
+from dms.services.audit_service import business_audit
+from dms.common.enums import AllowedAction
+from dms.services.authorization_service import (
+    require_plan_action,
+    require_security_clearance,
+)
+from dms.services.document_password_service import (
+    DocumentPasswordAction,
+    get_request_password,
+    require_document_password,
+)
+
+BIND_SUB_FIELDS = {"subPlanIds", "mainPlanRowVersion"}
+
+
+def _now() -> datetime:
+    return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+def _version(value: Any, name: str) -> int:
+    if type(value) is not int or value < 0:
+        raise InvalidArgumentError(f"{name}必须是非负整数")
+    return value
+
+
+def _string_id(value: Any, name: str) -> int:
+    if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
+        raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
+    return int(value)
+
+
+def _bind_payload(payload: Any) -> tuple[list[int], int]:
+    if not isinstance(payload, dict) or set(payload) != BIND_SUB_FIELDS:
+        raise InvalidArgumentError(
+            "请求字段必须且只能包含subPlanIds和mainPlanRowVersion"
+        )
+    raw_ids = payload["subPlanIds"]
+    if not isinstance(raw_ids, list) or not raw_ids:
+        raise InvalidArgumentError("subPlanIds必须是非空数组")
+    sub_plan_ids: list[int] = []
+    seen: set[int] = set()
+    for index, raw_id in enumerate(raw_ids):
+        sub_plan_id = _string_id(raw_id, f"subPlanIds[{index}]")
+        if sub_plan_id not in seen:
+            sub_plan_ids.append(sub_plan_id)
+            seen.add(sub_plan_id)
+    return sub_plan_ids, _version(
+        payload["mainPlanRowVersion"], "mainPlanRowVersion"
+    )
+
+
+def _locked_main(session, main_document_id: int) -> Document:
+    main = session.scalar(
+        select(Document)
+        .where(
+            Document.id == main_document_id,
+            Document.is_deleted.is_(False),
+        )
+        .with_for_update()
+    )
+    if main is None or main.document_type != DocumentType.MAIN.value:
+        raise ResourceNotFoundError("主案不存在或类型不匹配")
+    require_security_clearance(main, get_auth_context())
+    return main
+
+
+def _locked_sub_plans(
+    session,
+    main_id: int,
+    sub_plan_ids: list[int],
+) -> dict[int, Document]:
+    """校验目标均为有效子案;属于本主案的自有子案直接剔除(无需挂载)。"""
+    documents = session.scalars(
+        select(Document)
+        .where(Document.id.in_(sorted(sub_plan_ids)))
+        .order_by(Document.id.asc())
+        .with_for_update()
+    ).all()
+    by_id = {document.id: document for document in documents}
+    if any(
+        sub_plan_id not in by_id
+        or by_id[sub_plan_id].is_deleted
+        or by_id[sub_plan_id].document_type != DocumentType.SUB_PLAN.value
+        for sub_plan_id in sub_plan_ids
+    ):
+        raise ResourceNotFoundError("子案不存在或类型不匹配")
+    return {
+        sub_plan_id: by_id[sub_plan_id]
+        for sub_plan_id in sub_plan_ids
+        if by_id[sub_plan_id].parent_document_id != main_id
+    }
+
+
+def _current_version(main_document_id: int) -> int | None:
+    return db.session.scalar(
+        select(Document.row_version).where(
+            Document.id == main_document_id,
+            Document.is_deleted.is_(False),
+        )
+    )
+
+
+def bind_sub_plans(main_document_id: int, payload: Any) -> dict[str, int]:
+    """去重后批量挂载其他主案的子案;同一主案上的写入由主案行锁串行化。"""
+    sub_plan_ids, expected_version = _bind_payload(payload)
+    context = get_auth_context()
+
+    def operation() -> dict[str, int]:
+        with transaction() as session:
+            main = _locked_main(session, main_document_id)
+            require_plan_action(main, context, AllowedAction.BIND_ATTACHMENT)
+            require_document_password(
+                main,
+                DocumentPasswordAction.BIND_ATTACHMENT.value,
+                get_request_password(),
+                context,
+            )
+            if main.row_version != expected_version:
+                raise ConflictError(
+                    "数据已被其他用户修改,请刷新后重试",
+                    details={"currentRowVersion": main.row_version},
+                )
+            mountable_by_id = _locked_sub_plans(session, main.id, sub_plan_ids)
+
+            bindings = session.scalars(
+                select(SubPlanBinding)
+                .where(SubPlanBinding.main_document_id == main.id)
+                .order_by(SubPlanBinding.id.asc())
+                .with_for_update()
+            ).all()
+            active_by_sub = {
+                binding.sub_document_id: binding
+                for binding in bindings
+                if not binding.is_deleted
+            }
+            existing_ids = [
+                sub_plan_id
+                for sub_plan_id in mountable_by_id
+                if sub_plan_id in active_by_sub
+            ]
+            created_ids = [
+                sub_plan_id
+                for sub_plan_id in mountable_by_id
+                if sub_plan_id not in active_by_sub
+            ]
+            if not created_ids:
+                return {
+                    "createdCount": 0,
+                    "existingCount": len(existing_ids),
+                    "subPlanCount": len(active_by_sub),
+                    "mainPlanRowVersion": main.row_version,
+                }
+
+            max_sort_no = max(
+                (
+                    binding.sort_no
+                    for binding in bindings
+                    if not binding.is_deleted
+                ),
+                default=0,
+            )
+            for offset, sub_plan_id in enumerate(created_ids, start=1):
+                session.add(
+                    SubPlanBinding(
+                        main_document_id=main.id,
+                        sub_document_id=sub_plan_id,
+                        sort_no=max_sort_no + offset * 10,
+                        created_by=context.user_id,
+                        updated_by=context.user_id,
+                    )
+                )
+            session.flush()
+            main.row_version += 1
+            main.updated_by = context.user_id
+            main.updated_by_name = context.real_name
+            main.updated_at = _now()
+            session.add(
+                business_audit(
+                    action=AuditAction.BIND_ATTACHMENT,
+                    target=AuditTarget.ATTACHMENT_BINDING,
+                    target_id=main.id,
+                    target_name=main.document_name,
+                    detail={
+                        "scope": "subPlanMount",
+                        "mainPlanId": serialize_id(main.id),
+                        "requestedSubPlanIds": [
+                            serialize_id(value) for value in sub_plan_ids
+                        ],
+                        "createdSubPlanIds": [
+                            serialize_id(value) for value in created_ids
+                        ],
+                        "existingSubPlanIds": [
+                            serialize_id(value) for value in existing_ids
+                        ],
+                        "createdCount": len(created_ids),
+                        "existingCount": len(existing_ids),
+                        "mainPlanRowVersion": main.row_version,
+                    },
+                )
+            )
+            return {
+                "createdCount": len(created_ids),
+                "existingCount": len(existing_ids),
+                "subPlanCount": len(active_by_sub) + len(created_ids),
+                "mainPlanRowVersion": main.row_version,
+            }
+
+    try:
+        return execute_with_deadlock_retry(operation)
+    except IntegrityError as exc:
+        db.session.rollback()
+        raise ConflictError(
+            "子案挂载关系已被并发修改,请刷新后重试",
+            details={"currentRowVersion": _current_version(main_document_id)},
+        ) from exc
+
+
+def unbind_sub_plan(
+    main_document_id: int,
+    sub_document_id: int,
+    expected_version: int,
+) -> dict[str, int]:
+    """逻辑删除一条有效子案挂载关系。"""
+    expected_version = _version(expected_version, "mainPlanRowVersion")
+    context = get_auth_context()
+
+    def operation() -> dict[str, int]:
+        with transaction() as session:
+            main = _locked_main(session, main_document_id)
+            require_plan_action(main, context, AllowedAction.UNBIND_ATTACHMENT)
+            require_document_password(
+                main,
+                DocumentPasswordAction.UNBIND_ATTACHMENT.value,
+                get_request_password(),
+                context,
+            )
+            if main.row_version != expected_version:
+                raise ConflictError(
+                    "数据已被其他用户修改,请刷新后重试",
+                    details={"currentRowVersion": main.row_version},
+                )
+            binding = session.scalar(
+                select(SubPlanBinding)
+                .where(
+                    SubPlanBinding.main_document_id == main.id,
+                    SubPlanBinding.sub_document_id == sub_document_id,
+                    SubPlanBinding.is_deleted.is_(False),
+                )
+                .with_for_update()
+            )
+            if binding is None:
+                raise ResourceNotFoundError("有效子案挂载关系不存在")
+
+            now = _now()
+            binding.is_deleted = True
+            binding.deleted_at = now
+            binding.updated_at = now
+            binding.updated_by = context.user_id
+            binding.row_version += 1
+            remaining = (
+                session.scalar(
+                    select(func.count(SubPlanBinding.id)).where(
+                        SubPlanBinding.main_document_id == main.id,
+                        SubPlanBinding.is_deleted.is_(False),
+                    )
+                )
+                or 0
+            )
+            main.row_version += 1
+            main.updated_by = context.user_id
+            main.updated_by_name = context.real_name
+            main.updated_at = now
+            session.add(
+                business_audit(
+                    action=AuditAction.UNBIND_ATTACHMENT,
+                    target=AuditTarget.ATTACHMENT_BINDING,
+                    target_id=binding.id,
+                    target_name=main.document_name,
+                    detail={
+                        "scope": "subPlanMount",
+                        "bindingId": serialize_id(binding.id),
+                        "mainPlanId": serialize_id(main.id),
+                        "subPlanId": serialize_id(sub_document_id),
+                        "subPlanCount": remaining,
+                        "mainPlanRowVersion": main.row_version,
+                    },
+                )
+            )
+            return {
+                "subPlanCount": remaining,
+                "mainPlanRowVersion": main.row_version,
+            }
+
+    return execute_with_deadlock_retry(operation)
+
+
+__all__ = [
+    "bind_sub_plans",
+    "unbind_sub_plan",
+]

+ 40 - 2
backend/dms/services/user_service.py

@@ -93,6 +93,36 @@ def _format_utc(value: datetime | None) -> str | None:
     )
 
 
+def allowed_modules_for_user(user: User) -> list[str]:
+    """按“用户对文档的实际权限”计算可见模块(动态菜单口径)。
+
+    - 浏览:登录即有;
+    - 后台管理:管理员恒可见;普通用户在角色矩阵对任一已发布主案命中
+      管理 位(编辑/删除/授权/挂载/解除挂载)时可见;
+    - 审计 / 机构用户管理:仅管理员。
+    """
+    if RoleCode(user.role_code) == RoleCode.ADMIN:
+        return list(ALLOWED_MODULES[RoleCode.ADMIN])
+    modules = [AllowedModule.DOCUMENT_BROWSER.value]
+    from dms.security.auth_context import AuthContext
+    from dms.services.authorization_service import has_any_manage_action
+
+    if has_any_manage_action(
+        AuthContext(
+            user_id=user.id,
+            username=user.username,
+            real_name=user.real_name,
+            organization_id=user.organization_id,
+            organization_name=user.organization_name,
+            role_code=RoleCode(user.role_code),
+            security_level=SecurityLevel(user.security_level),
+            auth_version=user.auth_version,
+        )
+    ):
+        modules.append(AllowedModule.BACKEND_MANAGEMENT.value)
+    return modules
+
+
 def effective_role(user: User) -> tuple[str | None, str | None]:
     if user.role_id is None:
         return None, None
@@ -102,7 +132,11 @@ def effective_role(user: User) -> tuple[str | None, str | None]:
     return serialize_id(role.id), role.role_name
 
 
-def user_summary(user: User) -> dict[str, object]:
+def user_summary(
+    user: User,
+    *,
+    allowed_modules: list[str] | None = None,
+) -> dict[str, object]:
     organization_id, organization_name = effective_organization(user)
     role_id, role_name = effective_role(user)
     return {
@@ -120,7 +154,11 @@ def user_summary(user: User) -> dict[str, object]:
         "rowVersion": user.row_version,
         "lastLoginAt": _format_utc(user.last_login_at),
         "lastLoginIp": user.last_login_ip,
-        "allowedModules": ALLOWED_MODULES[RoleCode(user.role_code)],
+        "allowedModules": (
+            allowed_modules
+            if allowed_modules is not None
+            else ALLOWED_MODULES[RoleCode(user.role_code)]
+        ),
     }
 
 

+ 46 - 4
backend/dms/storage/uploads.py

@@ -15,12 +15,36 @@ from werkzeug.datastructures import FileStorage
 from dms.common.errors import PayloadTooLargeError, UnsupportedFileTypeError
 from dms.storage.paths import ensure_storage_directories
 
-ALLOWED_EXTENSIONS = {"doc", "docx", "pdf", "wps"}
+ALLOWED_EXTENSIONS = {
+    "doc", "docx", "pdf", "wps",
+    "xls", "xlsx", "et",
+    "jpg", "jpeg", "png", "gif", "bmp", "webp",
+}
 MIME_TYPES = {
     "doc": "application/msword",
     "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
     "pdf": "application/pdf",
     "wps": "application/vnd.ms-works",
+    "xls": "application/vnd.ms-excel",
+    "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+    "et": "application/vnd.kingsoft.spreadsheet",
+    "jpg": "image/jpeg",
+    "jpeg": "image/jpeg",
+    "png": "image/png",
+    "gif": "image/gif",
+    "bmp": "image/bmp",
+    "webp": "image/webp",
+}
+# 图片类扩展名:上传仅做魔数校验,预览时原图直出(不走 PDF 转换)
+IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "webp"}
+# 图片魔数签名:extension -> (签名, 起始偏移);webp 为 RIFF 容器,偏移 8 处为 WEBP
+_IMAGE_SIGNATURES: dict[str, tuple[bytes, int]] = {
+    "jpg": (b"\xff\xd8\xff", 0),
+    "jpeg": (b"\xff\xd8\xff", 0),
+    "png": (b"\x89PNG\r\n\x1a\n", 0),
+    "gif": (b"GIF8", 0),
+    "bmp": (b"BM", 0),
+    "webp": (b"WEBP", 8),
 }
 OLE_HEADER = bytes.fromhex("D0CF11E0A1B11AE1")
 
@@ -69,8 +93,18 @@ def _safe_zip(path: Path, required: set[str]) -> None:
         raise UnsupportedFileTypeError("Office ZIP容器损坏") from exc
 
 
+def _validate_image(prefix: bytes, extension: str) -> None:
+    signature, offset = _IMAGE_SIGNATURES[extension]
+    valid = prefix[offset : offset + len(signature)] == signature
+    if extension == "webp":
+        valid = valid and prefix.startswith(b"RIFF")
+    if not valid:
+        raise UnsupportedFileTypeError("图片文件头与扩展名不一致")
+
+
 def _validate(path: Path, extension: str) -> None:
-    prefix = path.read_bytes()[:8]
+    # 读取 12 字节:webp 需校验偏移 8~11 处的 WEBP 标识,其余类型不受影响
+    prefix = path.read_bytes()[:12]
     if extension == "pdf":
         if not prefix.startswith(b"%PDF-"):
             raise UnsupportedFileTypeError("PDF文件头与扩展名不一致")
@@ -78,6 +112,12 @@ def _validate(path: Path, extension: str) -> None:
     if extension == "docx":
         _safe_zip(path, {"[Content_Types].xml", "word/document.xml"})
         return
+    if extension == "xlsx":
+        _safe_zip(path, {"[Content_Types].xml", "xl/workbook.xml"})
+        return
+    if extension in IMAGE_EXTENSIONS:
+        _validate_image(prefix, extension)
+        return
     if not prefix.startswith(OLE_HEADER):
         raise UnsupportedFileTypeError("OLE文件头与扩展名不一致")
     try:
@@ -87,8 +127,10 @@ def _validate(path: Path, extension: str) -> None:
         raise UnsupportedFileTypeError("OLE/CFB容器损坏") from exc
     if extension == "doc" and "WordDocument" not in streams:
         raise UnsupportedFileTypeError("OLE容器不是有效DOC")
-    # wps:与 DOC 同族的 OLE 容器,但内部流名不保证 WordDocument,仅校验容器有效性;
-    # 内容合法性由 LibreOffice 转换环节兜底(转换失败返回预览不可用)。
+    if extension == "xls" and "Workbook" not in streams:
+        raise UnsupportedFileTypeError("OLE容器不是有效XLS")
+    # wps/et:与 DOC/XLS 同族的 OLE 容器,但内部流名不保证 WordDocument/Workbook,
+    # 仅校验容器有效性;内容合法性由 LibreOffice 转换环节兜底(转换失败返回预览不可用)。
 
 
 def stage_upload(file: FileStorage) -> StagedUpload:

+ 5 - 4
backend/migrations/versions/0006_document_organization.py

@@ -97,11 +97,8 @@ def upgrade() -> None:
 
 
 def downgrade() -> None:
-    indexes = _existing_index_names(_TABLE)
-    if _INDEX in indexes:
-        op.drop_index(_INDEX, table_name=_TABLE)
-
     # 外键存在性检测:MySQL 8 下 fk 名稳定,按名清理
+    # MySQL要求先删外键再删其依赖的索引,顺序不可交换
     bind = op.get_bind()
     fk_rows = bind.execute(
         sa.text(
@@ -115,6 +112,10 @@ def downgrade() -> None:
     for row in fk_rows:
         op.drop_constraint(row[0], _TABLE, type_="foreignkey")
 
+    indexes = _existing_index_names(_TABLE)
+    if _INDEX in indexes:
+        op.drop_index(_INDEX, table_name=_TABLE)
+
     columns = _existing_columns(_TABLE)
     if "organization_name" in columns:
         op.drop_column(_TABLE, "organization_name")

+ 2 - 1
backend/migrations/versions/0012_role_management.py

@@ -389,8 +389,9 @@ def downgrade() -> None:
         _target_check_sql(AUDIT_TARGETS_V2),
     )
 
-    op.drop_index("ix_sys_user_role_active", table_name="sys_user")
+    # MySQL要求先删外键再删其依赖的索引,顺序不可交换
     op.drop_constraint("fk_sys_user_role_id_sys_role", "sys_user", type_="foreignkey")
+    op.drop_index("ix_sys_user_role_active", table_name="sys_user")
     op.drop_column("sys_user", "role_id")
 
     op.drop_table("sys_role_permission")

+ 48 - 0
backend/migrations/versions/0017_category_type_display_name.py

@@ -0,0 +1,48 @@
+"""顶级分类类型显示名称:doc_category 新增 type_display_name。
+
+Revision ID: 0017_category_type_display_name
+Revises: 0016_portal_user_provision
+Create Date: 2026-08-24
+
+变更要点:
+1. doc_category 新增 type_display_name(VARCHAR(128),可空,默认NULL);
+2. 仅一级分类(parent_id IS NULL)有意义:检索结果列表中,该分类下
+   主体方案的类型徽标显示该名称(未设置时回退为分类名称),
+   子分类强制 NULL(与 flat_mode 口径一致,服务层保证);
+3. 存量数据保持 NULL,徽标回退显示顶级分类名称。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "0017_category_type_display_name"
+down_revision: str | None = "0016_portal_user_provision"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+_TABLE = "doc_category"
+_COLUMN = "type_display_name"
+
+
+def _existing_columns(table: str) -> set[str]:
+    inspector = sa.inspect(op.get_bind())
+    return {item["name"] for item in inspector.get_columns(table)}
+
+
+def upgrade() -> None:
+    if _COLUMN not in _existing_columns(_TABLE):
+        op.add_column(
+            _TABLE,
+            sa.Column(_COLUMN, sa.String(length=128), nullable=True),
+        )
+
+
+def downgrade() -> None:
+    if _COLUMN in _existing_columns(_TABLE):
+        op.drop_column(_TABLE, _COLUMN)

+ 132 - 0
backend/migrations/versions/0018_sub_plan_binding.py

@@ -0,0 +1,132 @@
+"""子案跨主案挂载:新表 doc_sub_plan_binding。
+
+Revision ID: 0018_sub_plan_binding
+Revises: 0017_category_type_display_name
+Create Date: 2026-08-24
+
+变更要点:
+1. 新建 doc_sub_plan_binding:主案对其他主案子案的挂载关系;
+2. 软删 + 生成列唯一约束(uq_doc_sub_plan_binding_active)保证同一主案
+   对同一子案至多一条有效挂载;主案自有子案(parent_document_id 指向
+   该主案)不允许建立挂载,由服务层校验;
+3. 审计复用 BIND_ATTACHMENT / UNBIND_ATTACHMENT 动作(detail 标注
+   scope=subPlanMount),不扩展 sys_audit_log 的 CHECK 约束;
+4. 降级保护:已产生挂载数据时拒绝有损降级。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+revision: str = "0018_sub_plan_binding"
+down_revision: str | None = "0017_category_type_display_name"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _business_columns() -> list[sa.Column]:
+    return [
+        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.PrimaryKeyConstraint("id"),
+    ]
+
+
+def _existing_tables() -> set[str]:
+    inspector = sa.inspect(op.get_bind())
+    return set(inspector.get_table_names())
+
+
+def upgrade() -> None:
+    if "doc_sub_plan_binding" in _existing_tables():
+        return
+    op.create_table(
+        "doc_sub_plan_binding",
+        *_business_columns(),
+        sa.Column("main_document_id", sa.BigInteger(), nullable=False),
+        sa.Column("sub_document_id", sa.BigInteger(), nullable=False),
+        sa.Column(
+            "sort_no",
+            sa.Integer(),
+            server_default=sa.text("0"),
+            nullable=False,
+        ),
+        sa.Column(
+            "active_marker",
+            sa.Integer(),
+            sa.Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),
+            nullable=True,
+        ),
+        sa.ForeignKeyConstraint(
+            ["main_document_id"],
+            ["doc_document.id"],
+            name="fk_doc_sub_plan_binding_main_id_doc_document",
+            ondelete="RESTRICT",
+        ),
+        sa.ForeignKeyConstraint(
+            ["sub_document_id"],
+            ["doc_document.id"],
+            name="fk_doc_sub_plan_binding_sub_id_doc_document",
+            ondelete="RESTRICT",
+        ),
+        sa.UniqueConstraint(
+            "main_document_id",
+            "sub_document_id",
+            "active_marker",
+            name="uq_doc_sub_plan_binding_active",
+        ),
+    )
+    op.create_index(
+        "ix_doc_sub_plan_binding_main_active_sort",
+        "doc_sub_plan_binding",
+        ["main_document_id", "is_deleted", "sort_no"],
+    )
+    op.create_index(
+        "ix_doc_sub_plan_binding_sub_active",
+        "doc_sub_plan_binding",
+        ["sub_document_id", "is_deleted"],
+    )
+
+
+def downgrade() -> None:
+    if "doc_sub_plan_binding" not in _existing_tables():
+        return
+    bind = op.get_bind()
+    binding_rows = bind.scalar(
+        sa.text("SELECT COUNT(*) FROM doc_sub_plan_binding")
+    )
+    if binding_rows:
+        raise RuntimeError(
+            "0018已产生子案挂载数据,拒绝有损降级;请先备份并按数据治理流程处理"
+        )
+    op.drop_table("doc_sub_plan_binding")

+ 58 - 0
backend/migrations/versions/0019_content_extract_error.py

@@ -0,0 +1,58 @@
+"""正文提取错误落库:doc_document 增加 content_extract_error 列。
+
+Revision ID: 0019_content_extract_error
+Revises: 0018_sub_plan_binding
+Create Date: 2026-08-27
+
+变更要点:
+1. doc_document 增加 content_extract_error VARCHAR(512) NULL,
+   记录正文提取失败的脱敏原因(异常类型+首行信息,去除绝对路径),
+   供管理员在"编辑"界面排障;成功/空文本/不支持时为 NULL;
+2. 存量行保持 NULL,无需回填(可经"重新解析"接口按需重提);
+3. 升级为幂等:列已存在时跳过;降级仅在有失败记录时拒绝有损删除。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+revision: str = "0019_content_extract_error"
+down_revision: str | None = "0018_sub_plan_binding"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_TABLE = "doc_document"
+_COLUMN = "content_extract_error"
+
+
+def _existing_columns() -> set[str]:
+    inspector = sa.inspect(op.get_bind())
+    return {column["name"] for column in inspector.get_columns(_TABLE)}
+
+
+def upgrade() -> None:
+    if _COLUMN in _existing_columns():
+        return
+    op.add_column(
+        _TABLE,
+        sa.Column(_COLUMN, sa.String(512), nullable=True),
+    )
+
+
+def downgrade() -> None:
+    if _COLUMN not in _existing_columns():
+        return
+    bind = op.get_bind()
+    error_rows = bind.scalar(
+        sa.text(
+            f"SELECT COUNT(*) FROM {_TABLE} WHERE {_COLUMN} IS NOT NULL"
+        )
+    )
+    if error_rows:
+        raise RuntimeError(
+            "0019已产生提取失败记录,拒绝有损降级;请先备份并按数据治理流程处理"
+        )
+    op.drop_column(_TABLE, _COLUMN)

+ 26 - 1
backend/openapi/components/schemas.yaml

@@ -502,7 +502,7 @@ CategoryNode:
     documentCount:
       type: integer
       minimum: 0
-      description: 当前用户可见的、该分类完整有效子树内的MAIN数量。
+      description: 该分类完整有效子树内所有有效MAIN数量(不按当前用户密级/权限过滤,所有登录用户口径一致)。
     rowVersion:
       type: integer
       minimum: 0
@@ -790,6 +790,19 @@ DocumentDetail:
           type: string
           nullable: true
           description: 提取的正文文本;viewLocked且请求头密码未验证通过时为null。
+        contentExtractStatus:
+          type: string
+          enum: [SUCCESS, EMPTY, UNSUPPORTED, FAILED]
+          description: 正文提取状态。仅管理员详情响应返回。
+        contentExtractError:
+          type: string
+          nullable: true
+          description: 提取失败的脱敏原因(异常类型+首行信息,不含绝对路径)。仅管理员详情响应返回;非FAILED时为null。
+        contentExtractedAt:
+          type: string
+          format: date-time
+          nullable: true
+          description: 最近一次正文提取时间(UTC)。仅管理员详情响应返回。
 
 AttachmentBindingSummary:
   allOf:
@@ -1035,6 +1048,10 @@ MainDocumentUpdateRequest:
       maxItems: 50
       items: {type: string, minLength: 1, maxLength: 64}
     rowVersion: {type: integer, minimum: 0}
+    organizationId:
+      allOf:
+        - $ref: '#/StringId'
+      description: 目标归属部门;缺省沿用现有归属,调整到其他部门仅ADMIN。
 
 SubPlanUpdateRequest:
   type: object
@@ -1049,6 +1066,10 @@ SubPlanUpdateRequest:
       maxItems: 50
       items: {type: string, minLength: 1, maxLength: 64}
     rowVersion: {type: integer, minimum: 0}
+    organizationId:
+      allOf:
+        - $ref: '#/StringId'
+      description: 目标归属部门;缺省沿用现有归属,调整到其他部门仅ADMIN。
 
 UpdateDocumentRequest:
   oneOf:
@@ -1138,6 +1159,10 @@ UpdateAttachmentRequest:
       maxItems: 50
       items: {type: string, minLength: 1, maxLength: 64}
     rowVersion: {type: integer, minimum: 0}
+    organizationId:
+      allOf:
+        - $ref: '#/StringId'
+      description: 目标归属部门;缺省沿用现有归属,调整到其他部门仅ADMIN。
 
 DeleteDocumentData:
   type: object

+ 14 - 2
backend/openapi/openapi.yaml

@@ -1,8 +1,8 @@
 openapi: 3.0.3
 info:
   title: 方案计划文档管理系统 API
-  version: 1.10.0
-  description: DMS第一阶段接口;Q3-B正文检索、原文件预览、1.6 LibreOffice离线文档转换与PDF预览、1.7 机构用户维护与详细说明、1.8 角色管理与权限矩阵、1.9 方案关联关系、1.10 部门IP登录限制。
+  version: 1.13.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: /
 paths:
@@ -22,6 +22,10 @@ paths:
     $ref: './paths/auth.yaml#/Login'
   /api/v1/auth/logout:
     $ref: './paths/auth.yaml#/Logout'
+  /api/v1/auth/portal/verify:
+    $ref: './paths/auth.yaml#/PortalVerify'
+  /api/v1/auth/portal/setup-password:
+    $ref: './paths/auth.yaml#/PortalSetupPassword'
   /api/v1/users/me:
     $ref: './paths/users.yaml#/CurrentUser'
   /api/v1/organizations/tree:
@@ -56,6 +60,8 @@ paths:
     $ref: './paths/configuration.yaml#/UiDictionaries'
   /api/v1/config/runtime:
     $ref: './paths/configuration.yaml#/RuntimeConfig'
+  /api/v1/config/login-background:
+    $ref: './paths/configuration.yaml#/LoginBackground'
   /api/v1/categories/tree:
     $ref: './paths/categories.yaml#/CategoryTree'
   /api/v1/categories:
@@ -72,6 +78,8 @@ paths:
     $ref: './paths/documents.yaml#/DocumentsReorder'
   /api/v1/documents/{id}:
     $ref: './paths/documents.yaml#/Document'
+  /api/v1/documents/{id}/re-extract:
+    $ref: './paths/documents.yaml#/DocumentReExtract'
   /api/v1/documents/batch-import:
     $ref: './paths/documents.yaml#/DocumentBatchImport'
   /api/v1/documents/{id}/preview:
@@ -80,6 +88,10 @@ paths:
     $ref: './paths/documents.yaml#/DocumentDownload'
   /api/v1/main-plans/{id}/sub-plans:
     $ref: './paths/documents.yaml#/SubPlans'
+  /api/v1/main-plans/{id}/sub-plans/bind:
+    $ref: './paths/documents.yaml#/MainPlanSubPlanBinding'
+  /api/v1/main-plans/{id}/sub-plans/{subPlanId}:
+    $ref: './paths/documents.yaml#/MainPlanSubPlan'
   /api/v1/attachments:
     $ref: './paths/documents.yaml#/Attachments'
   /api/v1/attachments/{id}:

+ 79 - 0
backend/openapi/paths/auth.yaml

@@ -48,3 +48,82 @@ Logout:
         $ref: '../components/responses.yaml#/Forbidden'
       '500':
         $ref: '../components/responses.yaml#/InternalError'
+
+PortalVerify:
+  post:
+    operationId: verifyPortalLaunch
+    summary: 统一门户跳转验签
+    description: 透传浏览器地址栏query串验签,按需落库建档并同步组织信息;返回跳转阶段,首次进入需设置独立密码。
+    security: []
+    parameters:
+      - $ref: '../components/parameters.yaml#/XRequestId'
+    requestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            type: object
+            required: [query]
+            additionalProperties: false
+            properties:
+              query: {type: string, minLength: 1, description: 门户跳转链接的完整query串}
+    responses:
+      '200':
+        description: 验签结果
+        content:
+          application/json:
+            schema:
+              type: object
+              required: [code, message, data, requestId]
+              properties:
+                code: {type: string}
+                message: {type: string}
+                requestId: {type: string}
+                data:
+                  type: object
+                  required: [phase, username, realName]
+                  properties:
+                    phase: {type: string, enum: [LOGIN, SET_PASSWORD], description: SET_PASSWORD时携带一次性设密票据portalTicket}
+                    username: {type: string}
+                    realName: {type: string}
+                    portalTicket: {type: string}
+      '400':
+        $ref: '../components/responses.yaml#/InvalidArgument'
+      '403':
+        $ref: '../components/responses.yaml#/Forbidden'
+      '500':
+        $ref: '../components/responses.yaml#/InternalError'
+
+PortalSetupPassword:
+  post:
+    operationId: setupPortalPassword
+    summary: 门户用户首次设置独立密码并登录
+    description: 票据一次性且5分钟内有效;设置成功后签发访问令牌。
+    security: []
+    parameters:
+      - $ref: '../components/parameters.yaml#/XRequestId'
+    requestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            type: object
+            required: [portalTicket, newPassword, keepSignedIn]
+            additionalProperties: false
+            properties:
+              portalTicket: {type: string, minLength: 1}
+              newPassword: {type: string, minLength: 1}
+              keepSignedIn: {type: boolean}
+    responses:
+      '200':
+        description: 设密并登录成功
+        content:
+          application/json:
+            schema:
+              $ref: '../components/schemas.yaml#/LoginResponse'
+      '400':
+        $ref: '../components/responses.yaml#/InvalidArgument'
+      '403':
+        $ref: '../components/responses.yaml#/Forbidden'
+      '500':
+        $ref: '../components/responses.yaml#/InternalError'

+ 38 - 0
backend/openapi/paths/configuration.yaml

@@ -40,3 +40,41 @@ RuntimeConfig:
               $ref: '../components/schemas.yaml#/RuntimeConfigResponse'
       '500':
         $ref: '../components/responses.yaml#/InternalError'
+
+LoginBackground:
+  get:
+    operationId: getLoginBackground
+    summary: 查询登录界面自定义背景图
+    description: >-
+      返回服务端运行目录下的 background.jpg 或 background.png(jpg优先);
+      运行目录不存在背景图文件时返回404,前端回退默认纯色背景。
+      接口公开(登录前无凭证可访问),允许浏览器缓存60秒。
+    security: []
+    parameters:
+      - $ref: '../components/parameters.yaml#/XRequestId'
+    responses:
+      '200':
+        description: 背景图片二进制
+        headers:
+          X-Request-Id:
+            description: 与请求发送的X-Request-Id一致
+            schema:
+              type: string
+              format: uuid
+        content:
+          image/jpeg:
+            schema:
+              type: string
+              format: binary
+          image/png:
+            schema:
+              type: string
+              format: binary
+      '404':
+        description: 运行目录下不存在背景图文件
+        content:
+          application/json:
+            schema:
+              $ref: '../components/schemas.yaml#/ErrorResponse'
+      '500':
+        $ref: '../components/responses.yaml#/InternalError'

+ 106 - 1
backend/openapi/paths/documents.yaml

@@ -132,7 +132,8 @@ SubPlans:
 Attachments:
   get:
     operationId: listAttachments
-    summary: ADMIN分页查询独立共享附件库
+    summary: 分页查询独立共享附件库(全量可见,普通用户allowedActions为空且不返回description/contentSnippet)
+    description: 所有登录用户可访问,支撑全局检索按标题命中全部附件;附件内容访问由详情/预览/下载接口按挂载主案权限独立拦截。
     parameters:
       - $ref: '../components/parameters.yaml#/XRequestId'
       - {name: keyword, in: query, schema: {type: string}}
@@ -477,6 +478,25 @@ DocumentDescription:
       '404': {$ref: '../components/responses.yaml#/NotFound'}
       '500': {$ref: '../components/responses.yaml#/InternalError'}
 
+DocumentReExtract:
+  parameters:
+    - {name: id, in: path, required: true, schema: {$ref: '../components/schemas.yaml#/StringId'}}
+  post:
+    operationId: reExtractDocumentContent
+    summary: 重新提取文档正文(仅管理员,主案/子案/附件通用)
+    description: |
+      按当前存储文件重新解析正文并回写 contentText/searchText 与提取状态;
+      Office 类型(doc/wps/xls/xlsx/et)先经 LibreOffice 转 PDF 再提取。
+      提取结果(含失败原因 contentExtractError)覆盖写回,rowVersion 递增,
+      审计复用 EDIT_DOCUMENT(detail.scope=reExtractContent)。
+    responses:
+      '200': {description: 重新提取后的文档详情, content: {application/json: {schema: {$ref: '../components/schemas.yaml#/DocumentDetailResponse'}}}}
+      '400': {$ref: '../components/responses.yaml#/InvalidArgument'}
+      '401': {$ref: '../components/responses.yaml#/AuthenticationError'}
+      '403': {$ref: '../components/responses.yaml#/Forbidden'}
+      '404': {$ref: '../components/responses.yaml#/NotFound'}
+      '500': {$ref: '../components/responses.yaml#/InternalError'}
+
 DocumentPermissions:
   parameters:
     - {name: id, in: path, required: true, schema: {$ref: '../components/schemas.yaml#/StringId'}}
@@ -562,3 +582,88 @@ DocumentRelations:
       '404': {$ref: '../components/responses.yaml#/NotFound'}
       '409': {$ref: '../components/responses.yaml#/Conflict'}
       '500': {$ref: '../components/responses.yaml#/InternalError'}
+
+MainPlanSubPlanBinding:
+  parameters:
+    - {name: id, in: path, required: true, schema: {$ref: '../components/schemas.yaml#/StringId'}}
+  post:
+    operationId: bindMainPlanSubPlans
+    summary: 批量挂载其他主案的子案到当前主案
+    description: 仅建立展示关系,子案权限仍继承原主案;同一主案上的写入由主案行锁串行化。
+    parameters:
+      - $ref: '../components/parameters.yaml#/XDocumentPassword'
+    requestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            type: object
+            required: [subPlanIds, mainPlanRowVersion]
+            additionalProperties: false
+            properties:
+              subPlanIds:
+                type: array
+                minItems: 1
+                items: {$ref: '../components/schemas.yaml#/StringId'}
+              mainPlanRowVersion: {type: integer, minimum: 0}
+    responses:
+      '200':
+        description: 挂载结果
+        content:
+          application/json:
+            schema:
+              type: object
+              required: [code, message, data, requestId]
+              properties:
+                code: {type: string}
+                message: {type: string}
+                requestId: {type: string}
+                data:
+                  type: object
+                  required: [createdCount, existingCount, subPlanCount, mainPlanRowVersion]
+                  properties:
+                    createdCount: {type: integer, minimum: 0}
+                    existingCount: {type: integer, minimum: 0}
+                    subPlanCount: {type: integer, minimum: 0}
+                    mainPlanRowVersion: {type: integer, minimum: 0}
+      '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'}
+
+MainPlanSubPlan:
+  parameters:
+    - {name: id, in: path, required: true, schema: {$ref: '../components/schemas.yaml#/StringId'}}
+    - {name: subPlanId, in: path, required: true, schema: {$ref: '../components/schemas.yaml#/StringId'}}
+  delete:
+    operationId: unbindMainPlanSubPlan
+    summary: 解除主案与已挂载子案的展示关系
+    parameters:
+      - {name: mainPlanRowVersion, in: query, required: true, schema: {type: integer, minimum: 0}}
+      - $ref: '../components/parameters.yaml#/XDocumentPassword'
+    responses:
+      '200':
+        description: 解除挂载结果
+        content:
+          application/json:
+            schema:
+              type: object
+              required: [code, message, data, requestId]
+              properties:
+                code: {type: string}
+                message: {type: string}
+                requestId: {type: string}
+                data:
+                  type: object
+                  required: [subPlanCount, mainPlanRowVersion]
+                  properties:
+                    subPlanCount: {type: integer, minimum: 0}
+                    mainPlanRowVersion: {type: integer, minimum: 0}
+      '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'}

+ 14 - 1
backend/tests/dms/conftest.py

@@ -49,8 +49,13 @@ def b2_app():
         AuditLog,
         Category,
         Document,
+        DocumentRelation,
         Organization,
         Permission,
+        Role,
+        RoleDepartment,
+        RolePermission,
+        SubPlanBinding,
         User,
     )
     from dms.security.passwords import hash_password
@@ -66,6 +71,14 @@ def b2_app():
 
     with app.app_context():
         db.session.execute(delete(AuditLog))
+        # 角色三表:先解除用户 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))
         db.session.execute(delete(Permission))
         db.session.execute(delete(AttachmentBinding))
         db.session.execute(
@@ -449,7 +462,7 @@ def b4_app(b3_app):
             "ATTACHMENT",
             security="PUBLIC",
             visibility="ALL_AUTHENTICATED",
-            attachment_type="OTHER",
+            attachment_type="TYPICAL_CASE",
             deleted=True,
         )
         db.session.add_all(

+ 76 - 0
backend/tests/dms/services/test_category_type_display_name.py

@@ -0,0 +1,76 @@
+"""顶级分类 typeDisplayName(0017)服务层入参契约。
+
+仅覆盖不依赖数据库与请求上下文的校验分支:
+字段集合契约 + 类型/长度校验均发生在 get_auth_context/事务之前。
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from dms.common.errors import InvalidArgumentError
+from dms.services.category_service import create_category, update_category
+
+
+def _create_payload(**overrides):
+    payload = {
+        "categoryCode": "Q6_TDN_CODE",
+        "categoryName": "Q6显示名分类",
+        "categoryType": "SCENE",
+        "parentId": None,
+        "sortNo": 10,
+        "flatMode": False,
+        "typeDisplayName": None,
+    }
+    payload.update(overrides)
+    return payload
+
+
+def _update_payload(**overrides):
+    payload = {
+        "categoryName": "Q6显示名分类",
+        "categoryType": "SCENE",
+        "parentId": None,
+        "sortNo": 10,
+        "flatMode": False,
+        "rowVersion": 0,
+        "typeDisplayName": None,
+    }
+    payload.update(overrides)
+    return payload
+
+
+def test_create_rejects_missing_type_display_name():
+    payload = _create_payload()
+    del payload["typeDisplayName"]
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        create_category(payload)
+    assert excinfo.value.code == "INVALID_ARGUMENT"
+    assert "typeDisplayName" in excinfo.value.message
+
+
+def test_update_rejects_missing_type_display_name():
+    payload = _update_payload()
+    del payload["typeDisplayName"]
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        update_category(1, payload)
+    assert excinfo.value.code == "INVALID_ARGUMENT"
+    assert "typeDisplayName" in excinfo.value.message
+
+
+def test_create_rejects_non_string_type_display_name():
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        create_category(_create_payload(typeDisplayName=123))
+    assert "typeDisplayName必须是字符串或null" in excinfo.value.message
+
+
+def test_create_rejects_overlength_type_display_name():
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        create_category(_create_payload(typeDisplayName="长" * 129))
+    assert "typeDisplayName长度不能超过128" in excinfo.value.message
+
+
+def test_update_rejects_overlength_type_display_name():
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        update_category(1, _update_payload(typeDisplayName="长" * 129))
+    assert "typeDisplayName长度不能超过128" in excinfo.value.message

+ 181 - 0
backend/tests/dms/services/test_document_content_service.py

@@ -0,0 +1,181 @@
+"""正文提取服务(document_content_service)纯单元测试。
+
+不依赖数据库与请求上下文:
+- _merge_wrapped_lines:PDF 段内软换行合并启发式(纯文本兜底);
+- _merge_line_items:块内合并规则(含字号突变切分);
+- sanitize_extract_error:失败原因脱敏(去除绝对路径);
+- extract_document_content:UNSUPPORTED/FAILED 分支、PDF 版面块分段与退化回退。
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import fitz
+
+from dms.common.enums import ContentExtractStatus
+from dms.services.document_content_service import (
+    _merge_line_items,
+    _merge_wrapped_lines,
+    extract_document_content,
+    sanitize_extract_error,
+)
+
+
+def test_merge_chinese_wrapped_line_without_space():
+    text = "这是一段在段内被换行\n打断的中文正文,合并后不应有空格"
+    assert _merge_wrapped_lines(text) == "这是一段在段内被换行打断的中文正文,合并后不应有空格"
+
+
+def test_merge_english_words_with_space():
+    text = "emergency response\nplan continues here"
+    assert _merge_wrapped_lines(text) == "emergency response plan continues here"
+
+
+def test_sentence_end_punctuation_keeps_paragraph_break():
+    text = "第一条意见如下。\n第二条意见如下:\n具体内容见附件。"
+    assert _merge_wrapped_lines(text) == "第一条意见如下。\n第二条意见如下:\n具体内容见附件。"
+
+
+def test_blank_line_keeps_paragraph_break():
+    text = "第一段未结束\n仍在同一段\n\n第二段开始"
+    assert _merge_wrapped_lines(text) == "第一段未结束仍在同一段\n第二段开始"
+
+
+def test_list_marker_start_keeps_paragraph_break():
+    text = "总体要求如下\n1. 第一条\n(一)细分条款\n一、中文序号\n第2章范围"
+    assert _merge_wrapped_lines(text) == "总体要求如下\n1. 第一条\n(一)细分条款\n一、中文序号\n第2章范围"
+
+
+def test_mixed_cjk_and_ascii_merge_directly():
+    # 一侧为中文时不补空格
+    text = "使用 PDF\n格式存储文件"
+    assert _merge_wrapped_lines(text) == "使用 PDF格式存储文件"
+
+
+def test_sanitize_error_strips_windows_path():
+    exc = RuntimeError("cannot open D:/dms/storage/uploads/a/b.pdf: permission denied")
+    sanitized = sanitize_extract_error(exc)
+    assert sanitized.startswith("RuntimeError:")
+    assert "D:/" not in sanitized
+    assert "<path>" in sanitized
+
+
+def test_sanitize_error_strips_posix_path_and_truncates():
+    exc = ValueError("failed at /var/lib/dms/storage/file.pdf " + "x" * 600)
+    sanitized = sanitize_extract_error(exc)
+    assert "/var/lib" not in sanitized
+    assert len(sanitized) <= 480
+
+
+def test_sanitize_error_multiline_uses_first_line():
+    exc = RuntimeError("第一行原因\n第二行堆栈")
+    assert sanitize_extract_error(exc) == "RuntimeError: 第一行原因"
+
+
+def test_unsupported_extension_returns_unsupported(tmp_path: Path):
+    target = tmp_path / "image.png"
+    target.write_bytes(b"\x89PNG\r\n\x1a\n")
+    result = extract_document_content(target, "png")
+    assert result.status == ContentExtractStatus.UNSUPPORTED.value
+    assert result.text is None
+    assert result.error is None
+    assert result.extracted_at is None
+
+
+def test_corrupt_pdf_returns_failed_with_error(tmp_path: Path):
+    target = tmp_path / "broken.pdf"
+    target.write_bytes(b"not a real pdf content")
+    result = extract_document_content(target, "pdf")
+    assert result.status == ContentExtractStatus.FAILED.value
+    assert result.text is None
+    assert result.error  # 必须携带失败原因
+    assert result.extracted_at is not None
+
+
+def _write_pdf(path: Path, lines: list[str], gap: int = 16, fontsize: int = 11) -> None:
+    document = fitz.open()
+    page = document.new_page()
+    y = 72
+    for line in lines:
+        # china-s:PyMuPDF 内嵌简体中文字体(默认 helv 无法编码 CJK)
+        page.insert_text((72, y), line, fontsize=fontsize, fontname="china-s")
+        y += gap
+    document.save(path)
+    document.close()
+
+
+def test_pdf_extraction_merges_wrapped_lines(tmp_path: Path):
+    target = tmp_path / "wrapped.pdf"
+    _write_pdf(
+        target,
+        ["这是一段在段内被换行", "打断的中文正文。", "另一段独立内容。"],
+    )
+    result = extract_document_content(target, "pdf")
+    assert result.status == ContentExtractStatus.SUCCESS.value
+    assert result.error is None
+    assert result.text is not None
+    # 第一行未以句末标点结尾:与第二行合并;第二行以"。"结尾:保留段落边界
+    assert "这是一段在段内被换行打断的中文正文。" in result.text
+    assert "打断的中文正文。\n另一段独立内容。" in result.text
+
+
+def test_pdf_heading_in_separate_block_not_merged_with_body(tmp_path: Path):
+    """标题行与正文行距较大(独立成块)时,即使标题无句末标点也不并入正文。"""
+    target = tmp_path / "heading.pdf"
+    document = fitz.open()
+    page = document.new_page()
+    page.insert_text((72, 72), "第一章 总则", fontsize=11, fontname="china-s")
+    page.insert_text((72, 72 + 24), "为规范方案管理,特制定本办法。", fontsize=11, fontname="china-s")
+    document.save(target)
+    document.close()
+    result = extract_document_content(target, "pdf")
+    assert result.status == ContentExtractStatus.SUCCESS.value
+    assert result.text is not None
+    assert "第一章 总则\n为规范方案管理,特制定本办法。" in result.text
+
+
+def test_pdf_different_font_size_splits_paragraph(tmp_path: Path):
+    """标题与正文字号不同时独立成块(或块内字号突变切分),不合并为一段。"""
+    target = tmp_path / "fontsize.pdf"
+    document = fitz.open()
+    page = document.new_page()
+    page.insert_text((72, 72), "方案名称", fontsize=18, fontname="china-s")
+    page.insert_text((72, 72 + 24), "本方案用于说明字号差异的段落切分", fontsize=11, fontname="china-s")
+    document.save(target)
+    document.close()
+    result = extract_document_content(target, "pdf")
+    assert result.status == ContentExtractStatus.SUCCESS.value
+    assert result.text is not None
+    assert "方案名称\n本方案用于说明字号差异的段落切分" in result.text
+
+
+def test_pdf_degenerate_layout_falls_back_to_heuristic(tmp_path: Path):
+    """每行独立成块(行距≥20pt 逐行定位)时回退纯文本启发式,行为与现状一致。"""
+    target = tmp_path / "degenerate.pdf"
+    _write_pdf(
+        target,
+        ["这是一段在段内被换行", "打断的中文正文。", "另一段独立内容。"],
+        gap=24,
+    )
+    result = extract_document_content(target, "pdf")
+    assert result.status == ContentExtractStatus.SUCCESS.value
+    assert result.text is not None
+    assert "这是一段在段内被换行打断的中文正文。" in result.text
+    assert "打断的中文正文。\n另一段独立内容。" in result.text
+
+
+def test_merge_line_items_splits_on_font_size_jump():
+    items = [("方案名称", 18.0), ("本方案用于说明字号差异", 11.0)]
+    assert _merge_line_items(items) == ["方案名称", "本方案用于说明字号差异"]
+
+
+def test_merge_line_items_merges_same_font_size():
+    items = [("段内被换行", 11.0), ("打断的中文正文", 11.0)]
+    assert _merge_line_items(items) == ["段内被换行打断的中文正文"]
+
+
+def test_merge_line_items_ignores_font_size_when_unavailable():
+    # 字号为 0(纯文本兜底路径)不参与字号比较,仍按启发式合并
+    items = [("段内被换行", 0.0), ("打断的中文正文", 0.0)]
+    assert _merge_line_items(items) == ["段内被换行打断的中文正文"]

+ 101 - 0
backend/tests/dms/services/test_document_converter.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import subprocess
+import sys
 import threading
 import time
 from pathlib import Path
@@ -18,6 +19,7 @@ from dms.common.errors import (
 from dms.services.document_converter import (
     FakeDocumentConverter,
     LibreOfficeDocumentConverter,
+    _version_at_least_7_4,
 )
 
 
@@ -56,6 +58,105 @@ def test_libreoffice_unavailable_when_disabled() -> None:
     assert converter.available is False
 
 
+def test_version_at_least_7_4() -> None:
+    assert _version_at_least_7_4("LibreOffice 7.4") is True
+    assert _version_at_least_7_4("LibreOffice 7.6.7.2 (X86_64)") is True
+    assert _version_at_least_7_4("LibreOffice 24.8.4.2 (X86_64)") is True
+    assert _version_at_least_7_4("LibreOffice 25.2 (x86_64)") is True
+    assert _version_at_least_7_4("LibreOffice 7.3.7.2") is False
+    assert _version_at_least_7_4("LibreOffice 6.4.7.2") is False
+    assert _version_at_least_7_4("") is False
+    assert _version_at_least_7_4("no version here") is False
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="soffice.com 仅 Windows 存在")
+def test_version_probe_prefers_soffice_com_on_windows(tmp_path: Path) -> None:
+    exe = tmp_path / "soffice.exe"
+    exe.write_bytes(b"MZ")
+    com = tmp_path / "soffice.com"
+    com.write_bytes(b"MZ")
+    converter = LibreOfficeDocumentConverter(
+        executable=str(exe),
+        timeout_seconds=5,
+        max_concurrency=1,
+    )
+    assert converter._version_probe_executable() == str(com)
+
+
+def test_version_probe_falls_back_when_com_missing(tmp_path: Path) -> None:
+    exe = tmp_path / "soffice.exe"
+    exe.write_bytes(b"MZ")
+    converter = LibreOfficeDocumentConverter(
+        executable=str(exe),
+        timeout_seconds=5,
+        max_concurrency=1,
+    )
+    assert converter._version_probe_executable() == str(exe)
+
+
+class _CompletedVersion:
+    def __init__(self, stdout: str) -> None:
+        self.stdout = stdout
+        self.stderr = ""
+
+
+def test_single_page_detect_success_cached_permanently(
+    tmp_path: Path, monkeypatch
+) -> None:
+    exe = tmp_path / "soffice.exe"
+    exe.write_bytes(b"MZ")
+    converter = LibreOfficeDocumentConverter(
+        executable=str(exe),
+        timeout_seconds=5,
+        max_concurrency=1,
+    )
+    calls: list[tuple] = []
+
+    def fake_run(*args, **kwargs):
+        calls.append(args)
+        return _CompletedVersion("LibreOffice 26.2.5.2")
+
+    monkeypatch.setattr(subprocess, "run", fake_run)
+    assert converter._supports_single_page_sheets() is True
+    assert converter._supports_single_page_sheets() is True
+    assert len(calls) == 1, "探测成功应永久缓存,不重复执行 --version"
+
+
+def test_single_page_detect_failure_retried_after_ttl(
+    tmp_path: Path, monkeypatch
+) -> None:
+    exe = tmp_path / "soffice.exe"
+    exe.write_bytes(b"MZ")
+    converter = LibreOfficeDocumentConverter(
+        executable=str(exe),
+        timeout_seconds=5,
+        max_concurrency=1,
+    )
+    calls: list[tuple] = []
+    state = {"fail": True}
+
+    def fake_run(*args, **kwargs):
+        calls.append(args)
+        if state["fail"]:
+            raise OSError("probe failed")
+        return _CompletedVersion("LibreOffice 26.2.5.2")
+
+    monkeypatch.setattr(subprocess, "run", fake_run)
+    assert converter._supports_single_page_sheets() is False
+    assert converter._supports_single_page_sheets() is False
+    assert len(calls) == 1, "TTL 内的失败结果应被缓存,不重复探测"
+
+    # TTL 过期后允许重试;恢复成功后转为永久缓存
+    monkeypatch.setattr(
+        "dms.services.document_converter._SINGLE_PAGE_DETECT_RETRY_SECONDS", 0.0
+    )
+    state["fail"] = False
+    assert converter._supports_single_page_sheets() is True
+    assert len(calls) == 2
+    assert converter._supports_single_page_sheets() is True
+    assert len(calls) == 2, "重试成功后应转为永久缓存"
+
+
 def test_libreoffice_unavailable_when_executable_missing() -> None:
     converter = LibreOfficeDocumentConverter(
         executable="/definitely/not/exists/soffice",

+ 22 - 1
backend/tests/dms/services/test_preview_service.py

@@ -7,7 +7,7 @@ from pathlib import Path
 import pytest
 
 from dms.services.document_converter import FakeDocumentConverter
-from dms.services.preview_service import PreviewService
+from dms.services.preview_service import PreviewService, preview_cache_key
 from dms.storage.paths import UnsafeStoragePathError, preview_cache_path
 
 
@@ -125,3 +125,24 @@ def test_preview_path_for_doc_uses_cache_on_second_call(
     second = preview_service.preview_path_for(document)
     assert second == first
     assert second.stat().st_mtime == mtime
+
+
+def test_preview_cache_key_spreadsheet_gets_version_suffix() -> None:
+    assert preview_cache_key("abcdef123456", "xlsx") == "abcdef123456-sps1"
+    assert preview_cache_key("abcdef123456", "XLS") == "abcdef123456-sps1"
+    assert preview_cache_key("abcdef123456", ".et") == "abcdef123456-sps1"
+    assert preview_cache_key("abcdef123456", "docx") == "abcdef123456"
+    assert preview_cache_key("abcdef123456", "wps") == "abcdef123456"
+
+
+def test_preview_path_for_xlsx_uses_suffixed_cache(
+    preview_service: PreviewService, storage_root: Path
+) -> None:
+    original = storage_root / "original" / "sample.xlsx"
+    original.write_bytes(b"fake xlsx content")
+    document = FakeDocument("xlsx", "xlshash", "original/sample.xlsx")
+    path = preview_service.preview_path_for(document)
+    # 表格类缓存键带策略版本后缀,与旧分页缓存隔离
+    assert path.name == "xlshash-sps1.pdf"
+    assert path.exists()
+    assert path.read_bytes().startswith(b"%PDF-")

+ 57 - 0
backend/tests/dms/services/test_sub_plan_binding_service.py

@@ -0,0 +1,57 @@
+"""子案跨主案挂载(0018)服务层载荷契约。
+
+仅覆盖不依赖数据库与请求上下文的入参校验分支:
+_bind_payload 与 _version 均在 get_auth_context/事务之前执行。
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from dms.common.errors import InvalidArgumentError
+from dms.services.sub_plan_binding_service import bind_sub_plans, unbind_sub_plan
+
+
+def test_bind_rejects_missing_main_plan_row_version():
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        bind_sub_plans(1, {"subPlanIds": ["2"]})
+    assert excinfo.value.code == "INVALID_ARGUMENT"
+
+
+def test_bind_rejects_extra_fields():
+    with pytest.raises(InvalidArgumentError):
+        bind_sub_plans(
+            1,
+            {"subPlanIds": ["2"], "mainPlanRowVersion": 0, "extra": 1},
+        )
+
+
+def test_bind_rejects_empty_sub_plan_ids():
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        bind_sub_plans(1, {"subPlanIds": [], "mainPlanRowVersion": 0})
+    assert "subPlanIds必须是非空数组" in excinfo.value.message
+
+
+def test_bind_rejects_non_string_sub_plan_id():
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        bind_sub_plans(1, {"subPlanIds": [2], "mainPlanRowVersion": 0})
+    assert "subPlanIds[0]" in excinfo.value.message
+
+
+def test_bind_rejects_non_numeric_sub_plan_id():
+    with pytest.raises(InvalidArgumentError):
+        bind_sub_plans(
+            1, {"subPlanIds": ["abc"], "mainPlanRowVersion": 0}
+        )
+
+
+def test_bind_rejects_negative_row_version():
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        bind_sub_plans(1, {"subPlanIds": ["2"], "mainPlanRowVersion": -1})
+    assert "mainPlanRowVersion" in excinfo.value.message
+
+
+def test_unbind_rejects_negative_row_version():
+    with pytest.raises(InvalidArgumentError) as excinfo:
+        unbind_sub_plan(1, 2, -1)
+    assert "mainPlanRowVersion" in excinfo.value.message

+ 12 - 7
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、0015,且两者 upgrade 不含数据删除。"""
+    """远端生产库 0013 → head 恰好经过 0014~0019,upgrade 不含数据删除。"""
     script = _script()
-    assert script.get_current_head() == "0015_login_ip_restriction"
+    assert script.get_current_head() == "0019_content_extract_error"
     steps = [
         r.revision
         for r in reversed(
@@ -31,22 +31,27 @@ def test_migration_chain_head_and_steps_from_0013():
         "0013_document_password_lock",
         "0014_document_relations",
         "0015_login_ip_restriction",
+        "0016_portal_user_provision",
+        "0017_category_type_display_name",
+        "0018_sub_plan_binding",
+        "0019_content_extract_error",
     ]
 
 
 def test_run_upgrade_lists_pending_steps_excluding_current(monkeypatch, capsys):
-    """0013 升级到 head:待执行清单只含 0014/0015,当前版本不重复出现。"""
+    """0016 升级到 head:待执行清单只含 0017/0018/0019,当前版本不重复出现。"""
     monkeypatch.setenv("DMS_DATABASE_URL", "mysql+pymysql://u:secret@127.0.0.1:3306/dms")
-    versions = iter(["0013_document_password_lock", "0015_login_ip_restriction"])
+    versions = iter(["0016_portal_user_provision", "0019_content_extract_error"])
     monkeypatch.setattr(
         "dms.db_upgrade._read_current_version", lambda url: next(versions)
     )
     monkeypatch.setattr("alembic.command.upgrade", lambda cfg, rev: None)
     assert run_upgrade(assume_yes=True) == 0
     out = capsys.readouterr().out
-    assert "  - 0014_document_relations" in out
-    assert "  - 0015_login_ip_restriction" in out
-    assert "  - 0013" not in out
+    assert "  - 0017_category_type_display_name" in out
+    assert "  - 0018_sub_plan_binding" in out
+    assert "  - 0019_content_extract_error" in out
+    assert "  - 0016" not in out
     assert "secret" not in out  # 输出中的地址必须已脱敏
 
 

+ 118 - 0
backend/tests/dms/test_document_organization.py

@@ -4,6 +4,8 @@
 - 上传默认取创建者当前部门;显式 organizationId 可指定其他启用部门
 - 无效/停用部门 → 404 RESOURCE_NOT_FOUND
 - PUT /documents/{id} 修改归属部门
+- 编辑缺省 organizationId 沿用现有归属,不被编辑人部门覆盖
+- 非管理员原样回传其他部门归属视为未变更;改挂其他部门 → 400 INVALID_ARGUMENT
 - 组织改名级联 Document.organization_name 冗余字段
 - 用户调岗不联动历史文档归属
 - 附件库条目输出 organizationName
@@ -214,6 +216,122 @@ def test_update_document_organization(do_app, do_client, token_for):
     ]
 
 
+def _edit_payload(item: dict, **extra) -> dict:
+    return {
+        "documentName": item["documentName"],
+        "summary": item["summary"],
+        "categoryId": item["categoryId"],
+        "securityLevel": item["securityLevel"],
+        "tags": item["tags"],
+        "rowVersion": item["rowVersion"],
+        **extra,
+    }
+
+
+def test_update_without_organization_keeps_current(
+    do_app, do_client, token_for
+):
+    """编辑未携带 organizationId 时沿用文档现有归属,不被编辑人部门覆盖。"""
+    token = token_for("admin")
+    org_a_id = do_app.config["DO_IDS"]["org_a"]
+    created = _upload(
+        do_client,
+        token,
+        "documents",
+        _main_metadata(
+            _leaf_category_id(do_app),
+            "DO_TEST_编辑不改部门主案",
+            organizationId=str(org_a_id),
+        ),
+    ).get_json()["data"]
+
+    updated = do_client.put(
+        f"/api/v1/documents/{created['id']}",
+        json=_edit_payload(created),
+        headers=_auth(token),
+    )
+    assert updated.status_code == 200
+    data = updated.get_json()["data"]
+    assert data["organizationId"] == str(org_a_id)
+    assert data["organizationName"] == "DO甲部门"
+
+
+def test_non_admin_can_resubmit_other_organization_but_not_reassign(
+    do_app, do_client, token_for
+):
+    """非管理员原样回传其他部门归属视为未变更;改挂其他部门被拒绝。"""
+    admin_token = token_for("admin")
+    user_token = token_for("user")
+    org_a_id = do_app.config["DO_IDS"]["org_a"]
+    comms_id, _, _, _ = _org_by_code(do_app, "ORG_COMMS")
+
+    created = _upload(
+        do_client,
+        admin_token,
+        "documents",
+        _main_metadata(
+            _leaf_category_id(do_app),
+            "DO_TEST_普通用户编辑他部门主案",
+            organizationId=str(org_a_id),
+        ),
+    ).get_json()["data"]
+    with do_app.app_context():
+        user_id = db.session.scalar(
+            db.select(User.id).where(User.username == "user")
+        )
+
+    # 授予普通用户该主案EDIT权限(CUSTOM可见范围+用户授权)
+    granted = do_client.put(
+        f"/api/v1/documents/{created['id']}/permissions",
+        json={
+            "visibilityType": "CUSTOM",
+            "documentRowVersion": created["rowVersion"],
+            "entries": [
+                {
+                    "subjectType": "USER",
+                    "subjectId": str(user_id),
+                    "canView": True,
+                    "canDownload": True,
+                    "canEdit": True,
+                    "canManagePermission": False,
+                    "canDelete": False,
+                }
+            ],
+        },
+        headers=_auth(admin_token),
+    )
+    assert granted.status_code == 200, granted.get_json()
+
+    # 保存权限会推进rowVersion,重新读取详情
+    latest = do_client.get(
+        f"/api/v1/documents/{created['id']}", headers=_auth(user_token)
+    ).get_json()["data"]
+    assert "EDIT" in latest["allowedActions"]
+
+    # 原样回传其他部门(DO甲部门)归属:视为未变更,允许保存
+    unchanged = do_client.put(
+        f"/api/v1/documents/{created['id']}",
+        json=_edit_payload(latest, organizationId=str(org_a_id)),
+        headers=_auth(user_token),
+    )
+    assert unchanged.status_code == 200, unchanged.get_json()
+    assert unchanged.get_json()["data"]["organizationId"] == str(org_a_id)
+
+    # 改挂到通信部:非管理员被拒绝
+    reassigned = do_client.put(
+        f"/api/v1/documents/{created['id']}",
+        json=_edit_payload(
+            unchanged.get_json()["data"], organizationId=str(comms_id)
+        ),
+        headers=_auth(user_token),
+    )
+    assert reassigned.status_code == 400
+    body = reassigned.get_json()
+    assert body["code"] == "INVALID_ARGUMENT"
+    assert body["message"] == "仅管理员可将文档调整到其他部门"
+
+
+
 def test_organization_rename_cascades_document_name(do_app, do_client, token_for):
     token = token_for("admin")
     org_a_id, _, parent_id, row_version = _org_by_code(do_app, "ORG_DO_A")

+ 35 - 8
backend/tests/dms/test_http.py

@@ -17,27 +17,54 @@ def test_only_q2b_dms_routes_are_registered(flask_app):
         "/api/v1/health",
         "/api/v1/auth/login",
         "/api/v1/auth/logout",
+        "/api/v1/auth/portal/verify",
+        "/api/v1/auth/portal/setup-password",
         "/api/v1/users/me",
+        "/api/v1/users",
+        "/api/v1/users/<id>",
+        "/api/v1/users/<id>/reset-password",
         "/api/v1/organizations/tree",
-            "/api/v1/users",
-            "/api/v1/config/ui-dictionaries",
+        "/api/v1/organizations",
+        "/api/v1/organizations/<id>",
+        "/api/v1/organizations/<id>/ip-assignments",
+        "/api/v1/config/ui-dictionaries",
+        "/api/v1/config/runtime",
+        "/api/v1/config/login-background",
         "/api/v1/categories/tree",
         "/api/v1/categories",
         "/api/v1/categories/<id>",
+        "/api/v1/categories/reorder",
         "/api/v1/documents",
-            "/api/v1/documents/<id>",
-            "/api/v1/documents/batch-import",
-            "/api/v1/documents/<id>/preview",
-            "/api/v1/documents/<id>/download",
+        "/api/v1/documents/<id>",
+        "/api/v1/documents/batch-import",
+        "/api/v1/documents/<id>/preview",
+        "/api/v1/documents/<id>/re-extract",
+        "/api/v1/documents/<id>/download",
+        "/api/v1/documents/<id>/description",
+        "/api/v1/documents/<id>/password",
+        "/api/v1/documents/<id>/relations",
+        "/api/v1/documents/relation-presets",
+        "/api/v1/documents/reorder",
+        "/api/v1/documents/tags",
         "/api/v1/main-plans/<id>/sub-plans",
+        "/api/v1/main-plans/<id>/sub-plans/bind",
+        "/api/v1/main-plans/<id>/sub-plans/<subPlanId>",
         "/api/v1/attachments",
-            "/api/v1/attachments/<id>",
-            "/api/v1/attachments/batch-import",
+        "/api/v1/attachments/<id>",
+        "/api/v1/attachments/batch-import",
         "/api/v1/attachments/<id>/main-plans",
         "/api/v1/main-plans/<id>/attachments",
         "/api/v1/main-plans/<id>/attachments/bind",
+        "/api/v1/main-plans/<id>/attachments/reorder",
         "/api/v1/main-plans/<id>/attachments/<attachmentId>",
         "/api/v1/documents/<id>/permissions",
+        "/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>",
         "/api/v1/audit/logs",
         "/api/v1/statistics/documents",
         "/api/v1/audit/statistics/trend",

+ 66 - 0
backend/tests/dms/test_login_background.py

@@ -0,0 +1,66 @@
+"""登录界面自定义背景图接口(/api/v1/config/login-background)测试。
+
+接口公开(无凭证可访问):
+- 运行目录存在 background.jpg / background.png 时返回对应图片二进制;
+- 两者同时存在时 jpg 优先;
+- 均不存在时返回 404 FILE_NOT_FOUND 标准错误体。
+"""
+
+from __future__ import annotations
+
+import pytest
+
+
+# 最小合法 PNG 文件头(IHDR 数据块),仅用于让 send_file 返回二进制
+_PNG_BYTES = (
+    b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
+    b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
+)
+_JPEG_BYTES = b"\xff\xd8\xff\xe0\x00\x10JFIF"
+
+
+@pytest.fixture()
+def background_dir(flask_app, tmp_path):
+    """把背景图查找目录指到临时目录,测试后还原。"""
+    original = flask_app.config.get("DMS_LOGIN_BACKGROUND_DIR")
+    flask_app.config["DMS_LOGIN_BACKGROUND_DIR"] = str(tmp_path)
+    yield tmp_path
+    if original is None:
+        flask_app.config.pop("DMS_LOGIN_BACKGROUND_DIR", None)
+    else:
+        flask_app.config["DMS_LOGIN_BACKGROUND_DIR"] = original
+
+
+def test_login_background_returns_jpg_when_present(client, background_dir):
+    (background_dir / "background.jpg").write_bytes(_JPEG_BYTES)
+    response = client.get("/api/v1/config/login-background")
+    assert response.status_code == 200
+    assert response.mimetype == "image/jpeg"
+    assert response.data == _JPEG_BYTES
+    assert response.headers["X-Content-Type-Options"] == "nosniff"
+    assert "max-age" in response.headers.get("Cache-Control", "")
+
+
+def test_login_background_returns_png_when_only_png_present(client, background_dir):
+    (background_dir / "background.png").write_bytes(_PNG_BYTES)
+    response = client.get("/api/v1/config/login-background")
+    assert response.status_code == 200
+    assert response.mimetype == "image/png"
+    assert response.data == _PNG_BYTES
+
+
+def test_login_background_prefers_jpg_over_png(client, background_dir):
+    (background_dir / "background.png").write_bytes(_PNG_BYTES)
+    (background_dir / "background.jpg").write_bytes(_JPEG_BYTES)
+    response = client.get("/api/v1/config/login-background")
+    assert response.status_code == 200
+    assert response.mimetype == "image/jpeg"
+    assert response.data == _JPEG_BYTES
+
+
+def test_login_background_returns_404_when_absent(client, background_dir):
+    response = client.get("/api/v1/config/login-background")
+    assert response.status_code == 404
+    body = response.get_json()
+    assert body["code"] == "FILE_NOT_FOUND"
+    assert body["requestId"]

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

@@ -44,6 +44,15 @@ LOGIN_IP_REVISION_PATH = (
 PORTAL_USER_PROVISION_REVISION_PATH = (
     MIGRATIONS_ROOT / "versions" / "0016_portal_user_provision.py"
 )
+TYPE_DISPLAY_NAME_REVISION_PATH = (
+    MIGRATIONS_ROOT / "versions" / "0017_category_type_display_name.py"
+)
+SUB_PLAN_BINDING_REVISION_PATH = (
+    MIGRATIONS_ROOT / "versions" / "0018_sub_plan_binding.py"
+)
+CONTENT_EXTRACT_ERROR_REVISION_PATH = (
+    MIGRATIONS_ROOT / "versions" / "0019_content_extract_error.py"
+)
 
 
 def _load_revision_module():
@@ -57,7 +66,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() == "0016_portal_user_provision"
+    assert script.get_current_head() == "0019_content_extract_error"
 
 
 def test_initial_migration_exports_upgrade_and_downgrade():
@@ -282,3 +291,54 @@ def test_portal_user_provision_migration_is_scoped_and_reversible():
     assert "external_id IS NOT NULL" in source
     assert "RuntimeError" in source
     assert "create_table" not in source
+
+
+def test_category_type_display_name_migration_is_scoped_and_idempotent():
+    source = TYPE_DISPLAY_NAME_REVISION_PATH.read_text(encoding="utf-8")
+    assert (
+        'down_revision: str | None = "0016_portal_user_provision"' in source
+    )
+    assert '_COLUMN = "type_display_name"' in source
+    assert "sa.String(length=128)" in source
+    # 幂等:按现存列集合判断,重复执行不重复加列
+    assert "_existing_columns" in source
+    assert "drop_column" in source
+    assert "create_table" not in source
+
+
+def test_sub_plan_binding_migration_is_scoped_and_reversible():
+    source = SUB_PLAN_BINDING_REVISION_PATH.read_text(encoding="utf-8")
+    assert (
+        'down_revision: str | None = "0017_category_type_display_name"'
+        in source
+    )
+    assert '"doc_sub_plan_binding"' in source
+    assert "uq_doc_sub_plan_binding_active" in source
+    assert "IF(is_deleted = 0, 1, NULL)" in source
+    assert "ix_doc_sub_plan_binding_main_active_sort" in source
+    assert "ix_doc_sub_plan_binding_sub_active" in source
+    assert source.count('ondelete="RESTRICT"') == 2
+    # 审计复用 BIND/UNBIND_ATTACHMENT,不扩展 sys_audit_log 的 CHECK 约束
+    assert "audit_action_type" not in source
+    # 降级保护:已产生挂载数据时拒绝有损降级
+    assert "RuntimeError" in source
+    assert source.count("op.create_table") == 1
+    assert "add_column" not in source
+
+
+def test_content_extract_error_migration_is_scoped_and_idempotent():
+    source = CONTENT_EXTRACT_ERROR_REVISION_PATH.read_text(encoding="utf-8")
+    assert (
+        'down_revision: str | None = "0018_sub_plan_binding"' in source
+    )
+    assert '_TABLE = "doc_document"' in source
+    assert '_COLUMN = "content_extract_error"' in source
+    assert "sa.String(512)" in source
+    # 幂等:按现存列集合判断,重复执行不重复加列
+    assert "_existing_columns" in source
+    assert "add_column" in source
+    assert "drop_column" in source
+    assert "create_table" not in source
+    # 降级保护:已产生失败记录时拒绝有损降级
+    assert "RuntimeError" in source
+    assert "IS NOT NULL" in source

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

@@ -23,6 +23,7 @@ EXPECTED_TABLES = {
     "doc_document",
     "doc_attachment_binding",
     "doc_document_relation",
+    "doc_sub_plan_binding",
     "doc_permission",
     "sys_audit_log",
     "sys_role",
@@ -46,7 +47,7 @@ COMMON_COLUMNS = {
 
 def test_expected_models_are_registered():
     assert set(db.metadata.tables) == EXPECTED_TABLES
-    assert len(db.metadata.tables) == 13
+    assert len(db.metadata.tables) == 14
 
 
 def test_business_tables_have_common_columns():
@@ -86,6 +87,7 @@ def test_active_marker_columns_are_virtual_generated_columns():
         "sys_ip_entry",
         "sys_org_ip_assignment",
         "sys_user",
+        "doc_sub_plan_binding",
     ):
         column = db.metadata.tables[table_name].c.active_marker
         assert isinstance(column.computed, Computed)
@@ -151,6 +153,22 @@ def test_ip_tables_unique_constraints():
     ) in _unique_column_sets("sys_org_ip_assignment")
 
 
+def test_sub_plan_binding_unique_constraint_and_indexes():
+    assert (
+        "main_document_id",
+        "sub_document_id",
+        "active_marker",
+    ) in _unique_column_sets("doc_sub_plan_binding")
+    names = {
+        index.name
+        for index in db.metadata.tables["doc_sub_plan_binding"].indexes
+    }
+    assert {
+        "ix_doc_sub_plan_binding_main_active_sort",
+        "ix_doc_sub_plan_binding_sub_active",
+    } <= names
+
+
 def test_portal_user_external_id_unique_constraint():
     assert ("external_id", "active_marker") in _unique_column_sets("sys_user")
 

+ 7 - 1
backend/tests/dms/test_mysql_integration.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import os
+from pathlib import Path
 from urllib.parse import urlparse
 
 import pytest
@@ -8,6 +9,9 @@ from alembic import command
 from alembic.config import Config
 from sqlalchemy import create_engine, text
 
+# alembic配置按测试文件位置解析,不依赖 pytest 启动目录。
+ALEMBIC_INI = Path(__file__).resolve().parents[2] / "migrations" / "alembic.ini"
+
 
 @pytest.mark.integration
 def test_mysql_q3_upgrade_downgrade_upgrade_when_test_database_is_configured(
@@ -24,7 +28,7 @@ def test_mysql_q3_upgrade_downgrade_upgrade_when_test_database_is_configured(
         pytest.fail("为避免误操作,MySQL测试库名称必须以_test结尾")
 
     monkeypatch.setenv("DMS_DATABASE_URL", database_url)
-    config = Config("migrations/alembic.ini")
+    config = Config(str(ALEMBIC_INI))
     command.upgrade(config, "head")
     # 仅清理独立测试库中的B8测试审计;迁移自身不修改历史审计。
     engine = create_engine(database_url)
@@ -36,10 +40,12 @@ def test_mysql_q3_upgrade_downgrade_upgrade_when_test_database_is_configured(
             )
         )
         # 0003明确阻止正文结果的有损降级;测试库先回到可安全降级状态。
+        # 0019同样阻止已有提取失败记录的降级,一并清空。
         connection.execute(
             text(
                 "UPDATE doc_document SET content_text = NULL, "
                 "content_extract_status = 'UNSUPPORTED', "
+                "content_extract_error = NULL, "
                 "content_extracted_at = NULL, "
                 "search_text = CONCAT_WS(' ', document_name, summary)"
             )

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

@@ -86,6 +86,8 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/health",
         "/api/v1/auth/login",
         "/api/v1/auth/logout",
+        "/api/v1/auth/portal/verify",
+        "/api/v1/auth/portal/setup-password",
         "/api/v1/users/me",
         "/api/v1/organizations/tree",
         "/api/v1/organizations",
@@ -103,6 +105,7 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/roles/{id}/users",
         "/api/v1/config/ui-dictionaries",
         "/api/v1/config/runtime",
+        "/api/v1/config/login-background",
         "/api/v1/categories/tree",
         "/api/v1/categories",
         "/api/v1/categories/reorder",
@@ -113,8 +116,11 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/documents/{id}",
         "/api/v1/documents/batch-import",
         "/api/v1/documents/{id}/preview",
+        "/api/v1/documents/{id}/re-extract",
         "/api/v1/documents/{id}/download",
         "/api/v1/main-plans/{id}/sub-plans",
+        "/api/v1/main-plans/{id}/sub-plans/bind",
+        "/api/v1/main-plans/{id}/sub-plans/{subPlanId}",
         "/api/v1/attachments",
         "/api/v1/attachments/{id}",
         "/api/v1/attachments/batch-import",
@@ -136,7 +142,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.10.0"
+    assert document["info"]["version"] == "1.13.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.10.0"
+    assert document["info"]["version"] == "1.13.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.10.0"
+    assert document["info"]["version"] == "1.13.0"
     for operation in ("Documents", "SubPlans", "MainPlanAttachments"):
         references = {
             parameter.get("$ref")

+ 16 - 7
backend/tests/dms/test_q3b_business_content_preview.py

@@ -217,7 +217,7 @@ def test_leaf_rules_create_defaults_and_sub_plan_category_sync(
         assert persisted_sub.category_name == moved.get_json()["data"]["categoryName"]
 
 
-def test_include_descendants_and_permission_aware_subtree_count(
+def test_include_descendants_and_full_subtree_count(
     b3_app, b3_client, token_for
 ):
     from dms.extensions import db
@@ -261,7 +261,8 @@ def test_include_descendants_and_permission_aware_subtree_count(
         "/api/v1/categories/tree", headers=_auth(user_token)
     ).get_json()["data"]
     assert _tree_node(admin_tree, root_id)["documentCount"] == 1
-    assert _tree_node(user_tree, root_id)["documentCount"] == 0
+    # 计数口径为子树全部主体方案,不按密级/权限过滤,普通用户看到相同数字。
+    assert _tree_node(user_tree, root_id)["documentCount"] == 1
     with b3_app.app_context():
         user = db.session.scalar(db.select(User).where(User.username == "user"))
         audit_before = db.session.scalar(
@@ -391,7 +392,7 @@ def test_docx_pdf_content_search_and_original_preview(
     assert after_audit == before_audit
 
 
-def test_attachment_library_is_admin_only_and_user_cannot_guess_unmounted_id(
+def test_attachment_library_list_open_but_content_gated_for_user(
     b3_app, b3_client, token_for
 ):
     admin_token = token_for("admin")
@@ -414,9 +415,17 @@ def test_attachment_library_is_admin_only_and_user_cannot_guess_unmounted_id(
         content_type="multipart/form-data",
     )
     attachment = response.get_json()["data"]
-    assert b3_client.get(
-        "/api/v1/attachments", headers=_auth(user_token)
-    ).status_code == 403
+    # 列表全量可见:普通用户可通过标题检索到全部附件,但 allowedActions 为空,
+    # 详细说明与正文片段不返回;详情/预览仍按挂载主案权限拦截。
+    listing = b3_client.get(
+        "/api/v1/attachments?keyword=Q3未挂载", headers=_auth(user_token)
+    )
+    assert listing.status_code == 200
+    listed = listing.get_json()["data"]["items"]
+    assert [item["id"] for item in listed] == [attachment["id"]]
+    assert listed[0]["allowedActions"] == []
+    assert listed[0]["description"] is None
+    assert "contentSnippet" not in listed[0]
     assert b3_client.get(
         f"/api/v1/attachments/{attachment['id']}", headers=_auth(user_token)
     ).status_code == 403
@@ -452,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.10.0"
+    assert document["info"]["version"] == "1.13.0"
     assert schemas["RoleCode"]["enum"] == ["USER", "ADMIN"]
     parameters = paths["Documents"]["get"]["parameters"]
     assert any(item.get("name") == "includeDescendants" for item in parameters)

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

@@ -94,7 +94,7 @@ def test_documents_tag_filter_all_semantics(b4_client, token_for):
     )
     assert response.status_code == 200
     items = data(response)["items"]
-    assert items and {item["tags"] for item in items} == {["B4", "测试"]}
+    assert items and {tuple(item["tags"]) for item in items} == {("B4", "测试")}
 
 
 def test_documents_tag_filter_all_with_missing_excludes(

+ 221 - 1
backend/tests/dms/test_role_authorization.py

@@ -13,13 +13,19 @@ from __future__ import annotations
 
 import pytest
 
-from dms.common.enums import RoleCode, SecurityLevel
+from dms.common.enums import AllowedAction, RoleCode, SecurityLevel
+from dms.common.errors import (
+    DocumentActionForbiddenError,
+    SecurityLevelForbiddenError,
+)
 from dms.extensions import db
 from dms.models import Document, Organization, Role, RolePermission, User
 from dms.security.auth_context import AuthContext
 from dms.services.authorization_service import (
     evaluate_plan_access,
+    has_any_manage_action,
     plan_allowed_actions,
+    require_plan_action,
 )
 
 
@@ -271,3 +277,217 @@ def test_matrix_actions_expand_allowed_actions(matrix_env):
         assert "BIND_ATTACHMENT" not in sub_actions
         assert "UNBIND_ATTACHMENT" not in sub_actions
         assert {"VIEW", "DOWNLOAD", "EDIT", "DELETE"} <= set(sub_actions)
+
+
+# ===== 动态管理菜单(allowedModules)判定 =====================================
+
+from dms.services.user_service import allowed_modules_for_user
+
+
+@pytest.mark.integration
+def test_admin_role_always_has_manage_capability(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        admin = db.session.scalar(
+            db.select(User).where(User.username == "admin")
+        )
+        context = _user_context(admin)
+        assert has_any_manage_action(context) is True
+        assert allowed_modules_for_user(admin) == [
+            "DOCUMENT_BROWSER",
+            "BACKEND_MANAGEMENT",
+            "AUDIT_LOG",
+            "ORGANIZATION_USER_MANAGEMENT",
+        ]
+
+
+@pytest.mark.integration
+def test_user_without_manage_bits_gets_browser_only(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        ops = db.session.get(Organization, ids["ops"])
+        user = db.session.get(User, ids["user"])
+        role = _make_role("只读角色", is_default=True, actor_id=user.id)
+        # 矩阵只有查看/下载位,无任何管理位
+        for scope in ("OWN", "ASSIGNED", "OTHER"):
+            _matrix_row(
+                role.id, "SECRET", scope, can_view=True, can_download=True
+            )
+        _make_document("只读可看主案", org=ops, visibility="ALL_AUTHENTICATED")
+        db.session.commit()
+
+        assert has_any_manage_action(_user_context(user)) is False
+        assert allowed_modules_for_user(user) == ["DOCUMENT_BROWSER"]
+
+
+@pytest.mark.integration
+def test_user_with_manage_bit_and_matching_document_gets_admin_menu(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        ops = db.session.get(Organization, ids["ops"])
+        user = db.session.get(User, ids["user"])
+        role = _make_role("编辑角色", is_default=True, actor_id=user.id)
+        # OWN × SECRET 勾选编辑:作战部存在已发布 SECRET 主案即命中
+        _matrix_row(
+            role.id, "SECRET", "OWN", can_view=True, can_edit=True
+        )
+        _make_document("本部门可编辑主案", org=ops)
+        db.session.commit()
+
+        assert has_any_manage_action(_user_context(user)) is True
+        assert allowed_modules_for_user(user) == [
+            "DOCUMENT_BROWSER",
+            "BACKEND_MANAGEMENT",
+        ]
+
+
+@pytest.mark.integration
+def test_manage_bit_without_matching_document_or_view_denies(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        ops = db.session.get(Organization, ids["ops"])
+        user = db.session.get(User, ids["user"])
+        role = _make_role("空转编辑角色", is_default=True, actor_id=user.id)
+        # 只有 OTHER × TOP_SECRET 勾编辑,但文档在本部门且密级只有 SECRET:
+        # 部门范围不命中 → 不出管理菜单
+        _matrix_row(
+            role.id, "TOP_SECRET", "OTHER", can_view=True, can_edit=True
+        )
+        _matrix_row(role.id, "SECRET", "OWN", can_view=True, can_edit=True)
+        _make_document("本部门SECRET主案", org=ops)
+        db.session.commit()
+
+        # 修正:OWN×SECRET 也勾了编辑且文档命中,应当可见 —— 先验证 OTHER 行不误伤
+        assert has_any_manage_action(_user_context(user)) is True
+
+        # 去掉 OWN 行的编辑位后只剩 OTHER 行:不再命中
+        row = db.session.scalar(
+            db.select(RolePermission).where(
+                RolePermission.role_id == role.id,
+                RolePermission.dept_scope == "OWN",
+            )
+        )
+        row.can_edit = False
+        db.session.commit()
+        assert has_any_manage_action(_user_context(user)) is False
+
+
+@pytest.mark.integration
+def test_manage_bit_without_view_source_denies(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        ops = db.session.get(Organization, ids["ops"])
+        user = db.session.get(User, ids["user"])
+        role = _make_role("无查看编辑角色", is_default=True, actor_id=user.id)
+        # 编辑位开启但查看位全关且文档非全员可见:access 不成立 → 管理位无效
+        _matrix_row(role.id, "SECRET", "OWN", can_edit=True)
+        _make_document("ORGANIZATION可见主案", org=ops, visibility="ORGANIZATION")
+        db.session.commit()
+
+        assert has_any_manage_action(_user_context(user)) is False
+
+
+# ===== 写路径集中鉴权 require_plan_action ====================================
+
+from dms.common.errors import (
+    DocumentActionForbiddenError,
+    SecurityLevelForbiddenError,
+)
+
+
+@pytest.mark.integration
+def test_require_plan_action_allows_matrix_edit(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        ops = db.session.get(Organization, ids["ops"])
+        user = db.session.get(User, ids["user"])
+        role = _make_role("写路径编辑角色", is_default=True, actor_id=user.id)
+        _matrix_row(role.id, "SECRET", "OWN", can_view=True, can_edit=True)
+        doc = _make_document("写路径可编辑主案", org=ops)
+        db.session.commit()
+
+        require_plan_action(doc, _user_context(user), AllowedAction.EDIT)
+
+
+@pytest.mark.integration
+def test_require_plan_action_denies_without_bit(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        ops = db.session.get(Organization, ids["ops"])
+        user = db.session.get(User, ids["user"])
+        role = _make_role("写路径只读角色", is_default=True, actor_id=user.id)
+        _matrix_row(role.id, "SECRET", "OWN", can_view=True, can_download=True)
+        doc = _make_document("写路径只读主案", org=ops)
+        db.session.commit()
+
+        with pytest.raises(DocumentActionForbiddenError):
+            require_plan_action(doc, _user_context(user), AllowedAction.EDIT)
+        with pytest.raises(DocumentActionForbiddenError):
+            require_plan_action(doc, _user_context(user), AllowedAction.DELETE)
+        with pytest.raises(DocumentActionForbiddenError):
+            require_plan_action(
+                doc, _user_context(user), AllowedAction.CONFIG_PERMISSION
+            )
+
+
+@pytest.mark.integration
+def test_require_plan_action_security_gate_precedes_bits(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        ops = db.session.get(Organization, ids["ops"])
+        user = db.session.get(User, ids["user"])
+        role = _make_role("写路径高密角色", is_default=True, actor_id=user.id)
+        _matrix_row(role.id, "TOP_SECRET", "OWN", can_view=True, can_edit=True)
+        # 用户密级 SECRET:即使矩阵在 TOP_SECRET 行勾了编辑,密级前置直接拒绝
+        doc = _make_document("绝密主案", org=ops, security="TOP_SECRET")
+        db.session.commit()
+
+        with pytest.raises(SecurityLevelForbiddenError):
+            require_plan_action(doc, _user_context(user), AllowedAction.EDIT)
+
+
+@pytest.mark.integration
+def test_require_plan_action_admin_passes_all(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        ops = db.session.get(Organization, ids["ops"])
+        admin = db.session.scalar(
+            db.select(User).where(User.username == "admin")
+        )
+        doc = _make_document("管理员主案", org=ops, security="SECRET")
+        db.session.commit()
+
+        for action in (
+            AllowedAction.EDIT,
+            AllowedAction.DELETE,
+            AllowedAction.CONFIG_PERMISSION,
+            AllowedAction.BIND_ATTACHMENT,
+            AllowedAction.UNBIND_ATTACHMENT,
+        ):
+            require_plan_action(doc, _user_context(admin), action)
+
+
+@pytest.mark.integration
+def test_require_plan_action_attachment_admin_only(matrix_env):
+    app, ids = matrix_env
+    with app.app_context():
+        ops = db.session.get(Organization, ids["ops"])
+        user = db.session.get(User, ids["user"])
+        admin = db.session.scalar(
+            db.select(User).where(User.username == "admin")
+        )
+        role = _make_role("附件全权角色", is_default=True, actor_id=user.id)
+        for scope in ("OWN", "ASSIGNED", "OTHER"):
+            _matrix_row(
+                role.id, "PUBLIC", scope,
+                can_view=True, can_download=True, can_edit=True, can_delete=True,
+            )
+        att = _make_document(
+            "共享附件", org=ops, security="PUBLIC",
+            visibility="ALL_AUTHENTICATED", document_type="ATTACHMENT",
+        )
+        db.session.commit()
+
+        with pytest.raises(DocumentActionForbiddenError):
+            require_plan_action(att, _user_context(user), AllowedAction.EDIT)
+        require_plan_action(att, _user_context(admin), AllowedAction.DELETE)

File diff suppressed because it is too large
+ 444 - 127
frontend/src/app/App.tsx


+ 21 - 5
frontend/src/features/audit/AuditDashboard.tsx

@@ -25,6 +25,7 @@ import {
 } from "recharts";
 import { AuditOperationResult } from "../../shared/api/enums";
 import { useDictionaries } from "../dictionaries/dictionaryContext";
+import { tipProps } from "../../shared/ui/hoverTip";
 import {
   formatAuditDate,
   toUtcIso,
@@ -446,14 +447,28 @@ export function AuditDashboard() {
                   </tr>
                 </thead>
                 <tbody>
-                  {logs.data.items.map((log, index) => (
+                  {logs.data.items.map((log, index) =>
+                    "invalid" in log ? (
+                      <tr
+                        key={`invalid-${index}`}
+                        className="border-b"
+                        style={{
+                          borderColor: "rgba(183,28,28,.25)",
+                          background: "rgba(183,28,28,.06)",
+                          color: "#B71C1C",
+                        }}
+                      >
+                        <td colSpan={8} className="px-3 py-2 text-xs font-bold">
+                          该行数据不符合DMS接口契约,无法展示
+                        </td>
+                      </tr>
+                    ) : (
                     <tr
                       key={log.id}
-                      className="border-b"
+                      className={`border-b transition-colors hover:bg-[rgba(49,145,235,0.18)] ${index % 2 ? "bg-[rgba(40,118,193,.04)]" : "bg-white"}`}
                       style={{
                         color: NAVY,
                         borderColor: "rgba(40,118,193,.1)",
-                        background: index % 2 ? "rgba(40,118,193,.04)" : WHITE,
                       }}
                     >
                       <td className="px-3 py-2 text-xs font-bold whitespace-nowrap">
@@ -503,7 +518,7 @@ export function AuditDashboard() {
                         {log.requestId ?? EMPTY}
                         {log.requestId && (
                           <button
-                            title="复制requestId"
+                            {...tipProps("复制requestId")}
                             onClick={() => void copyRequestId(log.requestId!)}
                             className="ml-2"
                             style={{ color: BLUE }}
@@ -513,7 +528,8 @@ export function AuditDashboard() {
                         )}
                       </td>
                     </tr>
-                  ))}
+                    )
+                  )}
                 </tbody>
               </table>
             </div>

+ 88 - 0
frontend/src/features/audit/__tests__/auditApi.test.ts

@@ -159,5 +159,93 @@ describe("auditApi", () => {
       signal: undefined,
     });
   });
+
+  it("个别日志行不符合契约时原位标记错误行,不拖垮整页", async () => {
+    const broken = { ...log, actionType: "FUTURE_UNKNOWN_ACTION" };
+    const client = {
+      get: vi.fn().mockResolvedValue(
+        result({ ...page, items: [log, broken], total: 2 }),
+      ),
+    };
+    const data = await createAuditApi(client).logs({});
+    expect(data.items).toHaveLength(2);
+    expect(data.items[0]).toEqual(log);
+    expect(data.items[1]).toEqual({ invalid: true, raw: broken });
+    expect(data.total).toBe(2);
+  });
+
+  it("分页骨架不符合契约时仍整页报错", async () => {
+    const client = {
+      get: vi.fn().mockResolvedValue(result({ items: "not-an-array" })),
+    };
+    await expect(createAuditApi(client).logs({})).rejects.toThrow(
+      "审计日志响应不符合DMS接口契约",
+    );
+  });
+
+  it("动作分布过滤不符合契约的统计项,保留有效项", async () => {
+    const items = [
+      { actionType: "FUTURE_UNKNOWN_ACTION", count: 9 },
+      { actionType: "LOGIN", count: 3 },
+    ];
+    const client = {
+      get: vi.fn().mockResolvedValue(result({ items })),
+    };
+    await expect(createAuditApi(client).actions({})).resolves.toEqual([
+      { actionType: "LOGIN", count: 3 },
+    ]);
+  });
+
+  it("趋势与活跃用户过滤不符合契约的统计项,保留有效项", async () => {
+    const client = {
+      get: vi
+        .fn()
+        .mockResolvedValueOnce(
+          result({
+            items: [
+              { period: "2026-07-21", operationCount: -1 },
+              { period: "2026-07-22", operationCount: 2 },
+            ],
+          }),
+        )
+        .mockResolvedValueOnce(
+          result({
+            items: [
+              { userId: "abc", realName: null, organizationName: null, operationCount: 1 },
+              { userId: "3", realName: null, organizationName: null, operationCount: 1 },
+            ],
+          }),
+        ),
+    };
+    const api = createAuditApi(client);
+    await expect(api.trend({ granularity: "DAY" })).resolves.toEqual([
+      { period: "2026-07-22", operationCount: 2 },
+    ]);
+    await expect(api.users({})).resolves.toEqual([
+      { userId: "3", realName: null, organizationName: null, operationCount: 1 },
+    ]);
+  });
+
+  it("接受后端新增的审计动作与目标类型枚举值", async () => {
+    const newActionLog = {
+      ...log,
+      actionType: "ASSIGN_ORG_IP",
+      targetType: "IP_ENTRY",
+    };
+    const client = {
+      get: vi
+        .fn()
+        .mockResolvedValueOnce(result({ ...page, items: [newActionLog] }))
+        .mockResolvedValueOnce(
+          result({ items: [{ actionType: "REORDER_DOCUMENT", count: 2 }] }),
+        ),
+    };
+    const api = createAuditApi(client);
+    const logsData = await api.logs({});
+    expect(logsData.items[0]).toEqual(newActionLog);
+    await expect(api.actions({})).resolves.toEqual([
+      { actionType: "REORDER_DOCUMENT", count: 2 },
+    ]);
+  });
 });
 

+ 2 - 1
frontend/src/features/audit/__tests__/f7Scope.test.ts

@@ -37,8 +37,9 @@ describe("F7 App and request scope", () => {
     const rootStart = appSource.indexOf("export default function App");
     const adminSource = appSource.slice(adminStart, rootStart);
     expect(adminSource).toContain("<DocumentStatisticsCards />");
+    // 管理页按模块权限动态可见(管理员或对任一文档有管理权限的用户)
     expect(appSource).toContain(
-      'page === "admin" && currentUser.roleCode === RoleCode.ADMIN',
+      'page === "admin" && isPageAllowed("admin", currentUser.allowedModules)',
     );
     expect(auditApiSource).not.toContain("statistics/documents");
   });

+ 21 - 14
frontend/src/features/audit/auditApi.ts

@@ -2,13 +2,16 @@ import { HttpError } from "../../shared/api/errors";
 import { httpClient, type RequestOptions } from "../../shared/api/httpClient";
 import type { JsonResult, PageResult } from "../../shared/api/types";
 import {
-  isAuditActionStatisticsData,
-  isAuditLogPage,
-  isAuditTrendData,
-  isAuditUserStatisticsData,
+  filterItems,
+  isAuditActionStatisticsItem,
+  isAuditItemsEnvelope,
+  isAuditLogPageMeta,
+  isAuditTrendItem,
+  isAuditUserStatisticsItem,
+  toAuditLogRow,
   type AuditActionStatisticsItem,
   type AuditLogQuery,
-  type AuditLogSummary,
+  type AuditLogRow,
   type AuditTimeRange,
   type AuditTrendItem,
   type AuditTrendQuery,
@@ -35,15 +38,19 @@ export function createAuditApi(client: AuditHttpClient = httpClient) {
     async logs(
       query: AuditLogQuery,
       signal?: AbortSignal,
-    ): Promise<PageResult<AuditLogSummary>> {
+    ): Promise<PageResult<AuditLogRow>> {
       const result = await client.get<unknown>("audit/logs", {
         query: { ...query },
         signal,
       });
-      if (!isAuditLogPage(result.data)) {
+      // 骨架(分页字段)不符才整页报错;行级不符由 toAuditLogRow 原位标记
+      if (!isAuditLogPageMeta(result.data)) {
         throw contractError(result, "审计日志");
       }
-      return result.data;
+      return {
+        ...result.data,
+        items: result.data.items.map(toAuditLogRow),
+      };
     },
 
     async trend(
@@ -54,10 +61,10 @@ export function createAuditApi(client: AuditHttpClient = httpClient) {
         query: { ...query },
         signal,
       });
-      if (!isAuditTrendData(result.data)) {
+      if (!isAuditItemsEnvelope(result.data)) {
         throw contractError(result, "审计趋势");
       }
-      return result.data.items;
+      return filterItems(result.data.items, isAuditTrendItem);
     },
 
     async actions(
@@ -68,10 +75,10 @@ export function createAuditApi(client: AuditHttpClient = httpClient) {
         query: { ...query },
         signal,
       });
-      if (!isAuditActionStatisticsData(result.data)) {
+      if (!isAuditItemsEnvelope(result.data)) {
         throw contractError(result, "动作分布");
       }
-      return result.data.items;
+      return filterItems(result.data.items, isAuditActionStatisticsItem);
     },
 
     async users(
@@ -82,10 +89,10 @@ export function createAuditApi(client: AuditHttpClient = httpClient) {
         query: { ...query },
         signal,
       });
-      if (!isAuditUserStatisticsData(result.data)) {
+      if (!isAuditItemsEnvelope(result.data)) {
         throw contractError(result, "活跃用户");
       }
-      return result.data.items;
+      return filterItems(result.data.items, isAuditUserStatisticsItem);
     },
   };
 }

+ 2 - 2
frontend/src/features/audit/auditState.ts

@@ -5,7 +5,7 @@ import { auditApi, type AuditApi } from "./auditApi";
 import type {
   AuditActionStatisticsItem,
   AuditLogQuery,
-  AuditLogSummary,
+  AuditLogRow,
   AuditTimeRange,
   AuditTrendItem,
   AuditTrendQuery,
@@ -37,7 +37,7 @@ export function useAuditLogs(
   api: AuditApi = auditApi,
   debounceMs = 300,
 ) {
-  const [state, setState] = useState<RemoteState<PageResult<AuditLogSummary>>>({
+  const [state, setState] = useState<RemoteState<PageResult<AuditLogRow>>>({
     data: null,
     loading: true,
     error: null,

+ 85 - 41
frontend/src/features/audit/auditTypes.ts

@@ -77,6 +77,14 @@ export interface AuditUserStatisticsItem {
   operationCount: number;
 }
 
+/** 不符合契约的日志行占位:保留原位并在界面标记为错误行。 */
+export interface InvalidAuditLogRow {
+  invalid: true;
+  raw: unknown;
+}
+
+export type AuditLogRow = AuditLogSummary | InvalidAuditLogRow;
+
 type RecordValue = Record<string, unknown>;
 const ID_PATTERN = /^[0-9]+$/;
 const UTC_PATTERN =
@@ -165,6 +173,24 @@ export function isAuditLogSummary(
 export function isAuditLogPage(
   value: unknown,
 ): value is PageResult<AuditLogSummary> {
+  if (!isAuditLogPageMeta(value)) {
+    return false;
+  }
+  return value.items.every(isAuditLogSummary);
+}
+
+/**
+ * 日志分页骨架校验:键集合、分页字段、items 为数组。
+ * 不逐行校验——行级契约由 toAuditLogRow 逐行判定,
+ * 避免个别脏数据行拖垮整页。
+ */
+export function isAuditLogPageMeta(value: unknown): value is {
+  items: unknown[];
+  page: number;
+  pageSize: number;
+  total: number;
+  totalPages: number;
+} {
   if (
     !isRecord(value) ||
     !hasExactKeys(value, [
@@ -179,7 +205,6 @@ export function isAuditLogPage(
   }
   return (
     Array.isArray(value.items) &&
-    value.items.every(isAuditLogSummary) &&
     isNonNegativeInteger(value.page) &&
     value.page >= 1 &&
     isNonNegativeInteger(value.pageSize) &&
@@ -190,58 +215,77 @@ export function isAuditLogPage(
   );
 }
 
-export function isAuditTrendData(
+/** 逐行转换:符合契约原样返回,否则包装为原位错误行。 */
+export function toAuditLogRow(value: unknown): AuditLogRow {
+  return isAuditLogSummary(value) ? value : { invalid: true, raw: value };
+}
+
+export function isAuditTrendItem(value: unknown): value is AuditTrendItem {
+  return (
+    isRecord(value) &&
+    hasExactKeys(value, ["period", "operationCount"]) &&
+    typeof value.period === "string" &&
+    isNonNegativeInteger(value.operationCount)
+  );
+}
+
+export function isAuditActionStatisticsItem(
   value: unknown,
-): value is { items: AuditTrendItem[] } {
-  if (!isRecord(value) || !hasExactKeys(value, ["items"])) return false;
+): value is AuditActionStatisticsItem {
   return (
-    Array.isArray(value.items) &&
-    value.items.every(
-      (item) =>
-        isRecord(item) &&
-        hasExactKeys(item, ["period", "operationCount"]) &&
-        typeof item.period === "string" &&
-        isNonNegativeInteger(item.operationCount),
-    )
+    isRecord(value) &&
+    hasExactKeys(value, ["actionType", "count"]) &&
+    isEnum(AuditActionType, value.actionType) &&
+    isNonNegativeInteger(value.count)
   );
 }
 
-export function isAuditActionStatisticsData(
+export function isAuditUserStatisticsItem(
   value: unknown,
-): value is { items: AuditActionStatisticsItem[] } {
-  if (!isRecord(value) || !hasExactKeys(value, ["items"])) return false;
+): value is AuditUserStatisticsItem {
   return (
-    Array.isArray(value.items) &&
-    value.items.every(
-      (item) =>
-        isRecord(item) &&
-        hasExactKeys(item, ["actionType", "count"]) &&
-        isEnum(AuditActionType, item.actionType) &&
-        isNonNegativeInteger(item.count),
-    )
+    isRecord(value) &&
+    hasExactKeys(value, [
+      "userId",
+      "realName",
+      "organizationName",
+      "operationCount",
+    ]) &&
+    isStringId(value.userId) &&
+    isNullableString(value.realName) &&
+    isNullableString(value.organizationName) &&
+    isNonNegativeInteger(value.operationCount)
   );
 }
 
+/** 统计响应骨架校验:仅要求 { items: 数组 },项级契约由过滤函数逐项判定。 */
+export function isAuditItemsEnvelope(value: unknown): value is { items: unknown[] } {
+  return isRecord(value) && hasExactKeys(value, ["items"]) && Array.isArray(value.items);
+}
+
+/** 逐项过滤:丢弃不符合契约的统计项,保留有效项渲染。 */
+export function filterItems<T>(items: unknown[], predicate: (value: unknown) => value is T): T[] {
+  return items.filter(predicate);
+}
+
+export function isAuditTrendData(
+  value: unknown,
+): value is { items: AuditTrendItem[] } {
+  if (!isAuditItemsEnvelope(value)) return false;
+  return value.items.every(isAuditTrendItem);
+}
+
+export function isAuditActionStatisticsData(
+  value: unknown,
+): value is { items: AuditActionStatisticsItem[] } {
+  if (!isAuditItemsEnvelope(value)) return false;
+  return value.items.every(isAuditActionStatisticsItem);
+}
+
 export function isAuditUserStatisticsData(
   value: unknown,
 ): value is { items: AuditUserStatisticsItem[] } {
-  if (!isRecord(value) || !hasExactKeys(value, ["items"])) return false;
-  return (
-    Array.isArray(value.items) &&
-    value.items.every(
-      (item) =>
-        isRecord(item) &&
-        hasExactKeys(item, [
-          "userId",
-          "realName",
-          "organizationName",
-          "operationCount",
-        ]) &&
-        isStringId(item.userId) &&
-        isNullableString(item.realName) &&
-        isNullableString(item.organizationName) &&
-        isNonNegativeInteger(item.operationCount),
-    )
-  );
+  if (!isAuditItemsEnvelope(value)) return false;
+  return value.items.every(isAuditUserStatisticsItem);
 }
 

+ 6 - 3
frontend/src/features/bindings/AttachmentBindingModal.tsx

@@ -24,6 +24,8 @@ const WHITE = "#FFFFFF";
 const PALE = "#EBF4FD";
 interface AttachmentBindingModalProps {
   mainPlan: DocumentSummary;
+  /** 附件类型筛选预置值(管理页附件 Tab 页"挂载"入口按当前页签类型预过滤)。 */
+  initialAttachmentType?: AttachmentTypeCode;
   onClose: () => void;
   onChanged: () => void;
 }
@@ -49,12 +51,13 @@ function asHttpError(error: unknown, message: string): HttpError {
 
 export function AttachmentBindingModal({
   mainPlan,
+  initialAttachmentType,
   onClose,
   onChanged,
 }: AttachmentBindingModalProps) {
   const dictionaries = useDictionaries();
   const [keyword, setKeyword] = useState("");
-  const [attachmentType, setAttachmentType] = useState<AttachmentTypeCode | "">("");
+  const [attachmentType, setAttachmentType] = useState<AttachmentTypeCode | "">(initialAttachmentType ?? "");
   const [fileExtension, setFileExtension] = useState("");
   const [updatedFrom, setUpdatedFrom] = useState("");
   const [updatedTo, setUpdatedTo] = useState("");
@@ -178,7 +181,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="font-black">挂载共享资料</span>
           <span className="ml-3 text-xs opacity-75">主案:{mainPlan.documentName}</span>
           </div>
           <button onClick={onClose} disabled={submitting}><X size={18} /></button>
@@ -250,7 +253,7 @@ export function AttachmentBindingModal({
                   const alreadyMounted = mounted.ids.has(item.id);
                   const checked = selected.includes(item.id);
                   return (
-                    <tr key={item.id} className="border-b" style={{ borderColor: "rgba(49,145,235,.12)", color: NAVY }}>
+                    <tr key={item.id} className="border-b transition-colors hover:bg-[rgba(49,145,235,0.18)]" style={{ borderColor: "rgba(49,145,235,.12)", color: NAVY }}>
                       <td className="px-4 py-3">
                         <input
                           type="checkbox"

+ 249 - 0
frontend/src/features/bindings/SubPlanBindingModal.tsx

@@ -0,0 +1,249 @@
+import { useEffect, useRef, useState } from "react";
+import { AlertCircle, Check, RefreshCw, Search, X } from "lucide-react";
+import { HttpError } from "../../shared/api/errors";
+import { DocumentPasswordAction, DocumentType } from "../../shared/api/enums";
+import { notifyContentChanged } from "../../shared/state/contentRefresh";
+import { withDocumentPassword } from "../../shared/state/documentPassword";
+import { useDocumentList } from "../documents/documentState";
+import { documentApi } from "../documents/documentApi";
+import { invalidateDetail } from "../documents/documentState";
+import type { DocumentSummary } from "../documents/documentTypes";
+import { useDictionaries } from "../dictionaries/dictionaryContext";
+import { subPlanBindingApi } from "./subPlanBindingApi";
+
+const NAVY = "#1A5299";
+const BLUE = "#3191eb";
+const WHITE = "#FFFFFF";
+const PALE = "#EBF4FD";
+
+interface SubPlanBindingModalProps {
+  mainPlan: DocumentSummary;
+  onClose: () => void;
+  onChanged: () => void;
+}
+
+interface ExistingState {
+  ids: Set<string>;
+  loading: boolean;
+  error: HttpError | null;
+}
+
+function asHttpError(error: unknown, message: string): HttpError {
+  return error instanceof HttpError
+    ? error
+    : new HttpError({
+        httpStatus: 0,
+        code: "INTERNAL_ERROR",
+        message,
+        details: null,
+        requestId: "unknown",
+        cause: error,
+      });
+}
+
+/** 子案挂载弹窗:列出其他主案的子案,勾选挂载到当前主案(仅建立展示关系,权限继承原主案)。 */
+export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBindingModalProps) {
+  const dictionaries = useDictionaries();
+  const [keyword, setKeyword] = useState("");
+  const [page, setPage] = useState(1);
+  const [selected, setSelected] = useState<string[]>([]);
+  // 已展示在当前主案下的子案(自有+已挂载),不可重复挂载
+  const [existing, setExisting] = useState<ExistingState>({ ids: new Set(), loading: true, error: null });
+  const [existingRevision, setExistingRevision] = useState(0);
+  const [submitting, setSubmitting] = useState(false);
+  const [error, setError] = useState<HttpError | null>(null);
+  const [notice, setNotice] = useState("");
+  const requestVersion = useRef(0);
+
+  const candidates = useDocumentList({
+    documentType: DocumentType.SUB_PLAN,
+    keyword: keyword.trim() || undefined,
+    sortBy: "documentName",
+    sortDirection: "asc",
+    page,
+    pageSize: 10,
+  });
+
+  // 全量拉取当前主案子案列表:自有子案与已挂载子案都不可再挂载
+  useEffect(() => {
+    const version = ++requestVersion.current;
+    const controller = new AbortController();
+    setExisting((current) => ({ ...current, loading: true, error: null }));
+    void (async () => {
+      const ids = new Set<string>();
+      let currentPage = 1;
+      let totalPages = 1;
+      do {
+        const result = await documentApi.subPlans(
+          mainPlan.id,
+          { page: currentPage, pageSize: 100, sortBy: "documentName", sortDirection: "asc" },
+          controller.signal,
+        );
+        result.items.forEach((item) => ids.add(item.id));
+        totalPages = result.totalPages;
+        currentPage += 1;
+      } while (currentPage <= totalPages);
+      if (!controller.signal.aborted && version === requestVersion.current) {
+        setExisting({ ids, loading: false, error: null });
+      }
+    })().catch((loadError: unknown) => {
+      if (!controller.signal.aborted && version === requestVersion.current) {
+        setExisting({
+          ids: new Set(),
+          loading: false,
+          error: asHttpError(loadError, "当前主案子案列表加载失败"),
+        });
+      }
+    });
+    return () => controller.abort();
+  }, [mainPlan.id, existingRevision]);
+
+  const selectedCount = selected.filter((id) => !existing.ids.has(id)).length;
+
+  const toggle = (id: string) => {
+    setSelected((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
+  };
+
+  const handleSubmit = async () => {
+    if (submitting) return;
+    const subPlanIds = Array.from(new Set(selected));
+    if (subPlanIds.length === 0) {
+      setNotice("请至少选择一个子案");
+      return;
+    }
+    setSubmitting(true);
+    setError(null);
+    setNotice("");
+    try {
+      const latest = await documentApi.detail(mainPlan.id);
+      const result = await withDocumentPassword(
+        mainPlan.id,
+        mainPlan.documentName,
+        DocumentPasswordAction.BIND_ATTACHMENT,
+        (password) => subPlanBindingApi.bind(mainPlan.id, {
+          subPlanIds,
+          mainPlanRowVersion: latest.rowVersion,
+        }, undefined, password),
+      );
+      setNotice(`挂载完成:新增 ${result.createdCount} 个,已在列表 ${result.existingCount} 个,当前子案共 ${result.subPlanCount} 个。`);
+      setSelected([]);
+      invalidateDetail("document", mainPlan.id);
+      notifyContentChanged("documents");
+      setExistingRevision((value) => value + 1);
+      onChanged();
+    } catch (submitError) {
+      const httpError = asHttpError(submitError, "挂载失败");
+      setError(httpError);
+      if (httpError.code === "DATA_VERSION_CONFLICT") {
+        setNotice("主案已被其他用户修改,已刷新最新挂载关系;请核对保留的选择后再次提交。");
+        invalidateDetail("document", mainPlan.id);
+        notifyContentChanged("documents");
+        setExistingRevision((value) => value + 1);
+        onChanged();
+      }
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  const loading = candidates.loading || existing.loading;
+  const loadError = candidates.error ?? existing.error;
+
+  return (
+    <div className="fixed inset-0 z-[80] flex items-center justify-center p-6" style={{ background: "rgba(26,82,153,.55)" }}>
+      <div className="w-[880px] max-w-full h-[80vh] flex flex-col shadow-2xl" style={{ background: WHITE }}>
+        <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>
+          </div>
+          <button onClick={onClose} disabled={submitting}><X size={18} /></button>
+        </div>
+
+        <div className="p-4 flex items-center gap-2 border-b" style={{ borderColor: "rgba(49,145,235,.2)", color: NAVY }}>
+          <label className="flex-1 flex items-center gap-2 border px-3 h-9">
+            <Search size={14} />
+            <input
+              value={keyword}
+              onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
+              placeholder="搜索子案名称"
+              className="w-full bg-transparent outline-none text-sm font-bold"
+            />
+          </label>
+        </div>
+
+        <div className="flex-1 overflow-auto">
+          {loading && <div className="p-10 text-center font-bold" style={{ color: NAVY }}><RefreshCw size={16} className="inline mr-2 animate-spin" />正在加载子案与挂载关系…</div>}
+          {loadError && !loading && (
+            <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>}
+            </div>
+          )}
+          {!loading && !loadError && candidates.data?.items.length === 0 && (
+            <div className="p-10 text-center text-sm font-bold opacity-55" style={{ color: NAVY }}>暂无符合条件的子案</div>
+          )}
+          {!loading && !loadError && candidates.data && candidates.data.items.length > 0 && (
+            <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>)}
+                </tr>
+              </thead>
+              <tbody>
+                {candidates.data.items.map((item) => {
+                  const alreadyListed = existing.ids.has(item.id);
+                  const checked = selected.includes(item.id);
+                  return (
+                    <tr key={item.id} className="border-b transition-colors hover:bg-[rgba(49,145,235,0.18)]" style={{ borderColor: "rgba(49,145,235,.12)", color: NAVY }}>
+                      <td className="px-4 py-3">
+                        <input
+                          type="checkbox"
+                          checked={checked && !alreadyListed}
+                          disabled={alreadyListed || submitting}
+                          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">
+                        {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>}
+                      </td>
+                    </tr>
+                  );
+                })}
+              </tbody>
+            </table>
+          )}
+        </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 }}>
+            <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>
+          </div>
+        )}
+
+        <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>}
+          <div className="flex items-center">
+            <span className="text-xs 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 }}>
+                {submitting ? "正在挂载…" : "确认挂载"}
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 147 - 0
frontend/src/features/bindings/subPlanBindingApi.ts

@@ -0,0 +1,147 @@
+import { HttpError } from "../../shared/api/errors";
+import { documentPasswordHeader } from "../../shared/api/documentPasswordHeader";
+import { httpClient, type RequestOptions } from "../../shared/api/httpClient";
+import type { JsonResult } from "../../shared/api/types";
+import { isStringId } from "./bindingTypes";
+
+export interface BindSubPlansRequest {
+  subPlanIds: string[];
+  mainPlanRowVersion: number;
+}
+
+export interface BindSubPlansResult {
+  createdCount: number;
+  existingCount: number;
+  subPlanCount: number;
+  mainPlanRowVersion: number;
+}
+
+export interface UnbindSubPlanResult {
+  subPlanCount: number;
+  mainPlanRowVersion: number;
+}
+
+interface SubPlanBindingHttpClient {
+  post<T>(
+    path: string,
+    body?: unknown,
+    options?: RequestOptions,
+  ): Promise<JsonResult<T>>;
+  delete<T>(path: string, options?: RequestOptions): Promise<JsonResult<T>>;
+}
+
+function contractError(
+  result: JsonResult<unknown>,
+  target: string,
+): HttpError {
+  return new HttpError({
+    httpStatus: result.httpStatus,
+    code: "INTERNAL_ERROR",
+    message: `${target}响应不符合DMS接口契约`,
+    details: null,
+    requestId: result.requestId,
+  });
+}
+
+function isNonNegativeInteger(value: unknown): value is number {
+  return typeof value === "number" && Number.isInteger(value) && value >= 0;
+}
+
+export function isBindSubPlansRequest(
+  value: unknown,
+): value is BindSubPlansRequest {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+  const request = value as Record<string, unknown>;
+  return (
+    Array.isArray(request.subPlanIds) &&
+    request.subPlanIds.length > 0 &&
+    request.subPlanIds.every((item) => typeof item === "string") &&
+    isNonNegativeInteger(request.mainPlanRowVersion)
+  );
+}
+
+export function isBindSubPlansResult(
+  value: unknown,
+): value is BindSubPlansResult {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+  const result = value as Record<string, unknown>;
+  return (
+    isNonNegativeInteger(result.createdCount) &&
+    isNonNegativeInteger(result.existingCount) &&
+    isNonNegativeInteger(result.subPlanCount) &&
+    isNonNegativeInteger(result.mainPlanRowVersion)
+  );
+}
+
+export function isUnbindSubPlanResult(
+  value: unknown,
+): value is UnbindSubPlanResult {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+  const result = value as Record<string, unknown>;
+  return (
+    isNonNegativeInteger(result.subPlanCount) &&
+    isNonNegativeInteger(result.mainPlanRowVersion)
+  );
+}
+
+function assertId(value: string, label: string): void {
+  if (!isStringId(value)) {
+    throw new TypeError(`${label}必须是十进制字符串ID`);
+  }
+}
+
+export function createSubPlanBindingApi(
+  client: SubPlanBindingHttpClient = httpClient,
+) {
+  return {
+    async bind(
+      mainPlanId: string,
+      request: BindSubPlansRequest,
+      signal?: AbortSignal,
+      password?: string,
+    ): Promise<BindSubPlansResult> {
+      assertId(mainPlanId, "主案ID");
+      if (!isBindSubPlansRequest(request)) {
+        throw new TypeError("子案挂载请求不符合DMS接口契约");
+      }
+      const result = await client.post<unknown>(
+        `main-plans/${mainPlanId}/sub-plans/bind`,
+        request,
+        { signal, headers: documentPasswordHeader(password) },
+      );
+      if (!isBindSubPlansResult(result.data)) {
+        throw contractError(result, "挂载子案");
+      }
+      return result.data;
+    },
+
+    async unbind(
+      mainPlanId: string,
+      subPlanId: string,
+      mainPlanRowVersion: number,
+      signal?: AbortSignal,
+      password?: string,
+    ): Promise<UnbindSubPlanResult> {
+      assertId(mainPlanId, "主案ID");
+      assertId(subPlanId, "子案ID");
+      if (!isNonNegativeInteger(mainPlanRowVersion)) {
+        throw new TypeError("主案版本必须是非负整数");
+      }
+      const result = await client.delete<unknown>(
+        `main-plans/${mainPlanId}/sub-plans/${subPlanId}`,
+        {
+          query: { mainPlanRowVersion },
+          signal,
+          headers: documentPasswordHeader(password),
+        },
+      );
+      if (!isUnbindSubPlanResult(result.data)) {
+        throw contractError(result, "解除子案挂载");
+      }
+      return result.data;
+    },
+  };
+}
+
+export const subPlanBindingApi = createSubPlanBindingApi();
+export type SubPlanBindingApi = ReturnType<typeof createSubPlanBindingApi>;

+ 4 - 0
frontend/src/features/categories/__tests__/categoryApi.test.ts

@@ -13,6 +13,7 @@ const node: CategoryNode = {
   sortNo: 10,
   documentCount: 0,
   flatMode: false,
+  typeDisplayName: null,
   rowVersion: 0,
   children: [],
 };
@@ -58,6 +59,7 @@ describe("categoryApi", () => {
       parentId: null,
       sortNo: 10,
       flatMode: false,
+      typeDisplayName: null,
     };
     await api.create(body);
     expect(mock.post).toHaveBeenCalledWith("categories", body);
@@ -73,6 +75,7 @@ describe("categoryApi", () => {
       parentId: "1",
       sortNo: 10,
       flatMode: false,
+      typeDisplayName: null,
     };
     await api.create(body);
     expect(mock.post).toHaveBeenCalledWith("categories", body);
@@ -87,6 +90,7 @@ describe("categoryApi", () => {
       parentId: null,
       sortNo: 20,
       flatMode: false,
+      typeDisplayName: null,
       rowVersion: 3,
     };
     await api.update("1", body);

+ 3 - 3
frontend/src/features/categories/__tests__/categoryTree.test.ts

@@ -12,17 +12,17 @@ import {
 const grandchild: CategoryNode = {
   id: "3", categoryCode: "C", categoryName: "C", categoryType: "SITUATION",
   parentId: "2", categoryPath: "/A/B/C", sortNo: 0, documentCount: 0,
-  flatMode: false, rowVersion: 0, children: [],
+  flatMode: false, typeDisplayName: null, rowVersion: 0, children: [],
 };
 const child: CategoryNode = {
   id: "2", categoryCode: "B", categoryName: "B", categoryType: "STYLE",
   parentId: "1", categoryPath: "/A/B", sortNo: 0, documentCount: 0,
-  flatMode: false, rowVersion: 0, children: [grandchild],
+  flatMode: false, typeDisplayName: null, rowVersion: 0, children: [grandchild],
 };
 const root: CategoryNode = {
   id: "1", categoryCode: "A", categoryName: "A", categoryType: "SCENE",
   parentId: null, categoryPath: "/A", sortNo: 0, documentCount: 0,
-  flatMode: false, rowVersion: 0, children: [child],
+  flatMode: false, typeDisplayName: null, rowVersion: 0, children: [child],
 };
 const other: CategoryNode = {
   ...root, id: "4", categoryCode: "D", categoryName: "D", categoryPath: "/D",

+ 2 - 0
frontend/src/features/categories/__tests__/categoryTypes.test.ts

@@ -15,6 +15,7 @@ const child: CategoryNode = {
   sortNo: 10,
   documentCount: 0,
   flatMode: false,
+  typeDisplayName: null,
   rowVersion: 0,
   children: [],
 };
@@ -29,6 +30,7 @@ const root: CategoryNode = {
   sortNo: 10,
   documentCount: 0,
   flatMode: false,
+  typeDisplayName: null,
   rowVersion: 1,
   children: [child],
 };

+ 68 - 0
frontend/src/features/categories/categoryState.ts

@@ -92,3 +92,71 @@ export function useCategoryTree(
     refresh,
   };
 }
+
+
+// ─── 分类树注册表:跨组件共享最新完整树快照 ──────────────────────────────────
+// 检索结果徽标(顶级分类显示名)与 flatMode 关系面板判断需要按 categoryId
+// 反查顶级分类,而树数据由 CategoryPanel 内部的 useCategoryTree 持有。
+// 这里以模块级注册表共享最新快照;左侧分类搜索会产生"被过滤的子树",
+// 仅当新快照节点数不少于已注册快照时才覆盖,避免残缺树污染反查结果。
+
+let registeredTree: CategoryNode[] = [];
+const registeredIndex = new Map<
+  string,
+  { node: CategoryNode; parent: CategoryNode | null }
+>();
+
+function countNodes(nodes: CategoryNode[]): number {
+  return nodes.reduce(
+    (total, node) => total + 1 + countNodes(node.children),
+    0,
+  );
+}
+
+export function registerCategoryTree(tree: CategoryNode[]): void {
+  if (!Array.isArray(tree) || !tree.length) return;
+  if (registeredTree.length && countNodes(tree) < countNodes(registeredTree)) {
+    return;
+  }
+  registeredTree = tree;
+  registeredIndex.clear();
+  const walk = (nodes: CategoryNode[], parent: CategoryNode | null): void => {
+    for (const node of nodes) {
+      registeredIndex.set(node.id, { node, parent });
+      walk(node.children, node);
+    }
+  };
+  walk(tree, null);
+}
+
+export function clearRegisteredCategoryTreeForTests(): void {
+  registeredTree = [];
+  registeredIndex.clear();
+}
+
+/** 沿 parentId 上溯返回所属顶级分类;未注册或不在树内时返回 null。 */
+export function topCategoryOf(categoryId: string | null): CategoryNode | null {
+  if (!categoryId) return null;
+  let entry = registeredIndex.get(categoryId);
+  if (!entry) return null;
+  while (entry.parent) {
+    const parentEntry = registeredIndex.get(entry.parent.id);
+    if (!parentEntry) break;
+    entry = parentEntry;
+  }
+  return entry.node;
+}
+
+/** 该分类所属顶级分类是否为扁平模式(不含配套方案与资料)。 */
+export function isFlatTopCategory(categoryId: string | null): boolean {
+  return topCategoryOf(categoryId)?.flatMode === true;
+}
+
+/** 顶级分类的类型显示名(优先 typeDisplayName,回退分类名称)。 */
+export function topCategoryDisplayName(
+  categoryId: string | null,
+): string | null {
+  const top = topCategoryOf(categoryId);
+  if (!top) return null;
+  return top.typeDisplayName?.trim() || top.categoryName;
+}

+ 5 - 0
frontend/src/features/categories/categoryTypes.ts

@@ -16,6 +16,8 @@ export interface CategoryNode {
   sortNo: number;
   documentCount: number;
   flatMode: boolean;
+  /** 类型显示名称(仅一级分类有意义,null 表示未设置,回退显示分类名称) */
+  typeDisplayName: string | null;
   rowVersion: number;
   children: CategoryNode[];
 }
@@ -32,6 +34,7 @@ export interface CreateCategoryRequest {
   parentId: Id | null;
   sortNo: number;
   flatMode: boolean;
+  typeDisplayName: string | null;
 }
 
 export interface UpdateCategoryRequest {
@@ -40,6 +43,7 @@ export interface UpdateCategoryRequest {
   parentId: Id | null;
   sortNo: number;
   flatMode: boolean;
+  typeDisplayName: string | null;
   rowVersion: number;
 }
 
@@ -107,6 +111,7 @@ export function isCategoryNode(value: unknown): value is CategoryNode {
     isNonNegativeInteger(value.sortNo) &&
     isNonNegativeInteger(value.documentCount) &&
     typeof value.flatMode === "boolean" &&
+    (value.typeDisplayName === null || value.typeDisplayName === undefined || typeof value.typeDisplayName === "string") &&
     isNonNegativeInteger(value.rowVersion) &&
     Array.isArray(value.children) &&
     value.children.every(isCategoryNode)

+ 3 - 4
frontend/src/features/directory/DirectoryPage.tsx

@@ -529,10 +529,9 @@ function OrganizationTreeRow({
   return (
     <>
       <div
-        className="flex items-center gap-2 px-3 py-2 cursor-pointer border-b last:border-b-0 transition-colors"
+        className={`flex items-center gap-2 px-3 py-2 cursor-pointer border-b last:border-b-0 transition-colors hover:bg-[rgba(49,145,235,0.18)] ${isSelected ? "bg-[rgba(49,145,235,0.08)]" : "bg-white"}`}
         style={{
           paddingLeft: 12 + depth * 18,
-          background: isSelected ? "rgba(49,145,235,0.08)" : W,
           borderColor: "rgba(49,145,235,0.1)",
         }}
         onClick={() => onSelect(node.id)}
@@ -1170,7 +1169,7 @@ function UserMaintenancePanel({
                 {data.items.map((user) => (
                   <tr
                     key={user.id}
-                    className="border-t hover:bg-blue-50/40"
+                    className="border-t transition-colors hover:bg-[rgba(49,145,235,0.18)]"
                     style={{ borderColor: "rgba(49,145,235,0.12)" }}
                   >
                     <td className="px-3 py-2 font-bold" style={{ color: N }}>
@@ -1338,7 +1337,7 @@ function UserOrgTreeRow({
   return (
     <>
       <div
-        className="flex items-center gap-1.5 px-3 py-1.5 cursor-pointer"
+        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)]"
         style={{ paddingLeft: 12 + depth * 14 }}
         onClick={() => onSelect(node.id)}
       >

+ 1 - 1
frontend/src/features/directory/IpEntryMaintenancePanel.tsx

@@ -284,7 +284,7 @@ export function IpEntryMaintenancePanel({
               {data.items.map((entry) => (
                 <tr
                   key={entry.id}
-                  className="border-t hover:bg-blue-50/40"
+                  className="border-t transition-colors hover:bg-[rgba(49,145,235,0.18)]"
                   style={{ borderColor: "rgba(49,145,235,0.12)" }}
                 >
                   <td className="px-3 py-2 font-black" style={{ color: N }}>

+ 1 - 1
frontend/src/features/directory/RoleMaintenancePanel.tsx

@@ -833,7 +833,7 @@ export function RoleMaintenancePanel({
                           return (
                             <tr
                               key={`${row.securityLevel}-${row.deptScope}`}
-                              className="border-t"
+                              className="border-t transition-colors hover:bg-[rgba(49,145,235,0.18)]"
                               style={{ borderColor: "rgba(49,145,235,0.12)" }}
                             >
                               {firstOfLevel && (

+ 2 - 1
frontend/src/features/directory/directoryUi.tsx

@@ -9,6 +9,7 @@ import {
   type SecurityLevel as SecurityLevelValue,
 } from "../../shared/api/enums";
 import { HttpError } from "../../shared/api/errors";
+import { tipProps } from "../../shared/ui/hoverTip";
 
 /** 目录模块共享的配色常量与基础 UI 组件。 */
 export const N = "#1A5299";
@@ -258,7 +259,7 @@ export function ModalFooter({
         disabled={submitting}
         className="px-3 py-1.5 text-sm font-black rounded-sm disabled:opacity-50"
         style={{ border: `1px solid ${B}`, color: N, background: W }}
-        title={secondaryTooltip}
+        {...tipProps(secondaryTooltip)}
       >
         取消
       </button>

+ 78 - 0
frontend/src/features/documents/__tests__/actionPermission.test.ts

@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { AllowedAction } from "../../../shared/api/enums";
+import type { UserSummary } from "../../auth/authTypes";
+import type { DocumentSummary } from "../documentTypes";
+import {
+  deniedActionReason,
+  isSecurityBlocked,
+  securityRank,
+} from "../actionPermission";
+
+const LEVEL_LABELS: Record<string, string> = {
+  PUBLIC: "公开",
+  INTERNAL: "内部",
+  SECRET: "秘密",
+  CONFIDENTIAL: "机密",
+  TOP_SECRET: "绝密",
+};
+const label = (code: string) => LEVEL_LABELS[code] ?? code;
+
+function user(level: string): UserSummary {
+  return {
+    id: "1", username: "u", realName: "用户", organizationId: null,
+    organizationName: null, roleCode: "USER", roleId: null, roleName: null,
+    securityLevel: level as UserSummary["securityLevel"], status: "ENABLED",
+    authVersion: 1, rowVersion: 1, lastLoginAt: null, lastLoginIp: null,
+    allowedModules: [],
+  };
+}
+
+function doc(level: string, actions: string[]): DocumentSummary {
+  return {
+    id: "2", documentName: "方案", summary: null, documentType: "MAIN",
+    status: "PUBLISHED", securityLevel: level as DocumentSummary["securityLevel"],
+    visibilityType: "ALL_AUTHENTICATED", visibilitySummary: "", categoryId: null,
+    categoryName: null, categoryPath: null, organizationId: null,
+    organizationName: null, parentDocumentId: null, rootDocumentId: null,
+    attachmentType: null, fileExtension: "docx", tags: [], childCount: 0,
+    attachmentCount: 0, viewCount: 0, downloadCount: 0, createdByName: null,
+    createdAt: "", updatedAt: "", rowVersion: 1,
+    allowedActions: actions as DocumentSummary["allowedActions"],
+  };
+}
+
+describe("deniedActionReason 无权原因推导", () => {
+  it("有权限时返回 null", () => {
+    const item = doc("SECRET", [AllowedAction.VIEW, AllowedAction.DOWNLOAD]);
+    expect(deniedActionReason(AllowedAction.VIEW, item, user("SECRET"), label)).toBeNull();
+    expect(deniedActionReason(AllowedAction.DOWNLOAD, item, user("SECRET"), label)).toBeNull();
+  });
+
+  it("密级不足时提示密级不匹配(含双方密级名)", () => {
+    const item = doc("CONFIDENTIAL", [AllowedAction.VIEW]);
+    const reason = deniedActionReason(AllowedAction.DOWNLOAD, item, user("SECRET"), label);
+    expect(reason).toContain("密级不匹配");
+    expect(reason).toContain("秘密");
+    expect(reason).toContain("机密");
+    expect(reason).toContain("下载");
+  });
+
+  it("密级足够但未授权时提示权限不匹配(不误报密级)", () => {
+    const item = doc("INTERNAL", [AllowedAction.VIEW]);
+    const reason = deniedActionReason(AllowedAction.EDIT, item, user("TOP_SECRET"), label);
+    expect(reason).toContain("权限不匹配");
+    expect(reason).toContain("编辑");
+    expect(reason).not.toContain("密级不匹配");
+  });
+
+  it("密级判定与后端 SECURITY_LEVEL_VALUES 同序", () => {
+    expect(securityRank("PUBLIC")).toBeLessThan(securityRank("INTERNAL"));
+    expect(securityRank("INTERNAL")).toBeLessThan(securityRank("SECRET"));
+    expect(securityRank("SECRET")).toBeLessThan(securityRank("CONFIDENTIAL"));
+    expect(securityRank("CONFIDENTIAL")).toBeLessThan(securityRank("TOP_SECRET"));
+    expect(isSecurityBlocked(doc("SECRET", []), user("INTERNAL"))).toBe(true);
+    expect(isSecurityBlocked(doc("SECRET", []), user("SECRET"))).toBe(false);
+    // 用户信息缺失时不做密级误判(回退权限不匹配口径)
+    expect(isSecurityBlocked(doc("TOP_SECRET", []), null)).toBe(false);
+  });
+});

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

@@ -29,8 +29,9 @@ describe("F4 read-only scope", () => {
 
   it("F4真实页面继续运行且后续阶段路由保留角色门禁", () => {
     expect(app).toContain('page === "main" && <RealMainPage');
+    // 管理页按模块权限动态可见(角色门禁已升级为权限门禁)
     expect(app).toContain(
-      'page === "admin" && currentUser.roleCode === RoleCode.ADMIN',
+      'page === "admin" && isPageAllowed("admin", currentUser.allowedModules)',
     );
     expect(app).toContain("<RealAdminPage");
     expect(app).toContain("<AuditDashboard />");

+ 11 - 3
frontend/src/features/documents/__tests__/searchTransitionScope.test.ts

@@ -11,8 +11,15 @@ const hook = readFileSync(new URL("../globalSearchResults.ts", import.meta.url),
 
 describe("关系面板按需显示", () => {
   it("主页与后台页均仅在选中主案且未处于检索态时渲染关系面板,占位文案已删除", () => {
-    expect(app).toContain("!hideRelations && !searchActive && selected?.documentType === DocumentType.MAIN && (");
-    expect(app.match(/!searchActive && selected\?\.documentType === DocumentType\.MAIN && \(/g)?.length).toBe(2);
+    // flatMode 顶级分类不渲染关系面板(需求:扁平模式仅列方案)
+    // 条件收敛为 relationVisible 布尔(主页另叠加 hideRelations,后台页叠加 plans 模式),
+    // 分隔线与配套面板共用该布尔;两侧定义各一处。
+    expect(app).toContain("const relationVisible = !hideRelations && !searchActive");
+    expect(app).toContain('const relationVisible = !searchActive && mode === "plans"');
+    expect(app.match(/&& !!selected && selected\.documentType === DocumentType\.MAIN/g)?.length).toBe(2);
+    expect(app.match(/&& !isFlatTopCategory\(selected\.categoryId\);/g)?.length).toBe(2);
+    expect(app.match(/\{relationVisible && <CenterSplitSeparator/g)?.length).toBe(2);
+    expect(app.match(/\{relationVisible && \(/g)?.length).toBe(2);
     expect(app).not.toContain("请选择主案");
   });
 });
@@ -53,7 +60,8 @@ describe("定位逻辑", () => {
   });
 
   it("跳转统一动作:清空检索 + 跳分类 + 选中主案 + 复位分页", () => {
-    expect(app.match(/resetGlobalSearch\(\);/g)?.length).toBe(2);
+    // 清空检索入口除跳转外还包括页签切换/点击分类(需求5/7),仅锁定跳转函数内的成对序列
+    expect(app.match(/resetGlobalSearch\(\);\s*setCategoryId\(main\.categoryId\);/g)?.length).toBe(2);
     expect(app.match(/setCategoryId\(main\.categoryId\);/g)?.length).toBe(2);
     expect(app.match(/setSelected\(main\);/g)?.length).toBe(2);
   });

+ 72 - 0
frontend/src/features/documents/actionPermission.ts

@@ -0,0 +1,72 @@
+import { AllowedAction } from "../../shared/api/enums";
+import type { UserSummary } from "../auth/authTypes";
+import type { DocumentSummary } from "./documentTypes";
+
+/**
+ * 操作按钮“不可用原因”推导。
+ *
+ * 后端把每个文档的 allowedActions 下发(管理员=全量;普通用户=角色矩阵
+ * ×密级×部门范围,与文档级授权 OR 叠加)。按钮置灰时需要说明原因,
+ * 本模块按后端授权顺序还原判定:
+ *
+ * 1. 密级前置检查(含管理员也不能绕过):用户密级低于文档密级 →
+ *    “密级不匹配”,任何操作都不可用;
+ * 2. 其余(未命中角色矩阵/文档授权、文档未发布等)→ “权限不匹配”。
+ */
+
+/** 密级秩(与后端 SECURITY_LEVEL_VALUES 同序,仅用于前端提示文案)。 */
+const SECURITY_RANK: Record<string, number> = {
+  PUBLIC: 10,
+  INTERNAL: 20,
+  SECRET: 30,
+  CONFIDENTIAL: 40,
+  TOP_SECRET: 50,
+};
+
+/** 操作码 → 中文动词(提示文案用)。 */
+const ACTION_VERB: Record<string, string> = {
+  [AllowedAction.VIEW]: "查看",
+  [AllowedAction.DOWNLOAD]: "下载",
+  [AllowedAction.EDIT]: "编辑",
+  [AllowedAction.DELETE]: "删除",
+  [AllowedAction.CONFIG_PERMISSION]: "授权",
+  [AllowedAction.BIND_ATTACHMENT]: "挂载",
+  [AllowedAction.UNBIND_ATTACHMENT]: "解除挂载",
+};
+
+export function securityRank(code: string): number {
+  return SECURITY_RANK[code] ?? 0;
+}
+
+/** 用户密级是否低于文档密级(密级前置检查不通过)。 */
+export function isSecurityBlocked(
+  item: Pick<DocumentSummary, "securityLevel">,
+  user: Pick<UserSummary, "securityLevel"> | null | undefined,
+): boolean {
+  if (!user) return false;
+  return securityRank(user.securityLevel) < securityRank(item.securityLevel);
+}
+
+/**
+ * 计算某操作对该文档是否不可用及原因;可用时返回 null。
+ *
+ * @param action 操作码
+ * @param item   文档行(含 allowedActions / securityLevel)
+ * @param user   当前登录用户(取密级用于密级比对)
+ * @param labels 密级码 → 中文标签(字典),缺省回退显示码值
+ */
+export function deniedActionReason(
+  action: AllowedAction | string,
+  item: Pick<DocumentSummary, "allowedActions" | "securityLevel">,
+  user: Pick<UserSummary, "securityLevel"> | null | undefined,
+  labels?: (code: string) => string,
+): string | null {
+  if (item.allowedActions.includes(action as never)) return null;
+  const verb = ACTION_VERB[action] ?? "执行该操作";
+  if (isSecurityBlocked(item, user)) {
+    const docLevel = labels?.(item.securityLevel) ?? item.securityLevel;
+    const userLevel = labels?.(user?.securityLevel ?? "") ?? user?.securityLevel ?? "未知";
+    return `密级不匹配:您的密级为「${userLevel}」,低于该文档密级「${docLevel}」,无权${verb}`;
+  }
+  return `权限不匹配:您没有被授予该文档的${verb}权限`;
+}

+ 14 - 2
frontend/src/features/documents/documentTypes.ts

@@ -1,6 +1,7 @@
 import {
   AllowedAction,
   AttachmentType,
+  ContentExtractStatus,
   DocumentPasswordAction,
   DocumentStatus,
   DocumentType,
@@ -8,6 +9,7 @@ import {
   VisibilityType,
   type AllowedAction as AllowedActionCode,
   type AttachmentType as AttachmentTypeCode,
+  type ContentExtractStatus as ContentExtractStatusCode,
   type DocumentPasswordAction as DocumentPasswordActionCode,
   type DocumentStatus as DocumentStatusCode,
   type DocumentType as DocumentTypeCode,
@@ -56,6 +58,8 @@ export interface DocumentSummary {
   titleHit?: boolean;
   subHitCount?: number;
   attachmentHitCount?: number;
+  // 子案挂载关系ID(仅"主案子案列表"接口返回:该子案经挂载关系展示在当前主案下)
+  mountId?: string;
 }
 
 export interface DocumentDetail extends DocumentSummary {
@@ -70,6 +74,10 @@ export interface DocumentDetail extends DocumentSummary {
   };
   viewPasswordVerified?: boolean;
   contentText?: string | null;
+  // 正文提取状态(仅管理员可见,用于"编辑"界面排障与重新解析)
+  contentExtractStatus?: ContentExtractStatusCode;
+  contentExtractError?: string | null;
+  contentExtractedAt?: string | null;
 }
 
 export interface MainPlanBrief {
@@ -195,7 +203,8 @@ export function isDocumentSummary(value: unknown): value is DocumentSummary {
     (!("contentSnippet" in value) || value.contentSnippet === null || value.contentSnippet === undefined || typeof value.contentSnippet === "string") &&
     (!("titleHit" in value) || value.titleHit === undefined || typeof value.titleHit === "boolean") &&
     (!("subHitCount" in value) || value.subHitCount === undefined || isInteger(value.subHitCount)) &&
-    (!("attachmentHitCount" in value) || value.attachmentHitCount === undefined || isInteger(value.attachmentHitCount))
+    (!("attachmentHitCount" in value) || value.attachmentHitCount === undefined || isInteger(value.attachmentHitCount)) &&
+    (!("mountId" in value) || value.mountId === undefined || value.mountId === null || isId(value.mountId))
   );
 }
 
@@ -213,7 +222,10 @@ export function isDocumentDetail(value: unknown): value is DocumentDetail {
     isInteger(permission.userCount) &&
     typeof permission.inheritedFromMainPlan === "boolean" &&
     (!("viewPasswordVerified" in value) || value.viewPasswordVerified === undefined || typeof value.viewPasswordVerified === "boolean") &&
-    (!("contentText" in value) || value.contentText === null || value.contentText === undefined || typeof value.contentText === "string")
+    (!("contentText" in value) || value.contentText === null || value.contentText === undefined || typeof value.contentText === "string") &&
+    (!("contentExtractStatus" in value) || value.contentExtractStatus === undefined || isEnum(ContentExtractStatus, value.contentExtractStatus)) &&
+    (!("contentExtractError" in value) || value.contentExtractError === undefined || value.contentExtractError === null || typeof value.contentExtractError === "string") &&
+    (!("contentExtractedAt" in value) || value.contentExtractedAt === undefined || value.contentExtractedAt === null || (typeof value.contentExtractedAt === "string" && utcPattern.test(value.contentExtractedAt)))
   );
 }
 

+ 47 - 11
frontend/src/features/files/FilePreviewModal.tsx

@@ -28,6 +28,7 @@ import type { DocumentSummary } from "../documents/documentTypes";
 import type { DocumentRelationItem } from "../documents/documentRelationTypes";
 import { documentApi } from "../documents/documentApi";
 import { invalidateDetail } from "../documents/documentState";
+import { tipProps } from "../../shared/ui/hoverTip";
 import { documentMutationApi } from "../mutations/documentMutationApi";
 import { notifyContentChanged } from "../../shared/state/contentRefresh";
 import { acquirePreview } from "./fileState";
@@ -39,6 +40,8 @@ import {
 
 const PDF_MIME = "application/pdf";
 const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+// 图片类扩展名:后端原图直出(image/* MIME),前端 <img> 渲染
+const IMAGE_PREVIEW_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
 
 function previewError(value: unknown, requestId = "unknown"): HttpError {
   if (value instanceof HttpError) return value;
@@ -145,7 +148,7 @@ function ImageLightbox({ src, onClose }: { src: string; onClose: () => void }) {
         type="button"
         onClick={onClose}
         aria-label="关闭图片浏览"
-        title="关闭图片浏览"
+        {...tipProps("关闭图片浏览")}
         className="absolute top-4 right-4 flex items-center justify-center w-10 h-10 rounded-sm text-white transition-colors hover:bg-white/25"
       >
         <X size={24} />
@@ -166,6 +169,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
   style?: React.CSSProperties;
 }) {
   const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
+  const [imageBlob, setImageBlob] = useState<Blob | null>(null);
   const [error, setError] = useState<HttpError | null>(null);
   const [loading, setLoading] = useState(true);
   const [retryVersion, setRetryVersion] = useState(0);
@@ -239,6 +243,16 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
     return () => URL.revokeObjectURL(pdfUrl);
   }, [pdfUrl]);
 
+  // 图片预览同理:<img> 只消费对象 URL,切换/卸载时释放。
+  const imageUrl = useMemo(
+    () => (imageBlob ? URL.createObjectURL(imageBlob) : null),
+    [imageBlob],
+  );
+  useEffect(() => {
+    if (!imageUrl) return;
+    return () => URL.revokeObjectURL(imageUrl);
+  }, [imageUrl]);
+
   // modal 模式下额外拉取 detail 以展示提取文本(contentText 仅在 detail DTO 暴露)。
   // side 模式宽度不足以分栏,跳过拉取以节省请求。
   useEffect(() => {
@@ -389,6 +403,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
     if (!canView) {
       // 无权限:不发预览请求,直接给出提示(复用 403 文案路径)。
       setPdfBlob(null);
+      setImageBlob(null);
       setError(new HttpError({
         httpStatus: 403,
         code: "DOCUMENT_VIEW_FORBIDDEN",
@@ -407,6 +422,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
     );
     let responseRequestId = "unknown";
     setPdfBlob(null);
+    setImageBlob(null);
     setError(null);
     setLoading(true);
     setLightboxSrc(null);
@@ -417,12 +433,18 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
       if (version !== requestVersion.current) return;
       const mime = (result.contentType || result.blob.type || "").split(";", 1)[0].trim().toLowerCase();
       const extension = current.fileExtension.trim().toLowerCase();
-      // 1.6 预览:PDF/DOC/DOCX/WPS 统一返回 application/pdf;XLS/XLSX 在后端返回 415
-      if ((extension === "pdf" || extension === "doc" || extension === "docx" || extension === "wps") && mime === PDF_MIME) {
+      // 预览:PDF/DOC/DOCX/WPS/XLS/XLSX/ET 统一转换返回 application/pdf;图片原图直出 image/*
+      if ((extension === "pdf" || extension === "doc" || extension === "docx" || extension === "wps"
+        || extension === "xls" || extension === "xlsx" || extension === "et") && mime === PDF_MIME) {
         if (version !== requestVersion.current) return;
         setPdfBlob(result.blob);
         return;
       }
+      if (IMAGE_PREVIEW_EXTENSIONS.has(extension) && mime.startsWith("image/")) {
+        if (version !== requestVersion.current) return;
+        setImageBlob(result.blob);
+        return;
+      }
       if (extension === "docx") {
         // 受控回退:仅当后端明确返回 DOCX 时使用 docx-preview
         if (mime !== DOCX_MIME) {
@@ -486,6 +508,18 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
           className="w-full h-full border-0 bg-white"
         />
       )}
+      {imageUrl && (
+        <div className="absolute inset-0 flex items-center justify-center overflow-auto p-6">
+          {/* 图片原图直出预览:点击进入全屏浏览遮罩(复用 ImageLightbox,Ctrl+滚轮缩放) */}
+          <img
+            src={imageUrl}
+            alt={current.documentName}
+            draggable={false}
+            onClick={() => setLightboxSrc(imageUrl)}
+            className="max-w-full max-h-full object-contain shadow-sm cursor-zoom-in"
+          />
+        </div>
+      )}
       <div
         ref={docxContainer}
         // 文档内图片点击 → 全屏图片浏览遮罩:docx-preview 动态渲染的 img 无法逐个绑定
@@ -496,7 +530,9 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
         }}
         className={`q3-docx-preview min-h-full overflow-auto [&_.docx-wrapper]:!bg-[#d8e8f5] [&_section.docx]:!shadow-sm ${mode === "side" ? "[&_.docx-wrapper]:!p-3 [&_section.docx]:!max-w-full" : "[&_.docx-wrapper]:!p-8"}`}
       />
-      {error && <div className="p-8 text-center" style={{ color: "#1A5299" }}>
+      {/* 错误提示必须绝对定位铺满:上方 docxContainer 带 min-h-full,即使为空也占满
+          内容区,流式提示会被 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>}
@@ -561,9 +597,9 @@ 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,.12)] disabled:opacity-60"
+                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"
                 style={{ color: "#1A5299" }}
-                title="点击查看该方案"
+                {...tipProps("点击查看该方案")}
               >
                 {relation.relationType} · {relation.relatedDocumentName}
               </button>
@@ -618,7 +654,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
           role="separator"
           aria-orientation="vertical"
           aria-label="拖动调整预览区宽度"
-          title="拖动调整预览区宽度"
+          {...tipProps("拖动调整预览区宽度")}
           className="absolute left-0 top-0 bottom-0 z-10 w-2.5 -ml-[5px] cursor-col-resize transition-colors hover:bg-[rgba(49,145,235,0.35)]"
           onPointerDown={(event) => {
             if (event.button !== 0) return;
@@ -674,14 +710,14 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
               type="button"
               onClick={() => setNavigated(null)}
               aria-label="返回原方案"
-              title={`返回原方案:${item.documentName}`}
+              {...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"
             >
               <ArrowLeft size={13} />返回
             </button>
           )}
           <div className="min-w-0 flex-1">
-            <div className="text-sm font-black truncate" title={current.documentName}>{mode === "modal" ? `文件查看 · ${current.documentName}` : current.documentName}</div>
+            <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>
           {isModal && (
@@ -717,13 +753,13 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
               type="button"
               onClick={() => setWindowState((current) => (current === "maximized" ? "normal" : "maximized"))}
               aria-label={windowState === "maximized" ? "还原窗口" : "最大化窗口"}
-              title={windowState === "maximized" ? "还原窗口(或双击标题栏)" : "最大化窗口(或双击标题栏)"}
+              {...tipProps(windowState === "maximized" ? "还原窗口(或双击标题栏)" : "最大化窗口(或双击标题栏)")}
               className="flex-shrink-0">
               {windowState === "maximized" ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
             </button>
           )}
           {isModal && (
-            <button type="button" onClick={() => setWindowState("fullscreen")} aria-label="全屏展示" title="全屏展示(隐藏标题栏)" className="flex-shrink-0">
+            <button type="button" onClick={() => setWindowState("fullscreen")} aria-label="全屏展示" {...tipProps("全屏展示(隐藏标题栏)")} className="flex-shrink-0">
               <Expand size={16} />
             </button>
           )}

+ 4 - 1
frontend/src/features/files/__tests__/fileApi.test.ts

@@ -42,6 +42,9 @@ describe("F5 file API and filename safety", () => {
   it("前端只做扩展名和空文件提示,不硬编码大小上限", () => {
     expect(validateSelectedFile(new File([], "empty.pdf"))).toContain("空文件");
     expect(validateSelectedFile(new File(["x"], "bad.exe"))).toContain("DOC");
-    expect(validateSelectedFile(new File(["x"], "bad.xlsx"))).toContain("DOC");
+    expect(validateSelectedFile(new File(["x"], "bad.txt"))).toContain("DOC");
+    // XLS/XLSX/ET 与图片扩展名已纳入允许范围
+    expect(validateSelectedFile(new File(["x"], "sheet.xlsx"))).toBeNull();
+    expect(validateSelectedFile(new File(["x"], "photo.webp"))).toBeNull();
   });
 });

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

@@ -47,8 +47,12 @@ describe("Q3-F 1.4 business alignment", () => {
     expect(app).toContain("onSelectCategory?.(id, node ? node.flatMode : false)");
     expect(app).toContain("setHideRelations(flatTop)");
     // 需求更新:面板还需选中主案才渲染(未选中时整体隐藏,不再显示占位);
-    // 全局搜索激活时同样隐藏配套列表
-    expect(app).toContain("{!hideRelations && !searchActive && selected?.documentType === DocumentType.MAIN && (");
+    // 全局搜索激活时同样隐藏配套列表;
+    // flatMode 顶级分类(经注册表反查)下也不渲染关系面板
+    // (条件收敛为 relationVisible 布尔,供上下分栏切分与面板渲染共用)
+    expect(app).toContain("const relationVisible = !hideRelations && !searchActive");
+    expect(app).toContain("&& !!selected && selected.documentType === DocumentType.MAIN");
+    expect(app).toContain("&& !isFlatTopCategory(selected.categoryId);");
   });
 
   it("对话框点击外部不再自动关闭,分类表单回车提交", () => {
@@ -180,4 +184,13 @@ describe("Q3-F right preview", () => {
     expect(preview).toContain("error.httpStatus !== 401");
     expect(preview).toContain("重试预览");
   });
+
+  it("错误提示绝对定位铺满:不被min-h-full的docx容器挤出可视区", () => {
+    // docxContainer 无条件渲染且 min-h-full,出错时仍占满内容区;
+    // 流式错误提示会被 overflow-hidden 裁掉(无权/415提示不可见的根因),
+    // 因此错误提示必须与 loading 蒙层同构(absolute inset-0 居中)。
+    expect(preview).toContain(
+      '{error && <div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-8 text-center"',
+    );
+  });
 });

+ 6 - 2
frontend/src/features/files/filename.ts

@@ -41,13 +41,17 @@ export function suggestedNameFromFile(file: File): string {
   return (index > 0 ? file.name.slice(0, index) : file.name).trim();
 }
 
-export const ACCEPTED_DOCUMENT_EXTENSIONS = [".doc", ".docx", ".wps", ".pdf"] as const;
+export const ACCEPTED_DOCUMENT_EXTENSIONS = [
+  ".doc", ".docx", ".wps", ".pdf",
+  ".xls", ".xlsx", ".et",
+  ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp",
+] as const;
 
 export function validateSelectedFile(file: File): string | null {
   if (file.size === 0) return "不能选择空文件";
   const lower = file.name.toLowerCase();
   if (!ACCEPTED_DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension))) {
-    return "请选择 DOC、DOCX、WPS 或 PDF 文件";
+    return "请选择 DOC、DOCX、WPS、PDF、XLS、XLSX、ET 或常用图片(JPG/PNG/GIF/BMP/WEBP)文件";
   }
   return null;
 }

+ 118 - 5
frontend/src/features/mutations/MutationModals.tsx

@@ -1,11 +1,14 @@
 import { useEffect, useMemo, useRef, useState } from "react";
-import { Check, FilePlus2, Link2, Lock, RefreshCw, Trash2, Upload, X } from "lucide-react";
+import { Check, FilePlus2, FileSearch, Link2, Lock, RefreshCw, Trash2, Upload, X } from "lucide-react";
 import { HttpError } from "../../shared/api/errors";
 import {
   AttachmentType,
+  ContentExtractStatus,
   DocumentPasswordAction,
   DocumentType,
   SecurityLevel,
+  type AttachmentType as AttachmentTypeCode,
+  type ContentExtractStatus as ContentExtractStatusCode,
   type DocumentPasswordActionCode,
 } from "../../shared/api/enums";
 import {
@@ -15,7 +18,11 @@ import {
 import { notifyContentChanged } from "../../shared/state/contentRefresh";
 import { attachmentApi } from "../attachments/attachmentApi";
 import { useCategoryTree } from "../categories/categoryState";
+import { tipProps } from "../../shared/ui/hoverTip";
 import { flattenCategoryOptions } from "../categories/categoryTree";
+import { flattenOrganizations } from "../directory/directoryState";
+import { organizationApi } from "../directory/organizationApi";
+import type { OrganizationNode } from "../directory/directoryTypes";
 import { useDictionaries } from "../dictionaries/dictionaryContext";
 import { documentApi } from "../documents/documentApi";
 import {
@@ -74,6 +81,14 @@ const PASSWORD_ACTION_LABELS: Record<DocumentPasswordActionCode, string> = {
   [DocumentPasswordAction.UNBIND_ATTACHMENT]: "解除挂载",
 };
 
+/** 正文提取状态标签(编辑弹窗管理员排障区块) */
+const EXTRACT_STATUS_LABELS: Record<ContentExtractStatusCode, string> = {
+  [ContentExtractStatus.SUCCESS]: "解析成功",
+  [ContentExtractStatus.EMPTY]: "未提取到正文文本(可能是扫描件或无文本层)",
+  [ContentExtractStatus.UNSUPPORTED]: "该文件类型不支持正文解析",
+  [ContentExtractStatus.FAILED]: "正文解析失败",
+};
+
 function mutationMessage(error: HttpError): string {
   const messages: Record<string, string> = {
     PAYLOAD_TOO_LARGE: "文件或本次请求过大,请减少文件数量或选择更小的文件",
@@ -185,12 +200,16 @@ function defaultDocumentMetadata(
   };
 }
 
-export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAIN", initialParentId, mainOnly = false, onClose }: {
+export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAIN", initialParentId, initialAttachmentType, mainOnly = false, onUploaded, onClose }: {
   kind: "document" | "attachment";
   documentType?: "MAIN" | "SUB_PLAN";
   initialParentId?: string;
+  /** 附件类型预置值(管理页 Tab 页"上传"入口按当前页签类型上传共享资料)。 */
+  initialAttachmentType?: AttachmentTypeCode;
   /** 仅允许上传主案:隐藏方案类型切换,documentType 锁定为 MAIN。 */
   mainOnly?: boolean;
+  /** 上传成功回调(返回创建的文档详情;管理页附件 Tab 上传后据此自动挂载到当前主案)。 */
+  onUploaded?: (detail: DocumentDetail) => void;
   onClose: () => void;
 }) {
   const references = useReferenceData();
@@ -203,7 +222,7 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
   const [parentDocumentId, setParentDocumentId] = useState(initialParentId ?? "");
   const [categoryId, setCategoryId] = useState("");
   const [securityLevel, setSecurityLevel] = useState(SecurityLevel.INTERNAL);
-  const [attachmentType, setAttachmentType] = useState(AttachmentType.SUPPORT_ACHIEVEMENT);
+  const [attachmentType, setAttachmentType] = useState<AttachmentTypeCode>(initialAttachmentType ?? AttachmentType.SUPPORT_ACHIEVEMENT);
   const [submitting, setSubmitting] = useState(false);
   const [error, setError] = useState<HttpError | null>(null);
   const [formError, setFormError] = useState("");
@@ -241,6 +260,7 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
     setSubmitting(true);
     controller.current = new AbortController();
     try {
+      let created: DocumentDetail;
       if (kind === "attachment") {
         const metadata: AttachmentCreateMetadata = {
           documentName: documentName.trim(),
@@ -251,6 +271,7 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
         const detail = await attachmentMutationApi.create(file!, metadata, controller.current.signal);
         cacheDetail("attachment", detail);
         notifyContentChanged("attachments");
+        created = detail;
       } else {
         const base = {
           documentName: documentName.trim(),
@@ -271,7 +292,9 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
         const detail = await documentMutationApi.create(file!, metadata, controller.current.signal);
         cacheDetail("document", detail);
         notifyContentChanged("documents");
+        created = detail;
       }
+      onUploaded?.(created);
       onClose();
     } catch (value) {
       const nextError = asMutationError(value);
@@ -461,7 +484,7 @@ 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" title={draft.file.name}>{index + 1}. {draft.file.name}</div>
+              <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"><input value={metadata.documentName} onChange={(event) => updateMetadata(draft.key, { documentName: event.target.value })} className={inputClass} style={selectStyle} /></div>
               {kind === "document" ? (
                 <>
@@ -498,12 +521,14 @@ interface DocumentRelationRow {
   relationType: string;
 }
 
-export function EditMetadataModal({ item, canManagePassword = false, canManageRelations = false, onClose }: {
+export function EditMetadataModal({ item, canManagePassword = false, canManageRelations = false, canManageOrganization = false, onClose }: {
   item: DocumentSummary;
   /** 管理员可在编辑弹窗维护主案密码锁 */
   canManagePassword?: boolean;
   /** 管理员可维护方案的关联关系(非管理员仅只读展示) */
   canManageRelations?: boolean;
+  /** 管理员可在编辑弹窗调整所属单位;非管理员只读展示当前归属 */
+  canManageOrganization?: boolean;
   onClose: () => void;
 }) {
   const attachment = item.documentType === DocumentType.ATTACHMENT;
@@ -517,6 +542,9 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
   const [securityLevel, setSecurityLevel] = useState(item.securityLevel);
   const [attachmentType, setAttachmentType] = useState(item.attachmentType ?? AttachmentType.SUPPORT_ACHIEVEMENT);
   const [tags, setTags] = useState(item.tags.join(","));
+  // 所属单位(归属部门):仅管理员可调整到其他部门
+  const [organizationId, setOrganizationId] = useState(item.organizationId ?? "");
+  const [organizations, setOrganizations] = useState<OrganizationNode[]>([]);
   const [loading, setLoading] = useState(true);
   const [submitting, setSubmitting] = useState(false);
   const [error, setError] = useState<HttpError | null>(null);
@@ -534,6 +562,9 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
   const [relationSubmitting, setRelationSubmitting] = useState(false);
   const [relationError, setRelationError] = useState<HttpError | null>(null);
   const [relationFormError, setRelationFormError] = useState("");
+  // 正文解析状态(仅管理员可见该区块;字段由详情接口按角色下发)
+  const [reExtracting, setReExtracting] = useState(false);
+  const [reExtractError, setReExtractError] = useState<HttpError | null>(null);
 
   const loadLatest = async (preserveError = false) => {
     setLoading(true);
@@ -549,6 +580,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
       setSecurityLevel(latest.securityLevel);
       setAttachmentType(latest.attachmentType ?? AttachmentType.SUPPORT_ACHIEVEMENT);
       setTags(latest.tags.join(","));
+      setOrganizationId(latest.organizationId ?? "");
       setPwActions(latest.passwordProtectedActions ?? []);
       cacheDetail(attachment ? "attachment" : "document", latest);
       invalidatePreview(item.id);
@@ -592,11 +624,35 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
 
   useEffect(() => { void loadRelations(); }, [item.id]);
 
+  /** 管理员加载启用部门树作为所属单位选项;接口仅管理员可用,失败时下拉退化为仅显示当前归属 */
+  useEffect(() => {
+    if (!canManageOrganization) return;
+    const controller = new AbortController();
+    organizationApi.getTree({ status: "ENABLED" }, controller.signal)
+      .then((tree) => setOrganizations(tree))
+      .catch(() => undefined);
+    return () => controller.abort();
+  }, [canManageOrganization]);
+
+  /** 所属单位下拉选项:启用部门按路径展示;当前归属不在启用列表时补兜底项,保证原值可回显 */
+  const organizationOptions = useMemo(() => {
+    const options = flattenOrganizations(organizations).map((org) => ({
+      id: org.id,
+      label: org.orgPath.replace(/^\//, "").replaceAll("/", " / ") || org.orgName,
+    }));
+    if (organizationId && !options.some((option) => option.id === organizationId)) {
+      options.push({ id: organizationId, label: detail?.organizationName ?? organizationId });
+    }
+    return options;
+  }, [organizations, organizationId, detail?.organizationName]);
+
   const submit = async () => {
     if (!detail || submitting) return;
     setSubmitting(true);
     setError(null);
     try {
+      // 所属单位为空(历史数据未设置归属)时不提交该键,由服务端沿用现有归属
+      const organizationPatch = organizationId ? { organizationId } : {};
       const latest = attachment
         ? await attachmentMutationApi.update(item.id, {
             documentName: documentName.trim(),
@@ -604,6 +660,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
             summary: summary.trim() || null,
             tags: parseTags(tags),
             rowVersion: detail.rowVersion,
+            ...organizationPatch,
           })
         : mainPlan
           ? await withDocumentPassword(item.id, item.documentName, DocumentPasswordAction.EDIT,
@@ -614,6 +671,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
                 securityLevel,
                 tags: parseTags(tags),
                 rowVersion: detail.rowVersion,
+                ...organizationPatch,
               }, undefined, password))
           : await documentMutationApi.update(item.id, {
               documentName: documentName.trim(),
@@ -621,6 +679,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
               securityLevel,
               tags: parseTags(tags),
               rowVersion: detail.rowVersion,
+              ...organizationPatch,
             });
       cacheDetail(attachment ? "attachment" : "document", latest);
       invalidatePreview(item.id);
@@ -769,6 +828,24 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
     setRelationRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, ...patch } : row));
   };
 
+  /** 管理员重新解析正文:成功后回写详情(含 rowVersion),并刷新预览/列表缓存 */
+  const reExtract = async () => {
+    if (!detail || reExtracting) return;
+    setReExtracting(true);
+    setReExtractError(null);
+    try {
+      const latest = await documentMutationApi.reExtract(item.id);
+      setDetail(latest);
+      cacheDetail(attachment ? "attachment" : "document", latest);
+      invalidatePreview(item.id);
+      notifyContentChanged(attachment ? "attachments" : "documents");
+    } catch (value) {
+      setReExtractError(asMutationError(value));
+    } finally {
+      setReExtracting(false);
+    }
+  };
+
   return (
     <Modal title={`编辑 · ${item.documentName}`} onClose={onClose}>
       <div className="p-6 space-y-3">
@@ -881,6 +958,42 @@ 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 }}>
+                <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">
+                  失败原因:{detail.contentExtractError ?? "未知错误"}。可点击"重新解析"重试,若持续失败请检查文件是否损坏或加密。
+                </div>
+              )}
+              {detail.contentExtractStatus === ContentExtractStatus.EMPTY && (
+                <div className="text-xs 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">
+                  <RefreshCw size={12} className={reExtracting ? "inline animate-spin mr-1" : "inline mr-1"} />{reExtracting ? "解析中…" : "重新解析"}
+                </button>
+              </div>
+            </div>
+          )}
+          <Field label="所属单位">
+            <select
+              value={organizationId}
+              disabled={!canManageOrganization || organizations.length === 0}
+              onChange={(event) => setOrganizationId(event.target.value)}
+              className={inputClass}
+              style={selectStyle}
+              data-testid="organization-select"
+            >
+              {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>}
+          </Field>
           {/* 概述/标签栏已按要求隐藏:state 保留服务端原值,保存时原样提交,不改动既有数据 */}
         </>}
         <ErrorBlock error={error} />

+ 42 - 0
frontend/src/features/mutations/__tests__/mutationApi.test.ts

@@ -107,6 +107,34 @@ describe("F5 mutation APIs", () => {
     expect(mock.delete).toHaveBeenCalledWith("documents/10", { query: { rowVersion: 3 }, signal: undefined });
   });
 
+  it("编辑请求可选携带字符串organizationId,非法取值被拒绝", async () => {
+    const mock = client(detail);
+    const request = {
+      documentName: "新名称",
+      summary: null,
+      categoryId: "1",
+      securityLevel: "SECRET" as const,
+      tags: [],
+      rowVersion: 3,
+      organizationId: "10",
+    };
+    await createDocumentMutationApi(mock).update("10", request);
+    expect(mock.put).toHaveBeenCalledWith("documents/10", request, { signal: undefined });
+    // 未设置所属单位时不提交该键,由服务端沿用现有归属
+    const withoutOrganization = { ...request };
+    delete (withoutOrganization as Partial<typeof request>).organizationId;
+    await createDocumentMutationApi(mock).update("10", withoutOrganization);
+    expect(mock.put).toHaveBeenLastCalledWith("documents/10", withoutOrganization, { signal: undefined });
+    // number ID、空串不符合契约
+    await expect(createDocumentMutationApi(mock).update("10", { ...request, organizationId: 10 } as never)).rejects.toThrow();
+    await expect(createDocumentMutationApi(mock).update("10", { ...request, organizationId: "" } as never)).rejects.toThrow();
+    // 附件编辑同样支持可选organizationId
+    const attachmentRequest = { documentName: "新附件", attachmentType: "TYPICAL_CASE" as const, summary: null, tags: [], rowVersion: 2, organizationId: "10" };
+    await createAttachmentMutationApi(mock).update("7", attachmentRequest);
+    expect(mock.put).toHaveBeenLastCalledWith("attachments/7", attachmentRequest, { signal: undefined });
+    await expect(createAttachmentMutationApi(mock).update("7", { ...attachmentRequest, organizationId: "abc" } as never)).rejects.toThrow();
+  });
+
   it("1.4主案和子方案请求严格排除废止字段", () => {
     const main = JSON.parse(String(documentUploadForm(file, mainMetadata).get("metadata")));
     const sub = JSON.parse(String(documentUploadForm(file, subMetadata).get("metadata")));
@@ -154,4 +182,18 @@ describe("F5 mutation APIs", () => {
     expect(() => batchImportForm([file], [])).toThrow();
     expect(() => documentUploadForm(file, { ...mainMetadata, fileHash: "x" } as never)).toThrow();
   });
+
+  it("重新解析使用 POST documents/{id}/re-extract 并校验详情契约", async () => {
+    const adminDetail = {
+      ...detail,
+      contentExtractStatus: "FAILED",
+      contentExtractError: "PdfReadError: 文件损坏",
+      contentExtractedAt: "2026-08-27T03:00:00.000Z",
+    };
+    const mock = client(adminDetail);
+    const latest = await createDocumentMutationApi(mock).reExtract("10");
+    expect(mock.post).toHaveBeenCalledWith("documents/10/re-extract", undefined, { signal: undefined });
+    expect(latest.contentExtractStatus).toBe("FAILED");
+    expect(latest.contentExtractError).toBe("PdfReadError: 文件损坏");
+  });
 });

+ 44 - 0
frontend/src/features/mutations/__tests__/organizationEditScope.test.ts

@@ -0,0 +1,44 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const app = readFileSync(new URL("../../../app/App.tsx", import.meta.url), "utf8");
+const modal = readFileSync(new URL("../MutationModals.tsx", import.meta.url), "utf8");
+const mutationTypes = readFileSync(new URL("../mutationTypes.ts", import.meta.url), "utf8");
+const directoryState = readFileSync(new URL("../../directory/directoryState.ts", import.meta.url), "utf8");
+
+describe("所属单位编辑 scope and UI capabilities", () => {
+  it("编辑弹窗提供所属单位下拉:管理员可选启用部门,非管理员只读展示", () => {
+    expect(modal).toContain("canManageOrganization");
+    expect(modal).toContain('data-testid="organization-select"');
+    expect(modal).toContain("所属单位");
+    expect(modal).toContain("未设置单位");
+    // 非管理员提示不可调整
+    expect(modal).toContain("调整所属单位需管理员权限");
+  });
+
+  it("管理员按启用部门树加载选项,失败时退化为仅显示当前归属", () => {
+    expect(modal).toContain('organizationApi.getTree({ status: "ENABLED" }');
+    expect(modal).toContain("flattenOrganizations");
+    // 当前归属不在启用列表时补兜底项,保证原值可回显
+    expect(modal).toContain("detail?.organizationName ?? organizationId");
+    expect(directoryState).toContain("export function flattenOrganizations");
+  });
+
+  it("保存时携带organizationId,历史空归属不提交该键由服务端沿用", () => {
+    expect(modal).toContain("organizationPatch");
+    expect(modal).toContain("...organizationPatch");
+    // 编辑后刷新列表归属冗余字段
+    expect(modal).toContain("setOrganizationId(latest.organizationId ?? \"\")");
+  });
+
+  it("编辑入口按管理员角色传入所属单位维护标志", () => {
+    expect(app).toContain("canManageOrganization={isAdmin}");
+    // 共享附件库编辑弹窗同样透传管理员标志
+    expect(app).toContain("canManageOrganization={canManageOrganization}");
+  });
+
+  it("更新请求契约将organizationId声明为可选字符串ID", () => {
+    expect(mutationTypes).toContain("organizationId?: Id");
+    expect(mutationTypes).toContain('isId(value.organizationId)');
+  });
+});

+ 5 - 3
frontend/src/features/mutations/__tests__/passwordScope.test.ts

@@ -44,10 +44,12 @@ describe("方案密码锁 scope and UI capabilities", () => {
     expect(unbind).toContain('DocumentPasswordAction.UNBIND_ATTACHMENT');
   });
 
-  it("全局密码弹窗挂载于应用根部且管理员编辑入口传入 canManagePassword", () => {
+  it("全局密码弹窗挂载于应用根部且编辑入口按文档授权位传入 canManagePassword", () => {
     expect(app).toContain("<PasswordPromptModal />");
-    // 主案编辑入口(后台 RealAdminPage)传入;附件库编辑对象非主案不传
-    expect(app.match(/canManagePassword=\{currentUser\.roleCode === RoleCode\.ADMIN\}/g)).toHaveLength(1);
+    // 密码锁=文档级安全配置:按 CONFIG_PERMISSION 授权位传入(管理员恒有)
+    expect(app).toContain(
+      "canManagePassword={edit.allowedActions.includes(AllowedAction.CONFIG_PERMISSION)}",
+    );
   });
 
   it("密码弹窗支持Enter确认/Escape取消并覆盖7种操作文案", () => {

+ 4 - 2
frontend/src/features/mutations/__tests__/relationScope.test.ts

@@ -31,8 +31,10 @@ describe("方案关联关系 scope and UI capabilities", () => {
     expect(preview).toContain("const current = navigated ?? item");
   });
 
-  it("主案编辑入口传入管理员标志,附件库编辑入口不传", () => {
-    expect(app.match(/canManageRelations=\{currentUser\.roleCode === RoleCode\.ADMIN\}/g)).toHaveLength(1);
+  it("编辑入口按文档 EDIT 授权位传入关联维护标志", () => {
+    expect(app).toContain(
+      "canManageRelations={edit.allowedActions.includes(AllowedAction.EDIT)}",
+    );
   });
 
   it("API client 覆盖预置清单/查询/保存三个端点", () => {

+ 7 - 0
frontend/src/features/mutations/documentMutationApi.ts

@@ -130,6 +130,13 @@ export function createDocumentMutationApi(client: MutationClient = httpClient) {
       return result.data;
     },
 
+    /** 管理员对存量文档重新提取正文(修复提取失败/空文本) */
+    async reExtract(id: string, signal?: AbortSignal): Promise<DocumentDetail> {
+      const result = await client.post<unknown>(`documents/${id}/re-extract`, undefined, { signal });
+      if (!isDocumentDetail(result.data)) throw contractError(result, "重新解析");
+      return result.data;
+    },
+
     /** 管理员整体替换方案的关联关系(rowVersion 乐观锁;锁定主案需 EDIT 密码) */
     async updateRelations(
       id: string,

+ 23 - 2
frontend/src/features/mutations/mutationTypes.ts

@@ -55,6 +55,8 @@ export interface MainDocumentUpdateRequest {
   securityLevel: SecurityLevelCode;
   tags: string[];
   rowVersion: number;
+  /** 所属单位(归属部门):缺省沿用现有归属;调整到其他部门仅管理员 */
+  organizationId?: Id;
 }
 
 export interface SubPlanUpdateRequest {
@@ -63,6 +65,8 @@ export interface SubPlanUpdateRequest {
   securityLevel: SecurityLevelCode;
   tags: string[];
   rowVersion: number;
+  /** 所属单位(归属部门):缺省沿用现有归属;调整到其他部门仅管理员 */
+  organizationId?: Id;
 }
 
 export type UpdateDocumentRequest = MainDocumentUpdateRequest | SubPlanUpdateRequest;
@@ -73,6 +77,8 @@ export interface UpdateAttachmentRequest {
   summary: string | null;
   tags: string[];
   rowVersion: number;
+  /** 所属单位(归属部门):缺省沿用现有归属;调整到其他部门仅管理员 */
+  organizationId?: Id;
 }
 
 /** 设置/修改/清除方案密码:password+protectedActions 成对为设密,仅 oldPassword 为清除 */
@@ -227,15 +233,30 @@ export function isUpdateDocumentRequest(value: unknown): value is UpdateDocument
     isTags(value.tags) &&
     isNonNegativeInteger(value.rowVersion);
   if (!common) return false;
+  // organizationId 可选:存在时必须是字符串ID(空值不提交,由服务端沿用现有归属)
+  if ("organizationId" in value && !isId(value.organizationId)) return false;
+  const organizationKeys = "organizationId" in value ? ["organizationId"] : [];
   const mainKeys = ["documentName", "summary", "categoryId", "securityLevel", "tags", "rowVersion"];
   const subKeys = ["documentName", "summary", "securityLevel", "tags", "rowVersion"];
-  return (hasOnlyKeys(value, mainKeys) && isId(value.categoryId)) || hasOnlyKeys(value, subKeys);
+  return (hasOnlyKeys(value, [...mainKeys, ...organizationKeys]) && isId(value.categoryId)) ||
+    hasOnlyKeys(value, [...subKeys, ...organizationKeys]);
 }
 
 export function isUpdateAttachmentRequest(value: unknown): value is UpdateAttachmentRequest {
   return (
     isRecord(value) &&
-    hasOnlyKeys(value, ["documentName", "attachmentType", "summary", "tags", "rowVersion"]) &&
+    ("organizationId" in value ? isId(value.organizationId) : true) &&
+    hasOnlyKeys(
+      value,
+      [
+        "documentName",
+        "attachmentType",
+        "summary",
+        "tags",
+        "rowVersion",
+        ...("organizationId" in value ? ["organizationId"] : []),
+      ],
+    ) &&
     typeof value.documentName === "string" &&
     value.documentName.length > 0 &&
     isEnum(AttachmentType, value.attachmentType) &&

+ 5 - 1
frontend/src/features/permissions/__tests__/f6Scope.test.ts

@@ -20,7 +20,11 @@ describe("F6 UI scope and safety", () => {
     expect(app).toContain("AllowedAction.BIND_ATTACHMENT");
     expect(app).toContain("AllowedAction.UNBIND_ATTACHMENT");
     expect(app).toContain("AllowedAction.CONFIG_PERMISSION");
-    expect(app).not.toMatch(/roleCode[^]*BIND_ATTACHMENT/);
+    // 挂载/解除挂载仍由 allowedActions 驱动;管理员专属写接口在入口处
+    // 额外叠加 isAdmin 门禁(roleCode 判定与 BIND_ATTACHMENT 并存属预期)。
+    expect(app).toContain(
+      "selected.allowedActions.includes(AllowedAction.BIND_ATTACHMENT)",
+    );
   });
 
   it("marks existing bindings and supports preserved multi-selection", () => {

+ 6 - 8
frontend/src/features/recycle-bin/RecycleBinModal.tsx

@@ -11,6 +11,7 @@ import {
 import { flattenCategoryOptions } from "../categories/categoryTree";
 import { useCategoryTree as useRealCategoryTree } from "../categories/categoryState";
 import { useDictionaries } from "../dictionaries/dictionaryContext";
+import { tipProps } from "../../shared/ui/hoverTip";
 import {
   deletedByLabel,
   formatLocalDate,
@@ -26,8 +27,6 @@ import { RestoreDocumentModal } from "./RestoreDocumentModal";
 
 const NAVY = "#1A5299";
 const BLUE = "#3191eb";
-const WHITE = "#fff";
-
 export function RecycleBinModal({ onClose }: { onClose: () => void }) {
   const dictionaries = useDictionaries();
   const [keyword, setKeyword] = useState("");
@@ -155,7 +154,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
           </select>
           <input
             type="datetime-local"
-            title="删除开始时间(含)"
+            {...tipProps("删除开始时间(含)")}
             value={deletedFromLocal}
             onChange={(event) => {
               setDeletedFromLocal(event.target.value);
@@ -165,7 +164,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
           />
           <input
             type="datetime-local"
-            title="删除结束时间(不含)"
+            {...tipProps("删除结束时间(不含)")}
             value={deletedToLocal}
             onChange={(event) => {
               setDeletedToLocal(event.target.value);
@@ -199,7 +198,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
           </select>
           <div className="col-span-1 flex gap-1">
             <button
-              title="清空筛选"
+              {...tipProps("清空筛选")}
               onClick={clearFilters}
               className="flex-1 h-9 border"
               style={{ borderColor: BLUE }}
@@ -207,7 +206,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
               <Filter size={13} className="mx-auto" />
             </button>
             <button
-              title="刷新"
+              {...tipProps("刷新")}
               onClick={list.refresh}
               className="flex-1 h-9 text-white"
               style={{ background: BLUE }}
@@ -286,11 +285,10 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
                 {list.data.items.map((item, index) => (
                   <tr
                     key={item.id}
-                    className="border-b"
+                    className={`border-b transition-colors hover:bg-[rgba(49,145,235,0.18)] ${index % 2 ? "bg-[rgba(40,118,193,.04)]" : "bg-white"}`}
                     style={{
                       color: NAVY,
                       borderColor: "rgba(40,118,193,.1)",
-                      background: index % 2 ? "rgba(40,118,193,.04)" : WHITE,
                     }}
                   >
                     <td className="px-3 py-2 text-xs font-black">

+ 2 - 1
frontend/src/features/recycle-bin/__tests__/f8Scope.test.ts

@@ -21,8 +21,9 @@ const listModal = readFileSync(
 
 describe("F8 scope and UI boundaries", () => {
   it("后台已按需求移除回收站入口,组件保留但不再挂载", () => {
+    // 管理页按模块权限动态可见(角色门禁已升级为权限门禁)
     expect(app).toContain(
-      'page === "admin" && currentUser.roleCode === RoleCode.ADMIN',
+      'page === "admin" && isPageAllowed("admin", currentUser.allowedModules)',
     );
     expect(app).not.toContain("<RecycleBinModal");
     expect(app).not.toContain("RecycleBinModal");

+ 24 - 0
frontend/src/shared/api/enums.ts

@@ -26,6 +26,14 @@ export const DocumentStatus = {
 } as const;
 export type DocumentStatus = (typeof DocumentStatus)[keyof typeof DocumentStatus];
 
+export const ContentExtractStatus = {
+  SUCCESS: "SUCCESS",
+  EMPTY: "EMPTY",
+  UNSUPPORTED: "UNSUPPORTED",
+  FAILED: "FAILED",
+} as const;
+export type ContentExtractStatus = (typeof ContentExtractStatus)[keyof typeof ContentExtractStatus];
+
 export const SecurityLevel = {
   PUBLIC: "PUBLIC",
   INTERNAL: "INTERNAL",
@@ -125,6 +133,9 @@ export const AuditActionType = {
   CHANGE_PERMISSION: "CHANGE_PERMISSION",
   BIND_ATTACHMENT: "BIND_ATTACHMENT",
   UNBIND_ATTACHMENT: "UNBIND_ATTACHMENT",
+  REORDER_DOCUMENT: "REORDER_DOCUMENT",
+  REORDER_ATTACHMENT: "REORDER_ATTACHMENT",
+  REORDER_CATEGORY: "REORDER_CATEGORY",
   RESTORE_DOCUMENT: "RESTORE_DOCUMENT",
   CREATE_ORGANIZATION: "CREATE_ORGANIZATION",
   EDIT_ORGANIZATION: "EDIT_ORGANIZATION",
@@ -133,6 +144,17 @@ export const AuditActionType = {
   EDIT_USER: "EDIT_USER",
   DELETE_USER: "DELETE_USER",
   RESET_USER_PASSWORD: "RESET_USER_PASSWORD",
+  CREATE_ROLE: "CREATE_ROLE",
+  EDIT_ROLE: "EDIT_ROLE",
+  DELETE_ROLE: "DELETE_ROLE",
+  CHANGE_ROLE_PERMISSION: "CHANGE_ROLE_PERMISSION",
+  CHANGE_ROLE_USER: "CHANGE_ROLE_USER",
+  DOCUMENT_PASSWORD_SET: "DOCUMENT_PASSWORD_SET",
+  DOCUMENT_PASSWORD_REMOVE: "DOCUMENT_PASSWORD_REMOVE",
+  CREATE_IP_ENTRY: "CREATE_IP_ENTRY",
+  EDIT_IP_ENTRY: "EDIT_IP_ENTRY",
+  DELETE_IP_ENTRY: "DELETE_IP_ENTRY",
+  ASSIGN_ORG_IP: "ASSIGN_ORG_IP",
 } as const;
 export type AuditActionType =
   (typeof AuditActionType)[keyof typeof AuditActionType];
@@ -146,6 +168,8 @@ export const AuditTargetType = {
   ATTACHMENT_BINDING: "ATTACHMENT_BINDING",
   ORGANIZATION: "ORGANIZATION",
   USER: "USER",
+  ROLE: "ROLE",
+  IP_ENTRY: "IP_ENTRY",
 } as const;
 export type AuditTargetType =
   (typeof AuditTargetType)[keyof typeof AuditTargetType];

Some files were not shown because too many files changed in this diff