Bladeren bron

1. 实现了文档更新时间范围检索,统一主方案、子方案与附件查询;

2. 新增了文档搜索工具栏,支持关键词、状态类型和时间筛选;

3. 优化了分类计数与管理交互,准确展示分支主方案数量;

4. 新增了演示数据与导入导出工具,补充多角色界面示例;

5. 优化了回收站与权限等弹窗,简化文案并修复遮挡问题;

6. 更新了接口契约与工程文档,补充查询边界和验证说明。
kaywxs 1 week geleden
bovenliggende
commit
873fc549be
63 gewijzigde bestanden met toevoegingen van 1783 en 204 verwijderingen
  1. 10 1
      CODEX_BACKEND_PERSISTENCE_GUIDE.md
  2. 13 3
      DMS_API_CONTRACT.md
  3. 16 3
      DMS_FUNCTION_CONTRACT.md
  4. 10 1
      FUNCTION_AND_API_SPECIFICATION.md
  5. 5 1
      backend/app.py
  6. 46 0
      backend/dms/common/time_range.py
  7. 345 0
      backend/dms/seed_showcase.py
  8. 7 3
      backend/dms/services/attachment_query_service.py
  9. 18 1
      backend/dms/services/category_service.py
  10. 8 11
      backend/dms/services/document_query_service.py
  11. 16 0
      backend/openapi/components/parameters.yaml
  12. 2 2
      backend/openapi/openapi.yaml
  13. 8 4
      backend/openapi/paths/documents.yaml
  14. 1 1
      backend/tests/dms/test_http.py
  15. 2 2
      backend/tests/dms/test_openapi_storage.py
  16. 1 1
      backend/tests/dms/test_q1b_ui_dictionaries.py
  17. 371 0
      backend/tests/dms/test_q2b_updated_range.py
  18. BIN
      docs/assets/demo-20260728/auditor/01_操作日志与统计.png
  19. BIN
      docs/assets/demo-20260728/普通用户界面/01_文档浏览与直属子方案.png
  20. BIN
      docs/assets/demo-20260728/普通用户界面/02_共享附件库.png
  21. BIN
      docs/assets/demo-20260728/登录界面/01_登录页.png
  22. BIN
      docs/assets/demo-20260728/管理员/01_后台方案管理.png
  23. BIN
      docs/assets/demo-20260728/管理员/02_后台共享附件库.png
  24. BIN
      docs/assets/demo-20260728/管理员/03_文档回收站.png
  25. BIN
      docs/assets/demo-20260728/管理员/04 上传方案文档.jpg
  26. BIN
      docs/assets/demo-20260728/管理员/05 批量导入.png
  27. BIN
      docs/assets/demo-20260728/管理员/06 文件浏览.jpg
  28. BIN
      docs/assets/demo-20260728/管理员/07 方案详情.png
  29. BIN
      docs/assets/demo-20260728/管理员/08 修改分类.png
  30. 1 1
      frontend/README.md
  31. 2 3
      frontend/index.html
  32. 201 76
      frontend/src/app/App.tsx
  33. 18 0
      frontend/src/features/attachments/__tests__/attachmentApi.test.ts
  34. 25 7
      frontend/src/features/attachments/attachmentState.ts
  35. 2 0
      frontend/src/features/attachments/attachmentTypes.ts
  36. 1 1
      frontend/src/features/audit/AuditDashboard.tsx
  37. 16 1
      frontend/src/features/audit/__tests__/auditTypes.test.ts
  38. 6 4
      frontend/src/features/auth/__tests__/appScope.test.ts
  39. 3 3
      frontend/src/features/bindings/AttachmentBindingModal.tsx
  40. 1 7
      frontend/src/features/bindings/UnbindAttachmentModal.tsx
  41. 9 0
      frontend/src/features/categories/__tests__/categoryTree.test.ts
  42. 7 0
      frontend/src/features/categories/categoryTree.ts
  43. 113 0
      frontend/src/features/documents/DocumentSearchToolbar.tsx
  44. 20 1
      frontend/src/features/documents/__tests__/documentApi.test.ts
  45. 44 0
      frontend/src/features/documents/__tests__/documentSearch.test.ts
  46. 50 0
      frontend/src/features/documents/__tests__/q2fScope.test.ts
  47. 38 0
      frontend/src/features/documents/documentSearch.ts
  48. 33 8
      frontend/src/features/documents/documentState.ts
  49. 2 0
      frontend/src/features/documents/documentTypes.ts
  50. 8 8
      frontend/src/features/files/FilePreviewModal.tsx
  51. 1 1
      frontend/src/features/files/filename.ts
  52. 6 9
      frontend/src/features/mutations/MutationModals.tsx
  53. 2 2
      frontend/src/features/mutations/__tests__/f5Scope.test.ts
  54. 11 14
      frontend/src/features/permissions/PermissionModal.tsx
  55. 7 7
      frontend/src/features/permissions/__tests__/f6Scope.test.ts
  56. 6 7
      frontend/src/features/recycle-bin/RecycleBinModal.tsx
  57. 0 7
      frontend/src/features/recycle-bin/RestoreDocumentModal.tsx
  58. 7 0
      frontend/src/features/recycle-bin/__tests__/f8Scope.test.ts
  59. 2 3
      frontend/src/features/statistics/DocumentStatisticsCards.tsx
  60. 1 0
      frontend/src/shared/api/enums.ts
  61. 155 0
      tools/export_full_demo_data.py
  62. 106 0
      tools/verify_full_demo_import.py
  63. 0 0
      verified.md

+ 10 - 1
CODEX_BACKEND_PERSISTENCE_GUIDE.md

@@ -1,6 +1,6 @@
 # 文档管理系统后端持久化实施指导(供 Codex 使用)
 
-> Q1-B统一UI字典同步版本:1.2。实现必须服从 `DMS_FUNCTION_CONTRACT.md` 1.2和 `DMS_API_CONTRACT.md` 1.2
+> Q2-B更新时间范围检索同步版本:1.3。实现必须服从 `DMS_FUNCTION_CONTRACT.md` 1.3和 `DMS_API_CONTRACT.md` 1.3
 
 ## 1. 文档目的
 
@@ -1102,3 +1102,12 @@ RESTORE_RELATION_CONFLICT      409
 - 本阶段不提供热更新或配置写接口;修改配置后重启各服务进程生效。
 - 不新增数据库表、字段或迁移,不修改既有枚举值和业务接口语义。
 - OpenAPI升级为 `1.2.0`,只增加字典查询路径及对应DTO、401和500响应。
+
+## 21. Q2-B更新时间范围检索实施指导
+
+- 使用一个公共解析方法处理三个接口的 `updatedFrom`、`updatedTo`,要求ISO 8601且包含明确时区,统一转换为UTC无时区值后与MySQL UTC `DATETIME`比较。
+- `updatedFrom`采用 `>=`,`updatedTo`采用 `<=`;同时存在时先验证开始时间不晚于结束时间。
+- 方案和子方案过滤 `doc_document.updated_at`;已挂载附件仍过滤附件对应的 `doc_document.updated_at`,禁止过滤 `doc_attachment_binding`时间或排序号。
+- SQLAlchemy `where`条件必须在查询执行和分页前形成,不得在Python中进行时间过滤。
+- 保持权限、密级、状态、排序、字符串ID、DTO、计数和审计不变。
+- 本轮不新增索引、迁移或数据库对象;OpenAPI升级为 `1.3.0`,Office预览使用后续版本。

+ 13 - 3
DMS_API_CONTRACT.md

@@ -1,7 +1,7 @@
 # 方案计划文档管理系统接口契约
 
-> 契约版本:1.2  
-> 状态:已确认,Q1-B统一UI字典接口基线  
+> 契约版本:1.3
+> 状态:已确认,Q2-B更新时间范围检索接口基线
 > 外部字段风格:`camelCase`  
 > 数据库字段风格:`snake_case`
 
@@ -631,6 +631,8 @@ rowVersion=<integer>
 
 关键词搜索也使用本接口,不建立独立 `/search` 路径。
 
+`updatedFrom`作用为 `updated_at >= updatedFrom`,`updatedTo`作用为 `updated_at <= updatedTo`;同时提供时为闭区间,且 `updatedFrom`不得晚于 `updatedTo`。时间参数必须是包含明确时区的ISO 8601时间,前端统一发送UTC `Z`格式;空字符串、无时区时间、非法格式或反向区间返回 `400 INVALID_ARGUMENT`。时间条件在数据库查询和分页之前执行。
+
 返回 `PageResult<DocumentSummary>`。
 
 ### 9.2 `POST /api/v1/documents`
@@ -751,12 +753,14 @@ rowVersion=<integer>
 
 - `keyword`
 - `status`
+- `updatedFrom`
+- `updatedTo`
 - `page`
 - `pageSize`
 - `sortBy`
 - `sortDirection`
 
-返回 `PageResult<DocumentSummary>`。`id` 不是有效主案时返回 `404 MAIN_PLAN_NOT_FOUND`。
+时间范围作用于直属子方案自身的 `updated_at`,语义和错误规则与9.1相同。返回 `PageResult<DocumentSummary>`。`id` 不是有效主案时返回 `404 MAIN_PLAN_NOT_FOUND`。
 
 ## 10. 共享附件
 
@@ -870,6 +874,8 @@ details.mainPlans
 
 - `keyword`
 - `attachmentType`
+- `updatedFrom`
+- `updatedTo`
 - `page`
 - `pageSize`
 
@@ -883,6 +889,8 @@ details.mainPlans
 }
 ```
 
+时间范围作用于附件文档自身的 `updated_at`,不使用 `doc_attachment_binding`的创建时间、更新时间或 `bindingSortNo`;语义和错误规则与9.1相同。
+
 ### 11.2 `POST /api/v1/main-plans/{id}/attachments/bind`
 
 管理员接口:
@@ -1384,3 +1392,5 @@ GET /api/v1/config/ui-dictionaries
 版本1.1经统领会话批准,增加独立回收站查询和单条文档恢复,旧文档恢复路径继续永久废止。
 
 版本1.2经统领会话批准,增加 `GET /api/v1/config/ui-dictionaries` 只读接口和严格启动配置规则;既有业务路径、枚举代码及数据库值不变,OpenAPI同步升级为 `1.2.0`。
+
+版本1.3经统领会话批准,为方案、直属子方案和已挂载附件三个既有查询接口统一增加文档更新时间闭区间筛选;不新增路径、DTO、数据库字段或迁移,OpenAPI同步升级为 `1.3.0`。Office预览使用1.4或后续版本。

+ 16 - 3
DMS_FUNCTION_CONTRACT.md

@@ -1,7 +1,7 @@
 # 方案计划文档管理系统功能契约
 
-> 契约版本:1.2  
-> 状态:已确认,Q1-B统一UI字典执行基线  
+> 契约版本:1.3
+> 状态:已确认,Q2-B更新时间范围检索执行基线
 > 适用范围:前端、后端、数据库、联调、测试与验收
 
 ## 1. 契约效力
@@ -471,6 +471,7 @@ INDEX(is_deleted, deleted_at, id)
 10. 任何契约偏差均已经统领会话书面确认。
 11. 回收站和三类文档恢复符合文件完整性、关系恢复、乐观锁和审计规则。
 12. 所有正式界面枚举的中文展示标签由后端统一UI字典配置提供,前端不重复维护同义映射。
+13. 主案、全部方案、最近更新、直属子方案和主案已挂载附件均支持基于文档自身 `updated_at` 的统一UTC时间范围检索。
 
 ## 15A. 统一UI展示字典
 
@@ -483,6 +484,16 @@ INDEX(is_deleted, deleted_at, id)
 7. 不提供UI字典新增、编辑、删除或刷新接口;接口不得返回配置文件路径、加载时间、文件内容或内部异常。
 8. 认证仍遵循现有登录状态和令牌失效校验规则;字典读取不额外引入业务数据查询。
 
+## 15B. Q2-B更新时间范围检索
+
+1. `GET /api/v1/documents`、`GET /api/v1/main-plans/{id}/sub-plans`和`GET /api/v1/main-plans/{id}/attachments`统一支持可选的 `updatedFrom`、`updatedTo`。
+2. `updatedFrom`表示文档 `updated_at >= updatedFrom`,`updatedTo`表示文档 `updated_at <= updatedTo`;同时提供时为闭区间,且开始时间不得晚于结束时间。
+3. 子方案按子方案自身更新时间检索;已挂载附件按附件文档自身更新时间检索,不使用挂载关系的创建时间、更新时间或排序号。
+4. 参数必须是包含明确时区的ISO 8601时间。前端统一发送UTC `Z`格式;后端接受合法带时区ISO 8601并归一化为UTC,禁止把无时区时间当作UTC。
+5. 空字符串、非法时间和反向区间统一返回 `400 INVALID_ARGUMENT`及requestId。
+6. 时间条件必须在数据库查询层、权限可见性判定和分页之前执行,不得读取完整结果后用Python过滤。
+7. 检索不增加查看次数、下载次数或审计记录,不改变既有权限、密级、状态、排序、字符串ID、DTO和附件共享规则。
+
 ## 16. 变更控制
 
 执行会话不得直接修改本文件。需要变更时,应:
@@ -496,4 +507,6 @@ INDEX(is_deleted, deleted_at, id)
 
 版本1.2经统领会话批准,引入只读统一UI字典配置和查询接口,不修改既有枚举、数据库结构或业务语义。
 
-Q1-B实现时必须同步将OpenAPI `info.version`升级为 `1.2.0`。
+版本1.3经统领会话批准,统一三个既有列表接口的文档更新时间范围检索,不新增路径、数据库字段、索引或迁移;Office预览不得占用1.3版本。
+
+Q2-B实现时必须同步将OpenAPI `info.version`升级为 `1.3.0`。

+ 10 - 1
FUNCTION_AND_API_SPECIFICATION.md

@@ -1,6 +1,6 @@
 # 方案计划文档管理系统功能与接口规格
 
-> Q1-B统一UI字典同步版本:1.2。实现以 `DMS_FUNCTION_CONTRACT.md` 1.2和 `DMS_API_CONTRACT.md` 1.2为最终依据。
+> Q2-B更新时间范围检索同步版本:1.3。实现以 `DMS_FUNCTION_CONTRACT.md` 1.3和 `DMS_API_CONTRACT.md` 1.3为最终依据。
 
 ## 1. 文档目的
 
@@ -895,3 +895,12 @@ HTTP状态、message和details结构以 `DMS_API_CONTRACT.md` 1.1为准,响应
 - 字典查询不产生业务审计,字典读取本身不访问数据库;现有认证基础设施继续执行令牌和用户状态校验。
 - 不新增数据库表、字段或迁移,不提供配置写接口,不修改既有业务语义。
 - Q1-B实现同步将OpenAPI `info.version`升级为 `1.2.0`。
+
+## 15. Q2-B更新时间范围检索补充
+
+- `GET /api/v1/documents`、`GET /api/v1/main-plans/{id}/sub-plans`、`GET /api/v1/main-plans/{id}/attachments`统一接受可选 `updatedFrom`和 `updatedTo`。
+- 两个边界分别表示文档自身 `updated_at >= updatedFrom`和 `updated_at <= updatedTo`,同时提供时为闭区间。
+- 子方案使用子方案自身更新时间;挂载附件使用附件文档自身更新时间,不使用挂载关系时间或排序号。
+- 时间必须是包含明确时区的ISO 8601格式,前端统一发送UTC `Z`;空字符串、无时区、非法格式和反向区间返回 `400 INVALID_ARGUMENT`。
+- 时间条件在数据库查询和分页前执行,且不改变权限、密级、状态、排序、DTO、计数和审计语义。
+- 不新增数据库、字段、索引或迁移;OpenAPI升级为 `1.3.0`。

+ 5 - 1
backend/app.py

@@ -556,4 +556,8 @@ def annotation():
 
 
 if __name__ == "__main__":
-    app.run(host="0.0.0.0", port=8754, debug=False)
+    app.run(
+        host=os.environ.get("DMS_HOST", "0.0.0.0"),
+        port=int(os.environ.get("DMS_PORT", "8755")),
+        debug=False,
+    )

+ 46 - 0
backend/dms/common/time_range.py

@@ -0,0 +1,46 @@
+"""DMS查询接口统一的带时区ISO 8601时间范围解析。"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Mapping
+
+from dms.common.errors import InvalidArgumentError
+
+
+def parse_datetime(value: str | None, *, name: str) -> datetime | None:
+    """解析带明确时区的ISO 8601时间并转换为UTC无时区值。"""
+    if value is None:
+        return None
+    if not isinstance(value, str) or not value.strip():
+        raise InvalidArgumentError(f"{name}必须是包含时区的ISO 8601时间")
+    normalized = value.strip()
+    if normalized.endswith(("Z", "z")):
+        normalized = normalized[:-1] + "+00:00"
+    try:
+        parsed = datetime.fromisoformat(normalized)
+    except ValueError as exc:
+        raise InvalidArgumentError(
+            f"{name}必须是包含时区的ISO 8601时间"
+        ) from exc
+    if parsed.tzinfo is None or parsed.utcoffset() is None:
+        raise InvalidArgumentError(f"{name}必须包含明确时区")
+    return parsed.astimezone(timezone.utc).replace(tzinfo=None)
+
+
+def parse_updated_range(
+    params: Mapping[str, str],
+) -> tuple[datetime | None, datetime | None]:
+    """解析updatedFrom/updatedTo并校验闭区间方向。"""
+    updated_from = parse_datetime(params.get("updatedFrom"), name="updatedFrom")
+    updated_to = parse_datetime(params.get("updatedTo"), name="updatedTo")
+    if (
+        updated_from is not None
+        and updated_to is not None
+        and updated_from > updated_to
+    ):
+        raise InvalidArgumentError("updatedFrom不得晚于updatedTo")
+    return updated_from, updated_to
+
+
+__all__ = ["parse_datetime", "parse_updated_range"]

+ 345 - 0
backend/dms/seed_showcase.py

@@ -0,0 +1,345 @@
+"""幂等补充业务展示分类、方案、附件、挂载和权限。"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from flask import Flask, current_app
+from sqlalchemy import func, select
+
+from dms import init_dms
+from dms.common.enums import (
+    AttachmentType,
+    CategoryType,
+    DocumentStatus,
+    DocumentType,
+    EnabledStatus,
+    SecurityLevel,
+    SubjectType,
+    VisibilityType,
+)
+from dms.extensions import db
+from dms.models import (
+    AttachmentBinding,
+    Category,
+    Document,
+    Organization,
+    Permission,
+    User,
+)
+from dms.seed_documents import _binding, _document, _permission, _required
+from dms.storage.paths import ensure_storage_directories
+
+
+def _category(
+    *,
+    code: str,
+    name: str,
+    category_type: CategoryType,
+    sort_no: int,
+    parent: Category | None = None,
+) -> tuple[Category, bool]:
+    existing = db.session.scalar(
+        select(Category).where(Category.category_code == code)
+    )
+    if existing is not None:
+        if existing.is_deleted or existing.status != EnabledStatus.ENABLED.value:
+            raise RuntimeError(f"展示分类{code}已存在但不可用")
+        return existing, False
+
+    category_path = (
+        f"{parent.category_path.rstrip('/')}/{name}"
+        if parent is not None
+        else f"/{name}"
+    )
+    category = Category(
+        category_code=code,
+        category_name=name,
+        category_type=category_type.value,
+        parent_id=parent.id if parent is not None else None,
+        category_path=category_path,
+        sort_no=sort_no,
+        document_count=0,
+        status=EnabledStatus.ENABLED.value,
+    )
+    db.session.add(category)
+    db.session.flush()
+    return category, True
+
+
+def _refresh_counts(categories: list[Category], mains: list[Document]) -> None:
+    for main in mains:
+        main.child_count = db.session.scalar(
+            select(func.count(Document.id)).where(
+                Document.parent_document_id == main.id,
+                Document.document_type == DocumentType.SUB_PLAN.value,
+                Document.is_deleted.is_(False),
+            )
+        ) or 0
+        main.attachment_count = db.session.scalar(
+            select(func.count(AttachmentBinding.id)).where(
+                AttachmentBinding.main_document_id == main.id,
+                AttachmentBinding.is_deleted.is_(False),
+            )
+        ) or 0
+
+    for category in categories:
+        category.document_count = db.session.scalar(
+            select(func.count(Document.id)).where(
+                Document.category_id == category.id,
+                Document.document_type.in_(
+                    [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
+                ),
+                Document.is_deleted.is_(False),
+            )
+        ) or 0
+
+
+def seed_showcase() -> tuple[int, int, int, int, int]:
+    root = ensure_storage_directories(current_app.config["DMS_STORAGE_ROOT"])
+    actor = _required(
+        User,
+        User.username == "admin",
+        User.is_deleted.is_(False),
+        description="有效admin用户",
+    )
+    ordinary_user = _required(
+        User,
+        User.username == "user",
+        User.is_deleted.is_(False),
+        description="有效user用户",
+    )
+    ops = _required(
+        Organization,
+        Organization.org_code == "ORG_OPS",
+        Organization.is_deleted.is_(False),
+        description="ORG_OPS组织",
+    )
+    comms = _required(
+        Organization,
+        Organization.org_code == "ORG_COMMS",
+        Organization.is_deleted.is_(False),
+        description="ORG_COMMS组织",
+    )
+
+    category_specs = (
+        ("DEMO_EMERGENCY", "应急预案体系", CategoryType.SCENE, 10, None),
+        ("DEMO_NATURAL", "自然灾害应急", CategoryType.STYLE, 10, "DEMO_EMERGENCY"),
+        ("DEMO_PUBLIC", "公共安全保障", CategoryType.STYLE, 20, "DEMO_EMERGENCY"),
+        ("DEMO_SUPPORT", "保障资源体系", CategoryType.SCENE, 20, None),
+        ("DEMO_COMMS", "通信保障", CategoryType.SITUATION, 10, "DEMO_SUPPORT"),
+        ("DEMO_MATERIAL", "物资保障", CategoryType.SITUATION, 20, "DEMO_SUPPORT"),
+        ("DEMO_RULES", "制度与模板", CategoryType.SCENE, 30, None),
+        ("DEMO_STANDARD", "工作规范", CategoryType.OTHER, 10, "DEMO_RULES"),
+        ("DEMO_TEMPLATE", "表单模板", CategoryType.OTHER, 20, "DEMO_RULES"),
+    )
+    categories: dict[str, Category] = {}
+    created_categories = 0
+    for code, name, category_type, sort_no, parent_code in category_specs:
+        category, created = _category(
+            code=code,
+            name=name,
+            category_type=category_type,
+            sort_no=sort_no,
+            parent=categories.get(parent_code) if parent_code else None,
+        )
+        categories[code] = category
+        created_categories += int(created)
+
+    new_files: list[Path] = []
+    created_documents = 0
+    existing_documents = 0
+    created_bindings = 0
+    created_permissions = 0
+    try:
+        main_specs = (
+            (
+                "防汛抗旱专项应急预案",
+                "面向汛情、城市内涝和旱情的监测预警、响应处置与恢复重建方案。",
+                "DEMO_NATURAL",
+                SecurityLevel.INTERNAL,
+                VisibilityType.CUSTOM,
+                ["防汛", "抗旱", "应急响应"],
+                ("监测预警子方案", "人员转移安置子方案", "抢险救援子方案"),
+            ),
+            (
+                "重大活动通信保障方案",
+                "保障重大活动期间指挥链路、无线通信和应急通信资源连续可用。",
+                "DEMO_COMMS",
+                SecurityLevel.SECRET,
+                VisibilityType.CUSTOM,
+                ["通信", "重大活动", "链路保障"],
+                ("核心网络保障子方案", "应急通信车调度子方案"),
+            ),
+            (
+                "应急物资储备与调拨方案",
+                "规范应急物资储备布局、库存盘点、需求汇总和跨区域调拨流程。",
+                "DEMO_MATERIAL",
+                SecurityLevel.INTERNAL,
+                VisibilityType.CUSTOM,
+                ["物资", "储备", "调拨"],
+                ("仓储盘点子方案", "跨区域调拨子方案"),
+            ),
+            (
+                "大型活动现场安全保障方案",
+                "覆盖人员疏导、重点区域管控、突发事件处置和多部门协同。",
+                "DEMO_PUBLIC",
+                SecurityLevel.INTERNAL,
+                VisibilityType.ALL_AUTHENTICATED,
+                ["现场安全", "活动保障", "协同处置"],
+                ("人员疏导子方案", "重点区域管控子方案"),
+            ),
+        )
+
+        mains: list[Document] = []
+        for name, summary, category_code, security, visibility, tags, children in main_specs:
+            main, created, failed = _document(
+                root=root,
+                name=name,
+                summary=summary,
+                document_type=DocumentType.MAIN,
+                status=DocumentStatus.PUBLISHED,
+                security=security,
+                visibility=visibility,
+                category=categories[category_code],
+                actor=actor,
+                tags=tags,
+                new_files=new_files,
+            )
+            if failed or main is None:
+                raise RuntimeError(f"同名展示主案已逻辑删除:{name}")
+            created_documents += int(created)
+            existing_documents += int(not created)
+            mains.append(main)
+
+            for child_name in children:
+                child, child_created, child_failed = _document(
+                    root=root,
+                    name=child_name,
+                    summary=f"《{name}》配套的{child_name},明确任务分工、实施步骤和保障要求。",
+                    document_type=DocumentType.SUB_PLAN,
+                    status=DocumentStatus.PUBLISHED,
+                    security=security,
+                    visibility=visibility,
+                    category=categories[category_code],
+                    actor=actor,
+                    tags=["子方案", *tags[:2]],
+                    new_files=new_files,
+                    parent=main,
+                    root_document=main,
+                )
+                if child_failed or child is None:
+                    raise RuntimeError(f"同名展示子方案已逻辑删除:{child_name}")
+                created_documents += int(child_created)
+                existing_documents += int(not child_created)
+
+        attachment_specs = (
+            ("防汛应急响应流程图", "防汛响应等级、指挥关系和处置流程示意。", AttachmentType.DIAGRAM),
+            ("应急物资需求统计表", "应急物资需求、库存和缺口统计模板。", AttachmentType.TABLE),
+            ("现场处置工作规范", "现场警戒、人员疏导和信息报送工作规范。", AttachmentType.WORK_STANDARD),
+            ("应急通讯录模板", "应急联系人、值班电话和协同单位通讯录模板。", AttachmentType.TABLE),
+            ("跨部门协同处置流程", "跨部门会商、指令下达和反馈闭环流程。", AttachmentType.DIAGRAM),
+        )
+        attachments: list[Document] = []
+        for name, summary, attachment_type in attachment_specs:
+            attachment, created, failed = _document(
+                root=root,
+                name=name,
+                summary=summary,
+                document_type=DocumentType.ATTACHMENT,
+                status=DocumentStatus.PUBLISHED,
+                security=SecurityLevel.PUBLIC,
+                visibility=VisibilityType.ALL_AUTHENTICATED,
+                category=None,
+                actor=actor,
+                tags=["共享附件", "业务模板"],
+                new_files=new_files,
+                attachment_type=attachment_type,
+            )
+            if failed or attachment is None:
+                raise RuntimeError(f"同名展示附件已逻辑删除:{name}")
+            created_documents += int(created)
+            existing_documents += int(not created)
+            attachments.append(attachment)
+
+        permission_specs = (
+            (mains[0], SubjectType.ORG, ops.id, ops.org_name),
+            (mains[1], SubjectType.ORG, comms.id, comms.org_name),
+            (mains[2], SubjectType.ORG, ops.id, ops.org_name),
+            (mains[2], SubjectType.USER, ordinary_user.id, ordinary_user.real_name),
+        )
+        for main, subject_type, subject_id, subject_name in permission_specs:
+            existing_permission = db.session.scalar(
+                select(Permission.id).where(
+                    Permission.document_id == main.id,
+                    Permission.subject_type == subject_type.value,
+                    Permission.subject_id == subject_id,
+                )
+            )
+            _permission(
+                main,
+                subject_type=subject_type,
+                subject_id=subject_id,
+                subject_name=subject_name,
+                actor_id=actor.id,
+            )
+            created_permissions += int(existing_permission is None)
+
+        binding_specs = (
+            (mains[0], attachments[0], 10),
+            (mains[0], attachments[1], 20),
+            (mains[0], attachments[4], 30),
+            (mains[1], attachments[3], 10),
+            (mains[1], attachments[4], 20),
+            (mains[2], attachments[1], 10),
+            (mains[2], attachments[3], 20),
+            (mains[3], attachments[2], 10),
+            (mains[3], attachments[4], 20),
+        )
+        for main, attachment, sort_no in binding_specs:
+            existing_binding = db.session.scalar(
+                select(AttachmentBinding.id).where(
+                    AttachmentBinding.main_document_id == main.id,
+                    AttachmentBinding.attachment_document_id == attachment.id,
+                )
+            )
+            _binding(
+                main,
+                attachment,
+                sort_no=sort_no,
+                actor_id=actor.id,
+            )
+            created_bindings += int(existing_binding is None)
+
+        _refresh_counts(list(categories.values()), mains)
+        db.session.commit()
+    except Exception:
+        db.session.rollback()
+        for path in new_files:
+            path.unlink(missing_ok=True)
+        raise
+
+    return (
+        created_categories,
+        created_documents,
+        existing_documents,
+        created_bindings,
+        created_permissions,
+    )
+
+
+def main() -> None:
+    app = Flask("dms-seed-showcase")
+    init_dms(app)
+    with app.app_context():
+        result = seed_showcase()
+    print(
+        "业务展示数据初始化完成:"
+        f"新增分类{result[0]}个,新增文档{result[1]}个,"
+        f"已有文档{result[2]}个,新增挂载{result[3]}条,"
+        f"新增权限{result[4]}条。"
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 7 - 3
backend/dms/services/attachment_query_service.py

@@ -10,6 +10,7 @@ from dms.common.enums import AttachmentType, DocumentType
 from dms.common.errors import ResourceNotFoundError
 from dms.common.pagination import page_result
 from dms.common.response import serialize_id
+from dms.common.time_range import parse_updated_range
 from dms.extensions import db
 from dms.models import AttachmentBinding, Document
 from dms.security.auth_context import AuthContext, get_auth_context
@@ -19,7 +20,6 @@ from dms.services.authorization_service import (
 )
 from dms.services.document_query_service import (
     DOCUMENT_SORTS,
-    _date,
     _enum,
     _iso,
     _page,
@@ -135,8 +135,7 @@ def _filtered_attachments(params: Mapping[str, str]):
         statement = statement.where(
             Document.file_extension == extension.strip().lower().lstrip(".")
         )
-    updated_from = _date(params.get("updatedFrom"), "updatedFrom")
-    updated_to = _date(params.get("updatedTo"), "updatedTo")
+    updated_from, updated_to = parse_updated_range(params)
     if updated_from:
         statement = statement.where(Document.updated_at >= updated_from)
     if updated_to:
@@ -240,6 +239,11 @@ def list_main_plan_attachments(
     )
     if attachment_type:
         statement = statement.where(Document.attachment_type == attachment_type)
+    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)
     rows = db.session.execute(
         statement.order_by(
             AttachmentBinding.sort_no.asc(),

+ 18 - 1
backend/dms/services/category_service.py

@@ -116,6 +116,7 @@ def _dto(
     category: Category,
     *,
     parent_id: int | None = None,
+    document_count: int | None = None,
     children: list[dict[str, object]] | None = None,
 ) -> dict[str, object]:
     return {
@@ -128,7 +129,11 @@ def _dto(
         ),
         "categoryPath": category.category_path,
         "sortNo": category.sort_no,
-        "documentCount": category.document_count,
+        "documentCount": (
+            category.document_count
+            if document_count is None
+            else document_count
+        ),
         "rowVersion": category.row_version,
         "children": children or [],
     }
@@ -176,6 +181,17 @@ def category_tree(
         .where(Category.is_deleted.is_(False))
         .order_by(Category.sort_no.asc(), Category.id.asc())
     ).all()
+    main_document_counts = dict(
+        db.session.execute(
+            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}
     children = _children_map(categories)
     eligible_ids = {
@@ -215,6 +231,7 @@ def category_tree(
             )
             nodes[category.id] = _dto(
                 category,
+                document_count=main_document_counts.get(category.id, 0),
             )
             nodes[category.id]["parentId"] = serialize_id(response_parent_id)
 

+ 8 - 11
backend/dms/services/document_query_service.py

@@ -26,6 +26,7 @@ from dms.common.errors import (
 )
 from dms.common.pagination import PageRequest, page_result
 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 Document, Permission
@@ -85,15 +86,7 @@ def _enum(value: str | None, enum_type, name: str) -> str | None:
 
 
 def _date(value: str | None, name: str) -> datetime | None:
-    if not value:
-        return None
-    try:
-        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
-    except ValueError as exc:
-        raise InvalidArgumentError(f"{name}必须是ISO 8601时间") from exc
-    if parsed.tzinfo is None:
-        parsed = parsed.replace(tzinfo=timezone.utc)
-    return parsed.astimezone(timezone.utc).replace(tzinfo=None)
+    return parse_datetime(value, name=name)
 
 
 def _sort(
@@ -266,8 +259,7 @@ def list_documents(params: Mapping[str, str]) -> dict[str, object]:
         value = _enum(params.get(name), enum_type, name)
         if value:
             statement = statement.where(column == value)
-    updated_from = _date(params.get("updatedFrom"), "updatedFrom")
-    updated_to = _date(params.get("updatedTo"), "updatedTo")
+    updated_from, updated_to = parse_updated_range(params)
     if updated_from:
         statement = statement.where(Document.updated_at >= updated_from)
     if updated_to:
@@ -388,6 +380,11 @@ def list_sub_plans(
     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)
     order, _ = _sort(params)
     children = db.session.scalars(
         statement.order_by(order, Document.id.asc())

+ 16 - 0
backend/openapi/components/parameters.yaml

@@ -6,3 +6,19 @@ XRequestId:
   schema:
     type: string
     format: uuid
+
+UpdatedFrom:
+  name: updatedFrom
+  in: query
+  required: false
+  description: 文档自身updatedAt的闭区间开始边界,必须为UTC Z格式。
+  schema:
+    $ref: './schemas.yaml#/UtcDateTime'
+
+UpdatedTo:
+  name: updatedTo
+  in: query
+  required: false
+  description: 文档自身updatedAt的闭区间结束边界,必须为UTC Z格式,且不得早于updatedFrom。
+  schema:
+    $ref: './schemas.yaml#/UtcDateTime'

+ 2 - 2
backend/openapi/openapi.yaml

@@ -1,8 +1,8 @@
 openapi: 3.0.3
 info:
   title: 方案计划文档管理系统 API
-  version: 1.2.0
-  description: DMS第一阶段接口;Q1-B增加统一UI展示字典查询
+  version: 1.3.0
+  description: DMS第一阶段接口;Q2-B统一方案、直属子方案和已挂载附件的更新时间范围检索
 servers:
   - url: /
 paths:

+ 8 - 4
backend/openapi/paths/documents.yaml

@@ -10,8 +10,8 @@ Documents:
       - {name: securityLevel, in: query, schema: {$ref: '../components/schemas.yaml#/SecurityLevel'}}
       - {name: visibilityType, in: query, schema: {$ref: '../components/schemas.yaml#/VisibilityType'}}
       - {name: status, in: query, schema: {$ref: '../components/schemas.yaml#/DocumentStatus'}}
-      - {name: updatedFrom, in: query, schema: {type: string, format: date-time}}
-      - {name: updatedTo, in: query, schema: {type: string, format: date-time}}
+      - $ref: '../components/parameters.yaml#/UpdatedFrom'
+      - $ref: '../components/parameters.yaml#/UpdatedTo'
       - name: sortBy
         in: query
         schema:
@@ -106,6 +106,8 @@ SubPlans:
     parameters:
       - {name: keyword, in: query, schema: {type: string}}
       - {name: status, in: query, schema: {$ref: '../components/schemas.yaml#/DocumentStatus'}}
+      - $ref: '../components/parameters.yaml#/UpdatedFrom'
+      - $ref: '../components/parameters.yaml#/UpdatedTo'
       - name: sortBy
         in: query
         schema: {type: string, enum: [documentName, createdAt, updatedAt, viewCount, downloadCount], default: updatedAt}
@@ -129,8 +131,8 @@ Attachments:
       - {name: keyword, in: query, schema: {type: string}}
       - {name: attachmentType, in: query, schema: {$ref: '../components/schemas.yaml#/AttachmentType'}}
       - {name: fileExtension, in: query, schema: {type: string}}
-      - {name: updatedFrom, in: query, schema: {type: string, format: date-time}}
-      - {name: updatedTo, in: query, schema: {type: string, format: date-time}}
+      - $ref: '../components/parameters.yaml#/UpdatedFrom'
+      - $ref: '../components/parameters.yaml#/UpdatedTo'
       - name: sortBy
         in: query
         schema: {type: string, enum: [documentName, createdAt, updatedAt, viewCount, downloadCount], default: updatedAt}
@@ -308,6 +310,8 @@ MainPlanAttachments:
     parameters:
       - {name: keyword, in: query, schema: {type: string}}
       - {name: attachmentType, in: query, schema: {$ref: '../components/schemas.yaml#/AttachmentType'}}
+      - $ref: '../components/parameters.yaml#/UpdatedFrom'
+      - $ref: '../components/parameters.yaml#/UpdatedTo'
       - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
       - {name: pageSize, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
     responses:

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

@@ -8,7 +8,7 @@ def test_dms_blueprint_is_registered(flask_app):
     assert "/api/v1/health" in rules
 
 
-def test_only_q1b_dms_routes_are_registered(flask_app):
+def test_only_q2b_dms_routes_are_registered(flask_app):
     dms_rules = {
         rule.rule for rule in flask_app.url_map.iter_rules()
         if rule.rule.startswith("/api/v1")

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

@@ -78,7 +78,7 @@ def test_flask_methods_match_openapi_exactly(flask_app):
     assert len(operation_ids) == len(set(operation_ids))
 
 
-def test_openapi_contains_only_q1b_paths():
+def test_openapi_contains_only_q2b_paths():
     document = yaml.safe_load(
         (OPENAPI_ROOT / "openapi.yaml").read_text(encoding="utf-8")
     )
@@ -115,7 +115,7 @@ def test_openapi_contains_only_q1b_paths():
         "/api/v1/recycle-bin/documents",
         "/api/v1/recycle-bin/documents/{id}/restore",
     }
-    assert document["info"]["version"] == "1.2.0"
+    assert document["info"]["version"] == "1.3.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

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

+ 371 - 0
backend/tests/dms/test_q2b_updated_range.py

@@ -0,0 +1,371 @@
+from __future__ import annotations
+
+from datetime import datetime
+from urllib.parse import urlencode
+
+import pytest
+import yaml
+from sqlalchemy import event, func, select
+
+from dms.common.errors import InvalidArgumentError
+from dms.common.time_range import parse_datetime, parse_updated_range
+
+
+def auth(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+def payload(response):
+    return response.get_json()["data"]
+
+
+TIMES = {
+    "B4_TEST_草稿主案": datetime(2026, 1, 5, 0, 0),
+    "B4_TEST_绝密主案": datetime(2026, 1, 6, 0, 0),
+    "B4_TEST_无授权主案": datetime(2026, 1, 7, 0, 0),
+    "B4_TEST_全部主案": datetime(2026, 1, 10, 0, 0),
+    "B4_TEST_规范附件": datetime(2026, 1, 15, 0, 0),
+    "B4_TEST_组织主案": datetime(2026, 1, 20, 0, 0),
+    "B4_TEST_动态继承子案": datetime(2026, 1, 25, 0, 0),
+    "B4_TEST_自定义主案": datetime(2026, 1, 30, 0, 0),
+    "B4_TEST_表格附件": datetime(2026, 2, 5, 0, 0),
+}
+
+
+@pytest.fixture()
+def q2b_app(b4_app):
+    from dms.extensions import db
+    from dms.models import AttachmentBinding, Document
+
+    with b4_app.app_context():
+        documents = db.session.scalars(
+            select(Document).where(Document.document_name.in_(TIMES))
+        ).all()
+        assert len(documents) == len(TIMES)
+        for document in documents:
+            document.updated_at = TIMES[document.document_name]
+        # 故意让挂载关系时间全部晚于筛选范围,证明筛选依据是附件文档。
+        for binding in db.session.scalars(select(AttachmentBinding)).all():
+            binding.updated_at = datetime(2035, 1, 1, 0, 0)
+        db.session.commit()
+    return b4_app
+
+
+@pytest.fixture()
+def q2b_client(q2b_app):
+    return q2b_app.test_client()
+
+
+def query(client, path: str, token: str, **params):
+    return client.get(
+        f"{path}?{urlencode(params)}",
+        headers=auth(token),
+    )
+
+
+def names(response) -> list[str]:
+    return [item["documentName"] for item in payload(response)["items"]]
+
+
+def test_common_parser_requires_timezone_and_normalizes_to_utc():
+    assert parse_datetime(
+        "2026-01-20T08:00:00+08:00", name="updatedFrom"
+    ) == datetime(2026, 1, 20, 0, 0)
+    assert parse_datetime(
+        "2026-01-20T00:00:00Z", name="updatedFrom"
+    ) == datetime(2026, 1, 20, 0, 0)
+    with pytest.raises(InvalidArgumentError):
+        parse_datetime("2026-01-20T00:00:00", name="updatedFrom")
+    with pytest.raises(InvalidArgumentError):
+        parse_updated_range(
+            {
+                "updatedFrom": "2026-01-21T00:00:00Z",
+                "updatedTo": "2026-01-20T00:00:00Z",
+            }
+        )
+
+
+def test_documents_updated_range_single_and_closed_boundaries(
+    q2b_client, token_for
+):
+    token = token_for("admin")
+    start = query(
+        q2b_client,
+        "/api/v1/documents",
+        token,
+        documentType="MAIN",
+        updatedFrom="2026-01-30T00:00:00Z",
+        pageSize=100,
+    )
+    end = query(
+        q2b_client,
+        "/api/v1/documents",
+        token,
+        documentType="MAIN",
+        updatedTo="2026-01-10T00:00:00Z",
+        pageSize=100,
+    )
+    closed = query(
+        q2b_client,
+        "/api/v1/documents",
+        token,
+        documentType="MAIN",
+        updatedFrom="2026-01-20T00:00:00Z",
+        updatedTo="2026-01-30T00:00:00Z",
+        sortBy="updatedAt",
+        sortDirection="asc",
+        pageSize=100,
+    )
+
+    assert names(start) == ["B4_TEST_自定义主案"]
+    assert "B4_TEST_全部主案" in names(end)
+    assert "B4_TEST_组织主案" not in names(end)
+    assert names(closed) == ["B4_TEST_组织主案", "B4_TEST_自定义主案"]
+    assert all(item["id"].isdecimal() for item in payload(closed)["items"])
+
+
+def test_documents_keyword_offset_timezone_and_filter_before_pagination(
+    q2b_client, token_for
+):
+    token = token_for("admin")
+    combined = query(
+        q2b_client,
+        "/api/v1/documents",
+        token,
+        documentType="MAIN",
+        keyword="组织",
+        updatedFrom="2026-01-20T08:00:00+08:00",
+        updatedTo="2026-01-20T08:00:00+08:00",
+        pageSize=100,
+    )
+    second_page = query(
+        q2b_client,
+        "/api/v1/documents",
+        token,
+        documentType="MAIN",
+        updatedFrom="2026-01-20T00:00:00Z",
+        updatedTo="2026-01-30T00:00:00Z",
+        sortBy="updatedAt",
+        sortDirection="asc",
+        page=2,
+        pageSize=1,
+    )
+
+    assert names(combined) == ["B4_TEST_组织主案"]
+    assert payload(second_page)["total"] == 2
+    assert names(second_page) == ["B4_TEST_自定义主案"]
+
+
+def test_direct_sub_plan_range_exact_boundary_and_status(
+    q2b_app, q2b_client, token_for
+):
+    main_id = q2b_app.config["B4_IDS"]["main_custom"]
+    response = query(
+        q2b_client,
+        f"/api/v1/main-plans/{main_id}/sub-plans",
+        token_for("user"),
+        keyword="动态",
+        status="PUBLISHED",
+        updatedFrom="2026-01-25T00:00:00Z",
+        updatedTo="2026-01-25T00:00:00Z",
+        pageSize=100,
+    )
+    outside = query(
+        q2b_client,
+        f"/api/v1/main-plans/{main_id}/sub-plans",
+        token_for("admin"),
+        status="PUBLISHED",
+        updatedFrom="2026-01-25T00:00:00.001Z",
+        pageSize=100,
+    )
+
+    assert response.status_code == 200
+    assert names(response) == ["B4_TEST_动态继承子案"]
+    assert names(outside) == []
+
+
+def test_mounted_attachment_range_uses_attachment_not_binding_time(
+    q2b_app, q2b_client, token_for
+):
+    main_id = q2b_app.config["B4_IDS"]["main_all"]
+    first = query(
+        q2b_client,
+        f"/api/v1/main-plans/{main_id}/attachments",
+        token_for("user"),
+        keyword="规范",
+        attachmentType="WORK_STANDARD",
+        updatedFrom="2026-01-15T00:00:00Z",
+        updatedTo="2026-01-15T00:00:00Z",
+        pageSize=100,
+    )
+    second = query(
+        q2b_client,
+        f"/api/v1/main-plans/{main_id}/attachments",
+        token_for("admin"),
+        attachmentType="TABLE",
+        updatedFrom="2026-02-01T00:00:00Z",
+        pageSize=100,
+    )
+    excluded = query(
+        q2b_client,
+        f"/api/v1/main-plans/{main_id}/attachments",
+        token_for("admin"),
+        updatedTo="2026-01-14T23:59:59Z",
+        pageSize=100,
+    )
+
+    assert names(first) == ["B4_TEST_规范附件"]
+    assert names(second) == ["B4_TEST_表格附件"]
+    assert names(excluded) == []
+    assert payload(first)["items"][0]["bindingId"].isdecimal()
+
+
+@pytest.mark.parametrize(
+    "params",
+    [
+        {"updatedFrom": ""},
+        {"updatedTo": "not-a-time"},
+        {"updatedFrom": "2026-01-01T00:00:00"},
+        {
+            "updatedFrom": "2026-02-01T00:00:00Z",
+            "updatedTo": "2026-01-01T00:00:00Z",
+        },
+    ],
+)
+@pytest.mark.parametrize("endpoint", ["documents", "sub-plans", "attachments"])
+def test_invalid_ranges_return_uniform_error(
+    q2b_app, q2b_client, token_for, endpoint, params
+):
+    ids = q2b_app.config["B4_IDS"]
+    paths = {
+        "documents": "/api/v1/documents",
+        "sub-plans": f"/api/v1/main-plans/{ids['main_custom']}/sub-plans",
+        "attachments": f"/api/v1/main-plans/{ids['main_all']}/attachments",
+    }
+    response = query(q2b_client, paths[endpoint], token_for("admin"), **params)
+
+    assert response.status_code == 400
+    body = response.get_json()
+    assert body["code"] == "INVALID_ARGUMENT"
+    assert body["requestId"] == response.headers["X-Request-Id"]
+
+
+def test_role_boundaries_and_queries_do_not_increment_counts_or_audit(
+    q2b_app, q2b_client, token_for
+):
+    from dms.extensions import db
+    from dms.models import AuditLog, Document
+
+    ids = q2b_app.config["B4_IDS"]
+    tokens = {name: token_for(name) for name in ("admin", "user", "auditor")}
+    with q2b_app.app_context():
+        before_counts = {
+            item.id: (item.view_count, item.download_count)
+            for item in db.session.scalars(select(Document)).all()
+        }
+        before_audits = db.session.scalar(select(func.count()).select_from(AuditLog))
+
+    admin = query(
+        q2b_client,
+        "/api/v1/documents",
+        tokens["admin"],
+        updatedFrom="2026-01-01T00:00:00Z",
+    )
+    user = query(
+        q2b_client,
+        f"/api/v1/main-plans/{ids['main_custom']}/sub-plans",
+        tokens["user"],
+        updatedTo="2026-12-31T23:59:59Z",
+    )
+    auditor = query(
+        q2b_client,
+        "/api/v1/documents",
+        tokens["auditor"],
+        updatedFrom="2026-01-01T00:00:00Z",
+    )
+    auditor_mounted = query(
+        q2b_client,
+        f"/api/v1/main-plans/{ids['main_all']}/attachments",
+        tokens["auditor"],
+        updatedTo="2026-12-31T23:59:59Z",
+    )
+
+    assert admin.status_code == 200
+    assert user.status_code == 200
+    assert auditor.status_code == 403
+    assert auditor_mounted.status_code == 403
+    with q2b_app.app_context():
+        after_counts = {
+            item.id: (item.view_count, item.download_count)
+            for item in db.session.scalars(select(Document)).all()
+        }
+        after_audits = db.session.scalar(select(func.count()).select_from(AuditLog))
+    assert after_counts == before_counts
+    assert after_audits == before_audits
+
+
+def test_time_filters_are_present_in_database_queries(
+    q2b_app, q2b_client, token_for
+):
+    from dms.extensions import db
+
+    ids = q2b_app.config["B4_IDS"]
+    statements: list[str] = []
+
+    def record(_conn, _cursor, statement, _params, _context, _many):
+        statements.append(statement.lower())
+
+    with q2b_app.app_context():
+        event.listen(db.engine, "before_cursor_execute", record)
+        try:
+            token = token_for("admin")
+            for path in (
+                "/api/v1/documents",
+                f"/api/v1/main-plans/{ids['main_custom']}/sub-plans",
+                f"/api/v1/main-plans/{ids['main_all']}/attachments",
+            ):
+                response = query(
+                    q2b_client,
+                    path,
+                    token,
+                    updatedFrom="2026-01-01T00:00:00Z",
+                    updatedTo="2026-12-31T23:59:59Z",
+                )
+                assert response.status_code == 200
+        finally:
+            event.remove(db.engine, "before_cursor_execute", record)
+
+    filtered = [
+        statement
+        for statement in statements
+        if "doc_document.updated_at >=" in statement
+        and "doc_document.updated_at <=" in statement
+    ]
+    assert len(filtered) >= 3
+    mounted = next(
+        statement for statement in filtered if "doc_attachment_binding" in statement
+    )
+    normalized_sql = " ".join(mounted.split())
+    where_clause = normalized_sql.split(" where ", maxsplit=1)[1]
+    assert "doc_attachment_binding.updated_at" not in where_clause
+
+
+def test_openapi_declares_q2b_parameters_without_new_paths():
+    from pathlib import Path
+
+    root = Path(__file__).resolve().parents[2] / "openapi"
+    document = yaml.safe_load((root / "openapi.yaml").read_text(encoding="utf-8"))
+    paths = yaml.safe_load(
+        (root / "paths" / "documents.yaml").read_text(encoding="utf-8")
+    )
+
+    assert document["info"]["version"] == "1.3.0"
+    for operation in ("Documents", "SubPlans", "MainPlanAttachments"):
+        references = {
+            parameter.get("$ref")
+            for parameter in paths[operation]["get"]["parameters"]
+            if "$ref" in parameter
+        }
+        assert "../components/parameters.yaml#/UpdatedFrom" in references
+        assert "../components/parameters.yaml#/UpdatedTo" in references
+    assert "/api/v1/documents/search" not in document["paths"]

BIN
docs/assets/demo-20260728/auditor/01_操作日志与统计.png


BIN
docs/assets/demo-20260728/普通用户界面/01_文档浏览与直属子方案.png


BIN
docs/assets/demo-20260728/普通用户界面/02_共享附件库.png


BIN
docs/assets/demo-20260728/登录界面/01_登录页.png


BIN
docs/assets/demo-20260728/管理员/01_后台方案管理.png


BIN
docs/assets/demo-20260728/管理员/02_后台共享附件库.png


BIN
docs/assets/demo-20260728/管理员/03_文档回收站.png


BIN
docs/assets/demo-20260728/管理员/04 上传方案文档.jpg


BIN
docs/assets/demo-20260728/管理员/05 批量导入.png


BIN
docs/assets/demo-20260728/管理员/06 文件浏览.jpg


BIN
docs/assets/demo-20260728/管理员/07 方案详情.png


BIN
docs/assets/demo-20260728/管理员/08 修改分类.png


+ 1 - 1
frontend/README.md

@@ -1,7 +1,7 @@
 
 # DMS 前端
 
-方案计划文档管理系统的 React 前端。
+方案管理系统的 React 前端。
 
 ## 常用命令
 

+ 2 - 3
frontend/index.html

@@ -5,8 +5,8 @@
       
       <meta charset="UTF-8" />
       <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-      <title>Document Management System</title>
-      <meta name="description" content="Streamline document management with an intuitive system for organizations, featuring user-friendly navigation, detailed document insights, and efficient search capabilities." />
+      <title>方案管理系统</title>
+      <meta name="description" content="方案管理系统" />
       <meta name="robots" content="noindex, nofollow" />
       <style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
       
@@ -19,4 +19,3 @@
       
     </body>
   </html>
-  

+ 201 - 76
frontend/src/app/App.tsx

@@ -28,6 +28,7 @@ import { categoryApi } from "../features/categories/categoryApi";
 import { useCategoryTree } from "../features/categories/categoryState";
 import {
   findCategoryNode,
+  getCategoryBranchMainPlanCount,
   getCategoryParentOptions,
   retainExistingCategoryIds,
 } from "../features/categories/categoryTree";
@@ -42,7 +43,14 @@ import {
   useUserDirectory,
 } from "../features/directory/directoryState";
 import type { OrganizationNode } from "../features/directory/directoryTypes";
-import { AllowedAction, AttachmentType, DocumentType, RoleCode } from "../shared/api/enums";
+import {
+  AllowedAction,
+  AttachmentType,
+  DocumentType,
+  RoleCode,
+  type AttachmentType as AttachmentTypeCode,
+  type DocumentStatus as DocumentStatusCode,
+} from "../shared/api/enums";
 import { documentApi } from "../features/documents/documentApi";
 import {
   loadDetailOnce,
@@ -54,6 +62,8 @@ import type {
   DocumentSummary,
   MainPlanBrief,
 } from "../features/documents/documentTypes";
+import { DocumentSearchToolbar } from "../features/documents/DocumentSearchToolbar";
+import { toUpdatedTimeRange } from "../features/documents/documentSearch";
 import { attachmentApi } from "../features/attachments/attachmentApi";
 import {
   useAttachmentList,
@@ -275,6 +285,12 @@ interface CategoryFormValue {
   sortNo: number;
 }
 
+function generateInternalCategoryCode(): string {
+  const timestamp = Date.now().toString(36);
+  const random = Math.random().toString(36).slice(2, 14);
+  return `CAT_${timestamp}_${random}`.toUpperCase();
+}
+
 function errorRequestSuffix(requestId: string): string {
   return requestId ? `(错误编号:${requestId})` : "";
 }
@@ -329,7 +345,7 @@ function CategoryTreeNode({ node, depth, expanded, onToggle, selectedId, onSelec
         <Layers size={12} style={{ color: B }} className="flex-shrink-0" />
         <span className="text-sm font-bold truncate flex-1" title={`${node.categoryCode} · ${node.categoryPath}`}>
           {node.categoryName}
-          <span className="ml-1 text-[10px] opacity-45">({node.documentCount})</span>
+          <span className="ml-1 text-[10px] opacity-45">({getCategoryBranchMainPlanCount(node)})</span>
         </span>
         {isAdmin && (
           <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
@@ -411,7 +427,7 @@ function CategoryPanel({ admin, selectedCategoryId, onSelectCategory }: {
         };
         await categoryApi.update(editModal.target.id, request);
         setSelectedId(editModal.target.id);
-        setActionMessage("分类已更新,当前树已从服务器重新加载。");
+        setActionMessage("分类已更新。");
       } else {
         const request: CreateCategoryRequest = {
           categoryCode: value.categoryCode,
@@ -425,7 +441,7 @@ function CategoryPanel({ admin, selectedCategoryId, onSelectCategory }: {
         if (request.parentId) {
           setExpanded((current) => new Set(current).add(request.parentId as string));
         }
-        setActionMessage("分类已新增,当前树已从服务器重新加载。");
+        setActionMessage("分类已新增。");
       }
       setEditModal(null);
       refresh();
@@ -441,13 +457,13 @@ function CategoryPanel({ admin, selectedCategoryId, onSelectCategory }: {
 
   const handleDelete = async (node: CategoryNode) => {
     if (submitting) return;
-    if (!window.confirm(`确认逻辑删除分类「${node.categoryName}」?不会递归删除子分类,不会迁移关联方案,且本阶段不提供恢复。`)) return;
+    if (!window.confirm(`确认删除分类「${node.categoryName}」?`)) return;
     setSubmitting(true);
     setActionMessage("");
     try {
       await categoryApi.remove(node.id, node.rowVersion);
       if (selectedId === node.id) setSelectedId(null);
-      setActionMessage("分类已逻辑删除,当前树已从服务器重新加载。");
+      setActionMessage("分类已删除。");
       refresh();
     } catch (operationError) {
       setActionMessage(categoryActionError(operationError));
@@ -546,14 +562,21 @@ function CategoryEditModal({ title, mode, target, tree, submitting, externalErro
   onSubmit: (value: CategoryFormValue) => void;
   onClose: () => void;
 }) {
-  const dictionaries = useDictionaries();
-  const [categoryCode, setCategoryCode] = useState(target?.categoryCode ?? "");
-  const [categoryName, setCategoryName] = useState(target?.categoryName ?? "");
-  const [categoryType, setCategoryType] = useState<keyof typeof CategoryType>(target?.categoryType ?? CategoryType.SCENE);
+  const [categoryCode] = useState(() => (
+    mode === "edit" && target
+      ? target.categoryCode
+      : generateInternalCategoryCode()
+  ));
+  const [categoryName, setCategoryName] = useState(
+    mode === "edit" ? target?.categoryName ?? "" : "",
+  );
+  const [categoryType] = useState<keyof typeof CategoryType>(
+    mode === "edit" ? target?.categoryType ?? CategoryType.OTHER : CategoryType.OTHER,
+  );
   const [parentId, setParentId] = useState<string | null>(
     mode === "addChild" ? target?.id ?? null : target?.parentId ?? null,
   );
-  const [sortNo, setSortNo] = useState(String(target?.sortNo ?? 10));
+  const [sortNo, setSortNo] = useState(String(mode === "edit" ? target?.sortNo ?? 10 : 10));
   const [formError, setFormError] = useState("");
   const parentOptions = getCategoryParentOptions(tree, mode === "edit" ? target : undefined);
 
@@ -561,8 +584,8 @@ function CategoryEditModal({ title, mode, target, tree, submitting, externalErro
     const nextCode = categoryCode.trim();
     const nextName = categoryName.trim();
     const parsedSortNo = Number(sortNo);
-    if (!nextCode || !nextName) {
-      setFormError("分类编码和分类名称不能为空");
+    if (!nextName) {
+      setFormError("分类名称不能为空");
       return;
     }
     if (!Number.isInteger(parsedSortNo) || parsedSortNo < 0) {
@@ -587,28 +610,15 @@ function CategoryEditModal({ title, mode, target, tree, submitting, externalErro
           <button onClick={onClose} className="opacity-70 hover:opacity-100 transition-opacity"><X size={16} /></button>
         </div>
         <div className="p-5 space-y-3">
-          <CategoryFormField label="分类编码">
-            <input autoFocus={mode !== "edit"} value={categoryCode} readOnly={mode === "edit"} onChange={e => setCategoryCode(e.target.value)}
-              className="w-full h-9 px-3 text-sm font-bold rounded-sm outline-none read-only:opacity-60"
-              style={{ color: N, background: `rgba(40,118,193,0.04)`, border: `1px solid rgba(40,118,193,0.3)` }} />
-          </CategoryFormField>
           <CategoryFormField label="分类名称">
-            <input autoFocus={mode === "edit"} value={categoryName} onChange={e => setCategoryName(e.target.value)}
+            <input autoFocus value={categoryName} onChange={e => setCategoryName(e.target.value)}
               className="w-full h-9 px-3 text-sm font-bold rounded-sm outline-none"
               style={{ color: N, background: `rgba(40,118,193,0.04)`, border: `1px solid rgba(40,118,193,0.3)` }} />
           </CategoryFormField>
-          <div className="grid grid-cols-2 gap-3">
-            <CategoryFormField label="分类类型">
-              <select value={categoryType} onChange={e => setCategoryType(e.target.value as keyof typeof CategoryType)}
-                className="w-full h-9 px-2 text-sm font-bold rounded-sm outline-none" style={{ color: N, border: `1px solid rgba(40,118,193,0.3)` }}>
-                {dictionaries.options("categoryTypes").map(option => <option key={option.code} value={option.code}>{option.label}</option>)}
-              </select>
-            </CategoryFormField>
-            <CategoryFormField label="排序号">
-              <input type="number" min={0} step={1} value={sortNo} onChange={e => setSortNo(e.target.value)}
-                className="w-full h-9 px-3 text-sm font-bold rounded-sm outline-none" style={{ color: N, border: `1px solid rgba(40,118,193,0.3)` }} />
-            </CategoryFormField>
-          </div>
+          <CategoryFormField label="排序号">
+            <input type="number" min={0} step={1} value={sortNo} onChange={e => setSortNo(e.target.value)}
+              className="w-full h-9 px-3 text-sm font-bold rounded-sm outline-none" style={{ color: N, border: `1px solid rgba(40,118,193,0.3)` }} />
+          </CategoryFormField>
           <CategoryFormField label="父分类">
             <select value={parentId ?? ""} disabled={mode === "addChild" || mode === "addRoot"} onChange={e => setParentId(e.target.value || null)}
               className="w-full h-9 px-2 text-sm font-bold rounded-sm outline-none disabled:opacity-60" style={{ color: N, border: `1px solid rgba(40,118,193,0.3)` }}>
@@ -650,7 +660,7 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory }: {
     <div className="w-[272px] flex-shrink-0 m-3 flex flex-col" style={{ background: W, border: `1px solid rgba(40,118,193,0.2)` }}>
       <div className="px-4 py-3 flex items-center gap-2 border-b" style={{ borderColor: "rgba(40,118,193,0.14)", background: `rgba(40,118,193,0.05)` }}>
         <Layers size={15} style={{ color: B }} />
-        <span className="text-sm font-black" style={{ color: N }}>方案计划分类</span>
+        <span className="text-sm font-black" style={{ color: N }}>方案分类</span>
       </div>
       <div className="flex-1 overflow-hidden flex flex-col p-3">
         <CategoryPanel
@@ -1053,7 +1063,7 @@ function PermissionModal({ doc, onClose }: { doc: Doc; onClose: () => void }) {
           <BtnOutline onClick={() => { setOrgChecked(new Set()); setPersonChecked(new Set()); }} danger>
             <RefreshCw size={13} />重置
           </BtnOutline>
-          <BtnPrimary onClick={() => setNotice("组织和人员数据已接入,权限持久化将在后续阶段完成。")}><Check size={13} />保存权限</BtnPrimary>
+          <BtnPrimary onClick={() => setNotice("")}><Check size={13} />保存权限</BtnPrimary>
         </div>
       </div>
     </div>
@@ -1268,7 +1278,7 @@ function MainPage({ onNav, navigationItems, onLogout, logoutPending }: Authentic
     : [];
 
   return (
-    <PageFrame title="方案计划系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
+    <PageFrame title="方案管理系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
       <LeftPanel />
       <div className="flex-1 flex flex-col m-3 ml-0 min-h-0">
         {/* 顶部 Tab 栏 */}
@@ -1287,7 +1297,7 @@ function MainPage({ onNav, navigationItems, onLogout, logoutPending }: Authentic
               </button>
             );
           })}
-          {/* 右侧检索框:与左侧方案计划分类搜索共用 keyword,覆盖主案/子方案/配套资料 */}
+        {/* 右侧检索框:与左侧方案分类搜索共用 keyword,覆盖主案/子方案/配套资料 */}
           <div className="flex items-center gap-2 ml-auto mr-2 my-1.5 px-3 py-1.5 rounded-sm" style={{ background: `rgba(40,118,193,0.05)`, border: `1px solid rgba(40,118,193,0.25)` }}>
             <Search size={14} style={{ color: B }} className="flex-shrink-0" />
             <input
@@ -1729,7 +1739,7 @@ function AdminPage({ onNav, navigationItems, onLogout, logoutPending }: Authenti
   };
 
   return (
-    <PageFrame title="方案计划系统后台" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
+    <PageFrame title="方案管理系统后台" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
       {adminMode === "plans"
         ? <LeftPanel admin />
         : <AttachmentTypePanel selected={attachmentType} onSelect={setAttachmentType} />}
@@ -2043,7 +2053,7 @@ function LoginPage({ onLogin, notice }: {
     <div className="w-full h-screen flex flex-col overflow-hidden" style={{ fontFamily: "'Noto Sans SC', sans-serif", background: PBG }}>
       {/* Top bar — auto height, title only */}
       <div className="relative z-30 flex-shrink-0 flex items-center justify-center py-3" style={{ background: B }}>
-        <span className="text-xl font-black tracking-[0.22em] whitespace-nowrap" style={{ color: W }}>方案计划系统</span>
+        <span className="text-xl font-black tracking-[0.22em] whitespace-nowrap" style={{ color: W }}>方案管理系统</span>
       </div>
 
       {/* Header → 主体 渐变过渡带(20px) */}
@@ -2107,7 +2117,7 @@ function LoginPage({ onLogin, notice }: {
           </div>
       </div>
       <div className="relative z-10 pb-6 text-center text-xs font-bold" style={{ color: `rgba(26,82,153,0.3)` }}>
-        © 2024 方案计划系统 · 版权所有 · 严禁扩散
+        © 2024 方案管理系统 · 版权所有 · 严禁扩散
       </div>
     </div>
   );
@@ -2126,7 +2136,7 @@ function AuthStateScreen({ title, description, requestId, primaryLabel, onPrimar
   return (
     <div className="w-full h-screen flex flex-col" style={{ fontFamily: "'Noto Sans SC', sans-serif", background: PBG }}>
       <div className="relative z-30 flex-shrink-0 flex items-center justify-center py-3" style={{ background: B }}>
-        <span className="text-xl font-black tracking-[0.22em]" style={{ color: W }}>方案计划系统</span>
+        <span className="text-xl font-black tracking-[0.22em]" style={{ color: W }}>方案管理系统</span>
       </div>
       <div aria-hidden style={{ height: 20, background: `linear-gradient(to bottom, ${B} 0%, ${PBG} 100%)` }} />
       <div className="flex-1 flex items-center justify-center">
@@ -2166,13 +2176,14 @@ function formatFileSize(value: number): string {
   return `${(value / 1024 / 1024).toFixed(1)} MB`;
 }
 
-function F4State({ loading, error, empty }: {
+function F4State({ loading, error, empty, onRetry }: {
   loading: boolean;
   error: HttpError | null;
   empty: boolean;
+  onRetry?: () => void;
 }) {
   if (loading) {
-    return <div className="p-8 text-center text-sm font-bold" style={{ color: N }}><RefreshCw className="inline animate-spin mr-2" size={15} />正在加载真实数据…</div>;
+    return <div className="p-8 text-center text-sm font-bold" style={{ color: N }}><RefreshCw className="inline animate-spin mr-2" size={15} />正在加载数据…</div>;
   }
   if (error) {
     return (
@@ -2180,6 +2191,7 @@ function F4State({ loading, error, empty }: {
         <AlertCircle className="inline mr-2" size={15} />
         {error.httpStatus === 403 ? "当前账号无权访问文档数据" : error.message}
         {error.requestId && <span className="block text-xs mt-1 opacity-60">错误编号:{error.requestId}</span>}
+        {onRetry && error.httpStatus !== 401 && <button onClick={onRetry} className="block mx-auto mt-3 px-3 py-1.5 text-xs font-black border" style={{ color: N, borderColor: B }}>重新加载</button>}
       </div>
     );
   }
@@ -2247,9 +2259,9 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
               <td className="px-4 py-3 text-sm font-bold" style={{ color: N }}>{index + 1}</td>
               <td className="px-4 py-3">
                 <div className="text-sm font-black" style={{ color: N }}>{item.documentName}</div>
-                <div className="text-[11px] font-bold opacity-55" style={{ color: N }}>
-                  ID {item.id}{attachment ? ` · ${item.fileExtension || "无扩展名"}` : ""}
-                </div>
+                {attachment && <div className="text-[11px] font-bold opacity-55" style={{ color: N }}>
+                  {item.fileExtension ? item.fileExtension.toUpperCase() : "无扩展名"}
+                </div>}
               </td>
               <td className="px-4 py-3 text-xs font-bold" style={{ color: N }}>
                 {attachment ? dictionaries.label("attachmentTypes", item.attachmentType) : item.categoryName ?? "未分类"}
@@ -2266,7 +2278,7 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
               </td>
               <td className="px-4 py-3 whitespace-nowrap">
                 {canView && <button onClick={(event) => { event.stopPropagation(); onDetail(item, attachment); }} className="mr-2 text-xs font-black" style={{ color: B }}>详情</button>}
-                {canView && onPreview && <button onClick={(event) => { event.stopPropagation(); onPreview(item); }} className="mr-2 text-xs font-black" style={{ color: B }}>预览</button>}
+            {canView && onPreview && <button onClick={(event) => { event.stopPropagation(); onPreview(item); }} className="mr-2 text-xs font-black" style={{ color: B }}>查看</button>}
                 {canDownload && onFileError && <DownloadButton item={item} onError={onFileError} onSuccess={() => { invalidateDetail(attachment ? "attachment" : "document", item.id); notifyContentChanged(attachment ? "attachments" : "documents"); }} />}
                 {canEdit && onEdit && <button onClick={(event) => { event.stopPropagation(); onEdit(item); }} className="mr-2 text-xs font-black" style={{ color: B }}>编辑</button>}
                 {canDelete && onDelete && <button onClick={(event) => { event.stopPropagation(); onDelete(item); }} className="mr-2 text-xs font-black" style={{ color: N }}>删除</button>}
@@ -2305,7 +2317,7 @@ function F4DetailModal({ item, attachment, onClose }: {
     <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(26,82,153,.5)" }} onClick={onClose}>
       <div className="w-[680px] max-h-[82vh] overflow-auto shadow-2xl" style={{ background: W }} onClick={(event) => event.stopPropagation()}>
         <div className="h-11 px-5 flex items-center justify-between" style={{ background: N, color: W }}>
-          <span className="font-black">真实详情 · {item.documentName}</span>
+          <span className="font-black">详情 · {item.documentName}</span>
           <button onClick={onClose}><X size={17} /></button>
         </div>
         <div className="p-6">
@@ -2328,7 +2340,6 @@ function F4DetailModal({ item, attachment, onClose }: {
                 <span>查看次数:{detail.viewCount}</span>
                 <span>下载次数:{detail.downloadCount}</span>
               </div>
-              <div className="p-4 text-center font-black" style={{ background: PBG }}>请使用列表中的“预览”或“下载”入口读取真实文件。</div>
             </div>
           )}
         </div>
@@ -2351,7 +2362,7 @@ function F4ReverseModal({ attachment, onClose }: { attachment: DocumentSummary;
           <F4State loading={state.loading} error={state.error} empty={state.data?.length === 0} />
           {state.data?.map((plan: MainPlanBrief) => (
             <div key={plan.id} className="flex items-center justify-between px-3 py-3 border-b" style={{ borderColor: "rgba(40,118,193,.12)", color: N }}>
-              <div><div className="text-sm font-black">{plan.documentName}</div><div className="text-xs opacity-55">主案 ID {plan.id} · {plan.categoryName ?? "未分类"}</div></div>
+                      <div><div className="text-sm font-black">{plan.documentName}</div><div className="text-xs opacity-55">{plan.categoryName ?? "未分类"}</div></div>
               <LevelBadge level={dictionaries.label("securityLevels", plan.securityLevel)} />
             </div>
           ))}
@@ -2394,7 +2405,7 @@ function F4AttachmentLibrary({ compact = false, management = false }: { compact?
           <option value="">全部附件类型</option>
           {dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}
         </select>
-        <span className="ml-auto text-xs font-bold" style={{ color: N }}>列表不请求文件大小 · 共 {state.data?.total ?? 0} 项</span>
+        <span className="ml-auto text-xs font-bold" style={{ color: N }}>共 {state.data?.total ?? 0} 项</span>
         {management && <><button onClick={() => setUpload(true)} className="px-3 py-1.5 text-xs font-black text-white" style={{ background: B }}>上传附件</button><button onClick={() => setBatch(true)} className="px-3 py-1.5 text-xs font-black border" style={{ color: N, borderColor: B }}>批量导入</button></>}
       </div>
       <div className="flex-1 overflow-auto">
@@ -2438,30 +2449,74 @@ function F4AttachmentLibrary({ compact = false, management = false }: { compact?
 function RealMainPage({ onNav, navigationItems, onLogout, logoutPending }: AuthenticatedPageProps) {
   const [tab, setTab] = useState<"main" | "all" | "recent">("main");
   const [keyword, setKeyword] = useState("");
+  const [updatedFrom, setUpdatedFrom] = useState("");
+  const [updatedTo, setUpdatedTo] = useState("");
   const [categoryId, setCategoryId] = useState<string | null>(null);
   const [page, setPage] = useState(1);
   const [selected, setSelected] = useState<DocumentSummary | null>(null);
   const [lowerTab, setLowerTab] = useState<"sub" | "mounted" | "library">("sub");
+  const [subKeyword, setSubKeyword] = useState("");
+  const [subStatus, setSubStatus] = useState<DocumentStatusCode | "">("");
+  const [subUpdatedFrom, setSubUpdatedFrom] = useState("");
+  const [subUpdatedTo, setSubUpdatedTo] = useState("");
+  const [subPage, setSubPage] = useState(1);
+  const [mountedKeyword, setMountedKeyword] = useState("");
+  const [mountedType, setMountedType] = useState<AttachmentTypeCode | "">("");
+  const [mountedUpdatedFrom, setMountedUpdatedFrom] = useState("");
+  const [mountedUpdatedTo, setMountedUpdatedTo] = useState("");
+  const [mountedPage, setMountedPage] = useState(1);
   const [detail, setDetail] = useState<{ item: DocumentSummary; attachment: boolean } | null>(null);
   const [preview, setPreview] = useState<DocumentSummary | null>(null);
   const [edit, setEdit] = useState<DocumentSummary | null>(null);
   const [remove, setRemove] = useState<DocumentSummary | null>(null);
   const [fileError, setFileError] = useState<HttpError | null>(null);
   const [permission, setPermission] = useState<DocumentSummary | null>(null);
+  const mainRange = useMemo(() => toUpdatedTimeRange(updatedFrom, updatedTo), [updatedFrom, updatedTo]);
+  const subRange = useMemo(() => toUpdatedTimeRange(subUpdatedFrom, subUpdatedTo), [subUpdatedFrom, subUpdatedTo]);
+  const mountedRange = useMemo(() => toUpdatedTimeRange(mountedUpdatedFrom, mountedUpdatedTo), [mountedUpdatedFrom, mountedUpdatedTo]);
   const documents = useDocumentList({
     documentType: tab === "main" ? DocumentType.MAIN : `${DocumentType.MAIN},${DocumentType.SUB_PLAN}`,
     categoryId: categoryId ?? undefined,
     keyword: keyword.trim() || undefined,
+    ...mainRange.query,
     sortBy: tab === "recent" ? "updatedAt" : "documentName",
     sortDirection: tab === "recent" ? "desc" : "asc",
     page,
     pageSize: 20,
-  });
-  const subPlans = useSubPlanList(selected?.documentType === DocumentType.MAIN ? selected.id : null, { page: 1, pageSize: 20, sortBy: "documentName", sortDirection: "asc" });
-  const mounted = useMountedAttachments(selected?.documentType === DocumentType.MAIN ? selected.id : null, { page: 1, pageSize: 20 });
+  }, !mainRange.error);
+  const selectedMainId = selected?.documentType === DocumentType.MAIN ? selected.id : null;
+  const subPlans = useSubPlanList(selectedMainId, {
+    keyword: subKeyword.trim() || undefined,
+    status: subStatus || undefined,
+    ...subRange.query,
+    page: subPage,
+    pageSize: 20,
+    sortBy: "documentName",
+    sortDirection: "asc",
+  }, !subRange.error);
+  const mounted = useMountedAttachments(selectedMainId, {
+    keyword: mountedKeyword.trim() || undefined,
+    attachmentType: mountedType || undefined,
+    ...mountedRange.query,
+    page: mountedPage,
+    pageSize: 20,
+  }, !mountedRange.error);
+
+  useEffect(() => {
+    setSubKeyword("");
+    setSubStatus("");
+    setSubUpdatedFrom("");
+    setSubUpdatedTo("");
+    setSubPage(1);
+    setMountedKeyword("");
+    setMountedType("");
+    setMountedUpdatedFrom("");
+    setMountedUpdatedTo("");
+    setMountedPage(1);
+  }, [selectedMainId]);
 
   return (
-    <PageFrame title="方案计划系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
+    <PageFrame title="方案管理系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
       <LeftPanel selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPage(1); setSelected(null); }} />
       <div className="flex-1 flex flex-col m-3 ml-0 min-h-0">
         <div className="flex items-center mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
@@ -2469,27 +2524,54 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending }: Authe
             <button key={item.id} onClick={() => { setTab(item.id); setPage(1); setSelected(null); }} className="px-7 py-3 text-sm font-black"
               style={{ color: tab === item.id ? W : N, background: tab === item.id ? N : W }}>{item.label}</button>
           ))}
-          <div className="ml-auto mr-3 flex items-center gap-2"><Search size={14} style={{ color: B }} /><input value={keyword} onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索方案名称或概述" className="w-64 h-8 px-2 text-sm font-bold outline-none border" style={{ color: N }} /></div>
         </div>
+        <DocumentSearchToolbar
+          ariaLabel="方案检索"
+          keyword={keyword}
+          keywordPlaceholder="搜索方案名称或概述"
+          updatedFrom={updatedFrom}
+          updatedTo={updatedTo}
+          error={mainRange.error}
+          onKeywordChange={(value) => { setKeyword(value); setPage(1); setSelected(null); }}
+          onUpdatedFromChange={(value) => { setUpdatedFrom(value); setPage(1); setSelected(null); }}
+          onUpdatedToChange={(value) => { setUpdatedTo(value); setPage(1); setSelected(null); }}
+          onClear={() => { setKeyword(""); setUpdatedFrom(""); setUpdatedTo(""); setPage(1); setSelected(null); }}
+        />
         <div className="flex-1 overflow-auto min-h-0 mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
-          <F4State loading={documents.loading} error={documents.error} empty={documents.data?.items.length === 0} />
+          <F4State loading={documents.loading} error={documents.error} empty={documents.data?.items.length === 0} onRetry={documents.retry} />
           {documents.data && documents.data.items.length > 0 && <F4DocumentTable items={documents.data.items} selectedId={selected?.id} onSelect={setSelected} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} />}
           {documents.data && <F4Pager page={page} totalPages={documents.data.totalPages} onPage={(value) => { setPage(value); setSelected(null); }} />}
         </div>
-        <div className="h-[290px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
+        <div className="h-[390px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
           <div className="flex border-b" style={{ borderColor: "rgba(40,118,193,.14)" }}>
             {([{ id: "sub", label: "直属子方案" }, { id: "mounted", label: "已挂载共享附件" }, { id: "library", label: "共享附件库" }] as const).map((item) => (
               <button key={item.id} onClick={() => setLowerTab(item.id)} className="px-6 py-2.5 text-sm font-black" style={{ color: lowerTab === item.id ? W : N, background: lowerTab === item.id ? N : W }}>{item.label}</button>
             ))}
             <span className="ml-auto px-4 py-3 text-xs font-bold" style={{ color: N }}>{selected ? `当前:${selected.documentName}` : "请选择一个主案"}</span>
           </div>
-          <div className="flex-1 overflow-auto">
+          <div className="flex-1 overflow-auto min-h-0">
             {lowerTab === "library" ? <F4AttachmentLibrary compact /> : !selected || selected.documentType !== DocumentType.MAIN ? (
-              <div className="p-12 text-center text-sm font-bold opacity-50" style={{ color: N }}>请选择主案查看真实关联数据</div>
+              <div className="p-12 text-center text-sm font-bold opacity-50" style={{ color: N }}>请选择主案查看关联数据</div>
             ) : lowerTab === "sub" ? (
-              <><F4State loading={subPlans.loading} error={subPlans.error} empty={subPlans.data?.items.length === 0} />{subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}</>
+              <>
+                <DocumentSearchToolbar ariaLabel="直属子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索子方案名称或概述" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
+                  selectFilter={{ dictionary: "documentStatuses", value: subStatus, emptyLabel: "全部状态", ariaLabel: "直属子方案状态", onChange: (value) => { setSubStatus(value as DocumentStatusCode | ""); setSubPage(1); } }}
+                  onKeywordChange={(value) => { setSubKeyword(value); setSubPage(1); }} onUpdatedFromChange={(value) => { setSubUpdatedFrom(value); setSubPage(1); }} onUpdatedToChange={(value) => { setSubUpdatedTo(value); setSubPage(1); }}
+                  onClear={() => { setSubKeyword(""); setSubStatus(""); setSubUpdatedFrom(""); setSubUpdatedTo(""); setSubPage(1); }} />
+                <F4State loading={subPlans.loading} error={subPlans.error} empty={subPlans.data?.items.length === 0} onRetry={subPlans.retry} />
+                {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
+                {subPlans.data && <F4Pager page={subPage} totalPages={subPlans.data.totalPages} onPage={setSubPage} />}
+              </>
             ) : (
-              <><F4State loading={mounted.loading} error={mounted.error} empty={mounted.data?.items.length === 0} />{mounted.data?.items.length ? <F4DocumentTable items={mounted.data.items} bindingMode onDetail={(item) => setDetail({ item, attachment: true })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onFileError={setFileError} /> : null}</>
+              <>
+                <DocumentSearchToolbar ariaLabel="已挂载附件检索" compact keyword={mountedKeyword} keywordPlaceholder="搜索附件名称或概述" updatedFrom={mountedUpdatedFrom} updatedTo={mountedUpdatedTo} error={mountedRange.error}
+                  selectFilter={{ dictionary: "attachmentTypes", value: mountedType, emptyLabel: "全部附件类型", ariaLabel: "已挂载附件类型", onChange: (value) => { setMountedType(value as AttachmentTypeCode | ""); setMountedPage(1); } }}
+                  onKeywordChange={(value) => { setMountedKeyword(value); setMountedPage(1); }} onUpdatedFromChange={(value) => { setMountedUpdatedFrom(value); setMountedPage(1); }} onUpdatedToChange={(value) => { setMountedUpdatedTo(value); setMountedPage(1); }}
+                  onClear={() => { setMountedKeyword(""); setMountedType(""); setMountedUpdatedFrom(""); setMountedUpdatedTo(""); setMountedPage(1); }} />
+                <F4State loading={mounted.loading} error={mounted.error} empty={mounted.data?.items.length === 0} onRetry={mounted.retry} />
+                {mounted.data?.items.length ? <F4DocumentTable items={mounted.data.items} bindingMode onDetail={(item) => setDetail({ item, attachment: true })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onFileError={setFileError} /> : null}
+                {mounted.data && <F4Pager page={mountedPage} totalPages={mounted.data.totalPages} onPage={setMountedPage} />}
+              </>
             )}
           </div>
         </div>
@@ -2508,9 +2590,21 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
   const [mode, setMode] = useState<"plans" | "attachments">("plans");
   const [categoryId, setCategoryId] = useState<string | null>(null);
   const [keyword, setKeyword] = useState("");
+  const [updatedFrom, setUpdatedFrom] = useState("");
+  const [updatedTo, setUpdatedTo] = useState("");
   const [planPage, setPlanPage] = useState(1);
   const [selected, setSelected] = useState<DocumentSummary | null>(null);
   const [relationTab, setRelationTab] = useState<"sub" | "mounted">("mounted");
+  const [subKeyword, setSubKeyword] = useState("");
+  const [subStatus, setSubStatus] = useState<DocumentStatusCode | "">("");
+  const [subUpdatedFrom, setSubUpdatedFrom] = useState("");
+  const [subUpdatedTo, setSubUpdatedTo] = useState("");
+  const [subPage, setSubPage] = useState(1);
+  const [mountedKeyword, setMountedKeyword] = useState("");
+  const [mountedType, setMountedType] = useState<AttachmentTypeCode | "">("");
+  const [mountedUpdatedFrom, setMountedUpdatedFrom] = useState("");
+  const [mountedUpdatedTo, setMountedUpdatedTo] = useState("");
+  const [mountedPage, setMountedPage] = useState(1);
   const [detail, setDetail] = useState<{ item: DocumentSummary; attachment: boolean } | null>(null);
   const [preview, setPreview] = useState<DocumentSummary | null>(null);
   const [edit, setEdit] = useState<DocumentSummary | null>(null);
@@ -2522,9 +2616,25 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
   const [permission, setPermission] = useState<DocumentSummary | null>(null);
   const [unbind, setUnbind] = useState<AttachmentBindingSummary | null>(null);
   const [recycleBinOpen, setRecycleBinOpen] = useState(false);
-  const plans = useDocumentList({ documentType: DocumentType.MAIN, categoryId: categoryId ?? undefined, keyword: keyword.trim() || undefined, sortBy: "updatedAt", sortDirection: "desc", page: planPage, pageSize: 20 });
-  const subPlans = useSubPlanList(selected?.id ?? null, { page: 1, pageSize: 50, sortBy: "documentName", sortDirection: "asc" });
-  const mounted = useMountedAttachments(selected?.id ?? null, { page: 1, pageSize: 50 });
+  const mainRange = useMemo(() => toUpdatedTimeRange(updatedFrom, updatedTo), [updatedFrom, updatedTo]);
+  const subRange = useMemo(() => toUpdatedTimeRange(subUpdatedFrom, subUpdatedTo), [subUpdatedFrom, subUpdatedTo]);
+  const mountedRange = useMemo(() => toUpdatedTimeRange(mountedUpdatedFrom, mountedUpdatedTo), [mountedUpdatedFrom, mountedUpdatedTo]);
+  const plans = useDocumentList({ documentType: DocumentType.MAIN, categoryId: categoryId ?? undefined, keyword: keyword.trim() || undefined, ...mainRange.query, sortBy: "updatedAt", sortDirection: "desc", page: planPage, pageSize: 20 }, !mainRange.error);
+  const subPlans = useSubPlanList(selected?.id ?? null, { keyword: subKeyword.trim() || undefined, status: subStatus || undefined, ...subRange.query, page: subPage, pageSize: 20, sortBy: "documentName", sortDirection: "asc" }, !subRange.error);
+  const mounted = useMountedAttachments(selected?.id ?? null, { keyword: mountedKeyword.trim() || undefined, attachmentType: mountedType || undefined, ...mountedRange.query, page: mountedPage, pageSize: 20 }, !mountedRange.error);
+
+  useEffect(() => {
+    setSubKeyword("");
+    setSubStatus("");
+    setSubUpdatedFrom("");
+    setSubUpdatedTo("");
+    setSubPage(1);
+    setMountedKeyword("");
+    setMountedType("");
+    setMountedUpdatedFrom("");
+    setMountedUpdatedTo("");
+    setMountedPage(1);
+  }, [selected?.id]);
 
   useEffect(() => {
     if (!selected || !plans.data) return;
@@ -2533,30 +2643,29 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
   }, [plans.data, selected]);
 
   return (
-    <PageFrame title="方案计划系统后台" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
-      {mode === "plans" ? <LeftPanel admin selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPlanPage(1); setSelected(null); }} /> : <div className="w-[272px] m-3 p-5 flex-shrink-0" style={{ background: W, color: N }}><Library size={22} className="mb-3" /><div className="font-black">共享附件</div><p className="text-xs font-bold opacity-60 mt-2 leading-6">附件独立存在,可上传、批量导入、编辑、预览、下载和逻辑删除。共享附件面向全部有效登录用户,不配置组织或人员 ACL。</p></div>}
+    <PageFrame title="方案管理系统后台" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
+      {mode === "plans" ? <LeftPanel admin selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPlanPage(1); setSelected(null); }} /> : <div className="w-[272px] m-3 p-5 flex-shrink-0" style={{ background: W, color: N }}><Library size={22} className="mb-3" /><div className="font-black">共享附件</div></div>}
       <div className="flex-1 flex flex-col m-3 ml-0 min-h-0">
         <DocumentStatisticsCards />
         <div className="flex items-center mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
           <button onClick={() => setMode("plans")} className="px-7 py-3 text-sm font-black" style={{ color: mode === "plans" ? W : N, background: mode === "plans" ? N : W }}>方案管理</button>
           <button onClick={() => setMode("attachments")} className="px-7 py-3 text-sm font-black" style={{ color: mode === "attachments" ? W : N, background: mode === "attachments" ? N : W }}>共享附件库</button>
-          <span className="ml-auto mr-3 text-xs font-black" style={{ color: N }}>F5 文件管理基线 · F6 挂载关系与主案权限已接入</span>
-          <div className="flex gap-2 mr-3">
+          <div className="ml-auto flex gap-2 mr-3">
             <button onClick={() => setRecycleBinOpen(true)} className="px-3 py-1.5 text-xs font-black border" style={{ color: N, borderColor: B }}><Trash2 size={12} className="inline mr-1" />回收站</button>
             {mode === "plans" && <><button onClick={() => setUploadDocument(true)} className="px-3 py-1.5 text-xs font-black text-white" style={{ background: B }}>上传主案/子方案</button><button onClick={() => setBatchDocuments(true)} className="px-3 py-1.5 text-xs font-black border" style={{ color: N, borderColor: B }}>批量导入方案</button></>}
           </div>
         </div>
         {mode === "attachments" ? <div className="flex-1 min-h-0"><F4AttachmentLibrary management /></div> : (
           <>
-            <div className="flex items-center gap-2 p-2 mb-2" style={{ background: W }}>
-              <Search size={14} style={{ color: B }} /><input value={keyword} onChange={(event) => { setKeyword(event.target.value); setPlanPage(1); }} placeholder="搜索主案" className="h-8 w-64 px-2 text-sm font-bold border outline-none" style={{ color: N }} />
-            </div>
+            <DocumentSearchToolbar ariaLabel="后台主案检索" keyword={keyword} keywordPlaceholder="搜索主案名称或概述" updatedFrom={updatedFrom} updatedTo={updatedTo} error={mainRange.error}
+              onKeywordChange={(value) => { setKeyword(value); setPlanPage(1); setSelected(null); }} onUpdatedFromChange={(value) => { setUpdatedFrom(value); setPlanPage(1); setSelected(null); }} onUpdatedToChange={(value) => { setUpdatedTo(value); setPlanPage(1); setSelected(null); }}
+              onClear={() => { setKeyword(""); setUpdatedFrom(""); setUpdatedTo(""); setPlanPage(1); setSelected(null); }} />
             <div className="flex-1 overflow-auto min-h-0 mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
-              <F4State loading={plans.loading} error={plans.error} empty={plans.data?.items.length === 0} />
+              <F4State loading={plans.loading} error={plans.error} empty={plans.data?.items.length === 0} onRetry={plans.retry} />
               {plans.data?.items.length ? <F4DocumentTable items={plans.data.items} selectedId={selected?.id} onSelect={setSelected} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
               {plans.data && <F4Pager page={planPage} totalPages={plans.data.totalPages} onPage={(value) => { setPlanPage(value); setSelected(null); }} />}
             </div>
-            <div className="h-[300px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
+            <div className="h-[400px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
               <div className="flex border-b" style={{ borderColor: "rgba(40,118,193,.14)" }}>
                 <button onClick={() => setRelationTab("sub")} className="px-7 py-2.5 text-sm font-black" style={{ color: relationTab === "sub" ? W : N, background: relationTab === "sub" ? N : W }}>直属子方案</button>
                 <button onClick={() => setRelationTab("mounted")} className="px-7 py-2.5 text-sm font-black" style={{ color: relationTab === "mounted" ? W : N, background: relationTab === "mounted" ? N : W }}>已挂载共享附件</button>
@@ -2564,11 +2673,27 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
                 {selected?.documentType === DocumentType.MAIN && selected.allowedActions.includes(AllowedAction.CONFIG_PERMISSION) && <button onClick={() => setPermission(selected)} className="my-1.5 ml-2 px-3 text-xs font-black border" style={{ color: N, borderColor: B }}>权限配置</button>}
                 <span className="ml-auto px-4 py-3 text-xs font-bold" style={{ color: N }}>{selected ? `当前主案:${selected.documentName}` : "请选择主案"}</span>
               </div>
-              <div className="flex-1 overflow-auto">
+              <div className="flex-1 overflow-auto min-h-0">
                 {!selected ? <div className="p-12 text-center text-sm font-bold opacity-50" style={{ color: N }}>请选择主案</div> : relationTab === "sub" ? (
-                  <><F4State loading={subPlans.loading} error={subPlans.error} empty={subPlans.data?.items.length === 0} />{subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}</>
+                  <>
+                    <DocumentSearchToolbar ariaLabel="后台直属子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索子方案名称或概述" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
+                      selectFilter={{ dictionary: "documentStatuses", value: subStatus, emptyLabel: "全部状态", ariaLabel: "后台直属子方案状态", onChange: (value) => { setSubStatus(value as DocumentStatusCode | ""); setSubPage(1); } }}
+                      onKeywordChange={(value) => { setSubKeyword(value); setSubPage(1); }} onUpdatedFromChange={(value) => { setSubUpdatedFrom(value); setSubPage(1); }} onUpdatedToChange={(value) => { setSubUpdatedTo(value); setSubPage(1); }}
+                      onClear={() => { setSubKeyword(""); setSubStatus(""); setSubUpdatedFrom(""); setSubUpdatedTo(""); setSubPage(1); }} />
+                    <F4State loading={subPlans.loading} error={subPlans.error} empty={subPlans.data?.items.length === 0} onRetry={subPlans.retry} />
+                    {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
+                    {subPlans.data && <F4Pager page={subPage} totalPages={subPlans.data.totalPages} onPage={setSubPage} />}
+                  </>
                 ) : (
-                  <><F4State loading={mounted.loading} error={mounted.error} empty={mounted.data?.items.length === 0} />{mounted.data?.items.length ? <F4DocumentTable items={mounted.data.items} bindingMode onDetail={(item) => setDetail({ item, attachment: true })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onUnbind={selected.allowedActions.includes(AllowedAction.UNBIND_ATTACHMENT) ? setUnbind : undefined} onFileError={setFileError} /> : null}</>
+                  <>
+                    <DocumentSearchToolbar ariaLabel="后台已挂载附件检索" compact keyword={mountedKeyword} keywordPlaceholder="搜索附件名称或概述" updatedFrom={mountedUpdatedFrom} updatedTo={mountedUpdatedTo} error={mountedRange.error}
+                      selectFilter={{ dictionary: "attachmentTypes", value: mountedType, emptyLabel: "全部附件类型", ariaLabel: "后台已挂载附件类型", onChange: (value) => { setMountedType(value as AttachmentTypeCode | ""); setMountedPage(1); } }}
+                      onKeywordChange={(value) => { setMountedKeyword(value); setMountedPage(1); }} onUpdatedFromChange={(value) => { setMountedUpdatedFrom(value); setMountedPage(1); }} onUpdatedToChange={(value) => { setMountedUpdatedTo(value); setMountedPage(1); }}
+                      onClear={() => { setMountedKeyword(""); setMountedType(""); setMountedUpdatedFrom(""); setMountedUpdatedTo(""); setMountedPage(1); }} />
+                    <F4State loading={mounted.loading} error={mounted.error} empty={mounted.data?.items.length === 0} onRetry={mounted.retry} />
+                    {mounted.data?.items.length ? <F4DocumentTable items={mounted.data.items} bindingMode onDetail={(item) => setDetail({ item, attachment: true })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onUnbind={selected.allowedActions.includes(AllowedAction.UNBIND_ATTACHMENT) ? setUnbind : undefined} onFileError={setFileError} /> : null}
+                    {mounted.data && <F4Pager page={mountedPage} totalPages={mounted.data.totalPages} onPage={setMountedPage} />}
+                  </>
                 )}
               </div>
             </div>
@@ -2781,7 +2906,7 @@ export default function App() {
             className="m-3 p-8 flex-1 text-sm font-bold"
             style={{ background: W, color: N }}
           >
-            后端返回的模块授权与该接口的角色权限不一致,本页面未发起任何受限数据请求。请联系管理员并提供当前账号信息
+            当前账号无法访问该模块,请联系管理员
           </div>
         </PageFrame>
       )}

+ 18 - 0
frontend/src/features/attachments/__tests__/attachmentApi.test.ts

@@ -55,6 +55,24 @@ describe("attachmentApi and F4-C1", () => {
     });
   });
 
+  it("已挂载附件透传类型、时间和分页组合", async () => {
+    const binding = { ...attachment, bindingId: "19", bindingSortNo: 10 };
+    const client = { get: vi.fn().mockResolvedValue(result({ ...page, items: [binding] })) };
+    const query = {
+      keyword: "法规",
+      attachmentType: "POLICY" as const,
+      updatedFrom: "2026-08-01T00:00:00.000Z",
+      updatedTo: "2026-08-04T00:00:00.000Z",
+      page: 2,
+      pageSize: 20,
+    };
+    await createAttachmentApi(client).mountedByMainPlan("1", query);
+    expect(client.get).toHaveBeenCalledWith("main-plans/1/attachments", {
+      query,
+      signal: undefined,
+    });
+  });
+
   it("正向挂载不放宽 binding DTO 校验", async () => {
     const client = { get: vi.fn().mockResolvedValue(result({ ...page, items: [{ ...attachment, bindingId: 19, bindingSortNo: "10" }] })) };
     await expect(createAttachmentApi(client).mountedByMainPlan("1", {})).rejects.toMatchObject({ code: "INTERNAL_ERROR" });

+ 25 - 7
frontend/src/features/attachments/attachmentState.ts

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { HttpError } from "../../shared/api/errors";
 import type { PageResult } from "../../shared/api/types";
 import type { DocumentSummary, MainPlanBrief } from "../documents/documentTypes";
@@ -27,29 +27,41 @@ function toHttpError(error: unknown): HttpError {
 function usePage<T>(
   loader: (signal: AbortSignal) => Promise<PageResult<T>>,
   dependencies: readonly unknown[],
-  delay = 300,
+  debounceKey: string | undefined,
+  enabled = true,
+  debounceMs = 300,
 ): RemotePageState<T> {
   const [state, setState] = useState<RemotePageState<T>>({
     data: null,
     loading: true,
     error: null,
+    retry: () => undefined,
   });
   const version = useRef(0);
+  const previousDebounceKey = useRef(debounceKey);
+  const [retryVersion, setRetryVersion] = useState(0);
+  const retry = useCallback(() => setRetryVersion((value) => value + 1), []);
 
   useEffect(() => {
+    if (!enabled) {
+      setState((previous) => ({ ...previous, loading: false, error: null, retry }));
+      return;
+    }
     const current = ++version.current;
     const controller = new AbortController();
+    const delay = previousDebounceKey.current === debounceKey ? 0 : debounceMs;
+    previousDebounceKey.current = debounceKey;
     const timer = window.setTimeout(() => {
-      setState((previous) => ({ ...previous, loading: true, error: null }));
+      setState((previous) => ({ ...previous, loading: true, error: null, retry }));
       void loader(controller.signal)
         .then((data) => {
           if (version.current === current) {
-            setState({ data, loading: false, error: null });
+            setState({ data, loading: false, error: null, retry });
           }
         })
         .catch((error: unknown) => {
           if (version.current !== current || controller.signal.aborted) return;
-          setState({ data: null, loading: false, error: toHttpError(error) });
+          setState({ data: null, loading: false, error: toHttpError(error), retry });
         });
     }, delay);
     return () => {
@@ -57,9 +69,9 @@ function usePage<T>(
       controller.abort();
     };
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, dependencies);
+  }, [...dependencies, enabled, retryVersion]);
 
-  return state;
+  return { ...state, retry };
 }
 
 export function useAttachmentList(
@@ -80,12 +92,14 @@ export function useAttachmentList(
       query.pageSize,
       revision,
     ],
+    query.keyword,
   );
 }
 
 export function useMountedAttachments(
   mainPlanId: string | null,
   query: MountedAttachmentQuery,
+  enabled = true,
 ): RemotePageState<AttachmentBindingSummary> {
   const documentRevision = useContentRevision("documents");
   const attachmentRevision = useContentRevision("attachments");
@@ -104,11 +118,15 @@ export function useMountedAttachments(
       mainPlanId,
       query.keyword,
       query.attachmentType,
+      query.updatedFrom,
+      query.updatedTo,
       query.page,
       query.pageSize,
       documentRevision,
       attachmentRevision,
     ],
+    query.keyword,
+    enabled,
   );
 }
 

+ 2 - 0
frontend/src/features/attachments/attachmentTypes.ts

@@ -23,6 +23,8 @@ export interface AttachmentListQuery {
 export interface MountedAttachmentQuery {
   keyword?: string;
   attachmentType?: AttachmentTypeCode;
+  updatedFrom?: string;
+  updatedTo?: string;
   page?: number;
   pageSize?: number;
 }

+ 1 - 1
frontend/src/features/audit/AuditDashboard.tsx

@@ -362,7 +362,7 @@ export function AuditDashboard() {
             className="px-4 py-2.5 flex items-center gap-3 border-b"
             style={{ color: NAVY, borderColor: "rgba(40,118,193,.14)" }}
           >
-            <span className="font-black">真实操作日志</span>
+          <span className="font-black">操作日志</span>
             <span className="text-xs font-black" style={{ color: BLUE }}>
               {logs.data?.total ?? 0} 条
             </span>

+ 16 - 1
frontend/src/features/audit/__tests__/auditTypes.test.ts

@@ -64,6 +64,11 @@ describe("F7 audit DTO guards", () => {
         items: [{ actionType: "LOGIN", count: 1 }],
       }),
     ).toBe(true);
+    expect(
+      isAuditActionStatisticsData({
+        items: [{ actionType: "RESTORE_DOCUMENT", count: 1 }],
+      }),
+    ).toBe(true);
     expect(
       isAuditUserStatisticsData({
         items: [
@@ -94,5 +99,15 @@ describe("F7 audit DTO guards", () => {
       }),
     ).toBe(false);
   });
-});
 
+  it("审计日志严格接受RESTORE_DOCUMENT且仍拒绝未知动作", () => {
+    expect(isAuditLogPage({
+      ...page,
+      items: [{ ...baseLog, actionType: "RESTORE_DOCUMENT" }],
+    })).toBe(true);
+    expect(isAuditLogPage({
+      ...page,
+      items: [{ ...baseLog, actionType: "RESTORE_ANYTHING" }],
+    })).toBe(false);
+  });
+});

+ 6 - 4
frontend/src/features/auth/__tests__/appScope.test.ts

@@ -13,9 +13,11 @@ describe("F5 App scope", () => {
     }
   });
 
-  it("保留F4真实列表并接入F5文件操作", () => {
-    expect(appSource).toContain("请使用列表中的“预览”或“下载”入口读取真实文件");
-    expect(appSource).toContain("F5 文件管理");
-    expect(appSource).toContain("列表不请求文件大小");
+  it("保留文档列表并接入文件操作", () => {
+    expect(appSource).toContain(">查看</button>");
+    expect(appSource).not.toContain("请使用列表中的“预览”或“下载”入口读取真实文件");
+    expect(appSource).not.toContain("F5 文件管理基线");
+    expect(appSource).toContain("共 {state.data?.total ?? 0} 项");
+    expect(appSource).not.toContain("列表不请求文件大小");
   });
 });

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

@@ -174,7 +174,7 @@ export function AttachmentBindingModal({
         <div className="h-12 px-5 flex items-center justify-between" style={{ background: NAVY, color: WHITE }}>
           <div>
             <span className="font-black">挂载共享附件</span>
-            <span className="ml-3 text-xs opacity-75">主案:{mainPlan.documentName} · ID {mainPlan.id}</span>
+          <span className="ml-3 text-xs opacity-75">主案:{mainPlan.documentName}</span>
           </div>
           <button onClick={onClose} disabled={submitting}><X size={18} /></button>
         </div>
@@ -222,7 +222,7 @@ export function AttachmentBindingModal({
         </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>}
+          {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" />
@@ -254,7 +254,7 @@ export function AttachmentBindingModal({
                           onChange={() => setSelected((current) => toggleAttachmentSelection(current, item.id))}
                         />
                       </td>
-                      <td className="px-4 py-3"><div className="text-sm font-black">{item.documentName}</div><div className="text-[11px] opacity-55">ID {item.id}</div></td>
+                  <td className="px-4 py-3"><div className="text-sm font-black">{item.documentName}</div></td>
                       <td className="px-4 py-3 text-xs font-bold">{dictionaries.label("attachmentTypes", item.attachmentType)}</td>
                       <td className="px-4 py-3 text-xs font-bold">{item.fileExtension}</td>
                       <td className="px-4 py-3 text-xs font-bold">{new Date(item.updatedAt).toLocaleString("zh-CN")}</td>

+ 1 - 7
frontend/src/features/bindings/UnbindAttachmentModal.tsx

@@ -86,12 +86,7 @@ export function UnbindAttachmentModal({
         </div>
         <div className="p-6">
           <p className="font-black mb-4">确认解除“{mainPlan.documentName}”与“{attachment.documentName}”的挂载关系?</p>
-          <ul className="list-disc pl-5 space-y-2 text-sm font-bold leading-6">
-            <li>只解除当前主案与附件的引用关系。</li>
-            <li>不会删除共享附件,也不会删除附件文件。</li>
-            <li>不会影响该附件挂载到其他主案。</li>
-            <li>解除后仍可从共享附件库重新挂载。</li>
-          </ul>
+          <div className="text-sm font-bold">附件及其文件不会被删除。</div>
           {error && <div className="mt-4 p-3 text-sm font-black" style={{ background: "#EBF4FD" }}><AlertCircle size={14} className="inline mr-1" />{errorMessage}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
           <div className="flex justify-end gap-2 mt-6">
             <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={{ borderColor: BLUE }}>取消</button>
@@ -104,4 +99,3 @@ export function UnbindAttachmentModal({
     </div>
   );
 }
-

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

@@ -4,6 +4,7 @@ import {
   findCategoryNode,
   flattenCategoryOptions,
   flattenCategoryTree,
+  getCategoryBranchMainPlanCount,
   getCategoryParentOptions,
   retainExistingCategoryIds,
 } from "../categoryTree";
@@ -60,4 +61,12 @@ describe("category tree pure functions", () => {
   it("刷新后只保留仍存在的展开ID", () => {
     expect([...retainExistingCategoryIds(new Set(["1", "3", "9"]), [root])]).toEqual(["1", "3"]);
   });
+
+  it("父分类展示自身及全部后代的主案总数", () => {
+    const countedGrandchild = { ...grandchild, documentCount: 3 };
+    const countedChild = { ...child, documentCount: 2, children: [countedGrandchild] };
+    const countedRoot = { ...root, documentCount: 1, children: [countedChild] };
+    expect(getCategoryBranchMainPlanCount(countedRoot)).toBe(6);
+    expect(getCategoryBranchMainPlanCount(countedChild)).toBe(5);
+  });
 });

+ 7 - 0
frontend/src/features/categories/categoryTree.ts

@@ -61,6 +61,13 @@ export function findCategoryNode(
   return flattenCategoryTree(tree).find((node) => node.id === id) ?? null;
 }
 
+export function getCategoryBranchMainPlanCount(node: CategoryNode): number {
+  return node.documentCount + node.children.reduce(
+    (total, child) => total + getCategoryBranchMainPlanCount(child),
+    0,
+  );
+}
+
 export function getCategoryDescendantIds(node: CategoryNode): Set<string> {
   return new Set(flattenCategoryTree(node.children).map((child) => child.id));
 }

+ 113 - 0
frontend/src/features/documents/DocumentSearchToolbar.tsx

@@ -0,0 +1,113 @@
+import { Search, X } from "lucide-react";
+import type { DictionaryName } from "../dictionaries/dictionaryTypes";
+import { useDictionaries } from "../dictionaries/dictionaryContext";
+
+interface SelectFilter {
+  dictionary: DictionaryName;
+  value: string;
+  emptyLabel: string;
+  ariaLabel: string;
+  onChange: (value: string) => void;
+}
+
+export function DocumentSearchToolbar({
+  ariaLabel,
+  keyword,
+  keywordPlaceholder,
+  updatedFrom,
+  updatedTo,
+  selectFilter,
+  error,
+  onKeywordChange,
+  onUpdatedFromChange,
+  onUpdatedToChange,
+  onClear,
+  compact = false,
+}: {
+  ariaLabel: string;
+  keyword: string;
+  keywordPlaceholder: string;
+  updatedFrom: string;
+  updatedTo: string;
+  selectFilter?: SelectFilter;
+  error: string | null;
+  onKeywordChange: (value: string) => void;
+  onUpdatedFromChange: (value: string) => void;
+  onUpdatedToChange: (value: string) => void;
+  onClear: () => void;
+  compact?: boolean;
+}) {
+  const dictionaries = useDictionaries();
+  const inputClass = compact
+    ? "h-8 px-2 text-xs font-bold border outline-none"
+    : "h-8 px-2 text-sm font-bold border outline-none";
+
+  return (
+    <div
+      aria-label={ariaLabel}
+      className="flex flex-wrap items-end gap-2 p-2"
+      style={{ color: "#1A5299", background: "#FFFFFF" }}
+    >
+      <label className="flex items-center gap-2">
+        <Search size={14} style={{ color: "#3191eb" }} />
+        <input
+          aria-label={`${ariaLabel}关键词`}
+          value={keyword}
+          onChange={(event) => onKeywordChange(event.target.value)}
+          placeholder={keywordPlaceholder}
+          className={`${inputClass} ${compact ? "w-48" : "w-60"}`}
+          style={{ borderColor: "rgba(40,118,193,.25)" }}
+        />
+      </label>
+      {selectFilter && (
+        <select
+          aria-label={selectFilter.ariaLabel}
+          value={selectFilter.value}
+          onChange={(event) => selectFilter.onChange(event.target.value)}
+          className={inputClass}
+          style={{ borderColor: "rgba(40,118,193,.25)" }}
+        >
+          <option value="">{selectFilter.emptyLabel}</option>
+          {dictionaries.options(selectFilter.dictionary).map((option) => (
+            <option key={option.code} value={option.code}>{option.label}</option>
+          ))}
+        </select>
+      )}
+      <label className="text-[11px] font-bold">
+        <span className="block mb-1">更新时间从</span>
+        <input
+          aria-label={`${ariaLabel}更新时间从`}
+          type="datetime-local"
+          value={updatedFrom}
+          onChange={(event) => onUpdatedFromChange(event.target.value)}
+          className={inputClass}
+          style={{ borderColor: "rgba(40,118,193,.25)" }}
+        />
+      </label>
+      <label className="text-[11px] font-bold">
+        <span className="block mb-1">更新时间至</span>
+        <input
+          aria-label={`${ariaLabel}更新时间至`}
+          type="datetime-local"
+          value={updatedTo}
+          onChange={(event) => onUpdatedToChange(event.target.value)}
+          className={inputClass}
+          style={{ borderColor: "rgba(40,118,193,.25)" }}
+        />
+      </label>
+      <button
+        type="button"
+        onClick={onClear}
+        className="h-8 px-3 text-xs font-black border"
+        style={{ color: "#1A5299", borderColor: "#3191eb" }}
+      >
+        <X size={12} className="inline mr-1" />清空条件
+      </button>
+      {error && (
+        <span role="alert" className="w-full text-xs font-black text-red-600">
+          {error}
+        </span>
+      )}
+    </div>
+  );
+}

+ 20 - 1
frontend/src/features/documents/__tests__/documentApi.test.ts

@@ -9,7 +9,7 @@ describe("documentApi", () => {
     const client = { get: vi.fn().mockResolvedValue(result(page)) };
     const api = createDocumentApi(client);
     const signal = new AbortController().signal;
-    const query = { documentType: "MAIN,SUB_PLAN", categoryId: "2", keyword: "应急", sortBy: "updatedAt" as const, sortDirection: "desc" as const, page: 2, pageSize: 20 };
+    const query = { documentType: "MAIN,SUB_PLAN", categoryId: "2", keyword: "应急", updatedFrom: "2026-08-04T01:00:00.000Z", updatedTo: "2026-08-04T10:00:00.000Z", sortBy: "updatedAt" as const, sortDirection: "desc" as const, page: 2, pageSize: 20 };
     await api.list(query, signal);
     expect(client.get).toHaveBeenCalledWith("documents", { query, signal });
   });
@@ -31,6 +31,25 @@ describe("documentApi", () => {
     });
   });
 
+  it("直属子方案透传状态、时间、排序和分页组合", async () => {
+    const client = { get: vi.fn().mockResolvedValue(result(page)) };
+    const query = {
+      keyword: "通信",
+      status: "PUBLISHED" as const,
+      updatedFrom: "2026-08-01T00:00:00.000Z",
+      updatedTo: "2026-08-04T00:00:00.000Z",
+      page: 2,
+      pageSize: 20,
+      sortBy: "updatedAt" as const,
+      sortDirection: "desc" as const,
+    };
+    await createDocumentApi(client).subPlans("1", query);
+    expect(client.get).toHaveBeenCalledWith("main-plans/1/sub-plans", {
+      query,
+      signal: undefined,
+    });
+  });
+
   it("拒绝 number ID 和非 UTC 时间", async () => {
     const client = { get: vi.fn().mockResolvedValue(result({ ...page, items: [{ ...summary, id: 1, updatedAt: "2026-07-23 10:00:00" }] })) };
     await expect(createDocumentApi(client).list({})).rejects.toMatchObject({

+ 44 - 0
frontend/src/features/documents/__tests__/documentSearch.test.ts

@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { toUpdatedTimeRange } from "../documentSearch";
+
+describe("Q2-F datetime-local到UTC查询边界", () => {
+  it("按浏览器本地时间解析并转换为UTC Z格式,而不是直接拼接Z", () => {
+    const localValue = "2026-08-04T09:30";
+    const result = toUpdatedTimeRange(localValue, "");
+    expect(result.error).toBeNull();
+    expect(result.query.updatedFrom).toBe(new Date(localValue).toISOString());
+    expect(result.query.updatedFrom).toMatch(/Z$/);
+    expect(result.query.updatedFrom).not.toBe(`${localValue}Z`);
+    expect(result.query).not.toHaveProperty("updatedTo");
+  });
+
+  it("支持仅结束时间和闭区间双边时间", () => {
+    const onlyTo = toUpdatedTimeRange("", "2026-08-04T18:00");
+    expect(onlyTo.query).toEqual({
+      updatedTo: new Date("2026-08-04T18:00").toISOString(),
+    });
+
+    const both = toUpdatedTimeRange("2026-08-04T09:00", "2026-08-04T09:00");
+    expect(both.error).toBeNull();
+    expect(both.query.updatedFrom).toBe(both.query.updatedTo);
+  });
+
+  it("空条件不产生空字符串参数", () => {
+    const result = toUpdatedTimeRange("", "");
+    expect(result).toEqual({
+      query: {},
+      error: null,
+    });
+    expect(Object.values(result.query)).not.toContain("");
+  });
+
+  it("反向区间和非法时间返回中文错误且不生成查询边界", () => {
+    expect(
+      toUpdatedTimeRange("2026-08-04T10:00", "2026-08-04T09:00"),
+    ).toEqual({ query: {}, error: "更新时间从不能晚于更新时间至" });
+    expect(toUpdatedTimeRange("not-a-date", "")).toEqual({
+      query: {},
+      error: "“更新时间从”不是有效时间",
+    });
+  });
+});

+ 50 - 0
frontend/src/features/documents/__tests__/q2fScope.test.ts

@@ -0,0 +1,50 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const app = readFileSync(new URL("../../../app/App.tsx", import.meta.url), "utf8");
+const documentState = readFileSync(new URL("../documentState.ts", import.meta.url), "utf8");
+const attachmentState = readFileSync(new URL("../../attachments/attachmentState.ts", import.meta.url), "utf8");
+const documentApi = readFileSync(new URL("../documentApi.ts", import.meta.url), "utf8");
+const attachmentApi = readFileSync(new URL("../../attachments/attachmentApi.ts", import.meta.url), "utf8");
+
+describe("Q2-F检索范围与并发保护", () => {
+  it("三个真实区域均使用服务端查询和统一时间工具栏", () => {
+    expect(app).toContain('ariaLabel="方案检索"');
+    expect(app).toContain('ariaLabel="直属子方案检索"');
+    expect(app).toContain('ariaLabel="已挂载附件检索"');
+    expect(app).toContain("toUpdatedTimeRange");
+    expect(app).toContain("...mainRange.query");
+    expect(app).toContain("...subRange.query");
+    expect(app).toContain("...mountedRange.query");
+  });
+
+  it("关键词约300ms防抖,旧请求会取消且旧响应不能覆盖新响应", () => {
+    for (const source of [documentState, attachmentState]) {
+      expect(source).toContain("debounceMs = 300");
+      expect(source).toContain("new AbortController()");
+      expect(source).toContain("controller.abort()");
+      expect(source).toMatch(/requestVersion|version\.current/);
+    }
+  });
+
+  it("非法区间禁用请求,筛选变化与主案切换重置页码和下方筛选", () => {
+    expect(app).toContain("!mainRange.error");
+    expect(app).toContain("!subRange.error");
+    expect(app).toContain("!mountedRange.error");
+    expect(app).toContain("setSubPage(1)");
+    expect(app).toContain("setMountedPage(1)");
+    expect(app).toContain("}, [selectedMainId]);");
+    expect(app).toContain("}, [selected?.id]);");
+  });
+
+  it("只调用正式路径,不增加search或废止接口", () => {
+    expect(documentApi).toContain('"documents"');
+    expect(documentApi).toContain("`main-plans/${mainPlanId}/sub-plans`");
+    expect(attachmentApi).toContain("`main-plans/${mainPlanId}/attachments`");
+    for (const source of [documentApi, attachmentApi]) {
+      expect(source).not.toContain("/search");
+      expect(source).not.toContain("/children");
+      expect(source).not.toContain("/materials");
+    }
+  });
+});

+ 38 - 0
frontend/src/features/documents/documentSearch.ts

@@ -0,0 +1,38 @@
+export interface UpdatedTimeRange {
+  updatedFrom?: string;
+  updatedTo?: string;
+}
+
+export interface UpdatedTimeRangeResult {
+  query: UpdatedTimeRange;
+  error: string | null;
+}
+
+function parseLocalDateTime(value: string): Date | null {
+  if (!value) return null;
+  const parsed = new Date(value);
+  return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+export function toUpdatedTimeRange(
+  localFrom: string,
+  localTo: string,
+): UpdatedTimeRangeResult {
+  const from = parseLocalDateTime(localFrom);
+  const to = parseLocalDateTime(localTo);
+
+  if (localFrom && !from) {
+    return { query: {}, error: "“更新时间从”不是有效时间" };
+  }
+  if (localTo && !to) {
+    return { query: {}, error: "“更新时间至”不是有效时间" };
+  }
+  if (from && to && from.getTime() > to.getTime()) {
+    return { query: {}, error: "更新时间从不能晚于更新时间至" };
+  }
+
+  const query: UpdatedTimeRange = {};
+  if (from) query.updatedFrom = from.toISOString();
+  if (to) query.updatedTo = to.toISOString();
+  return { query, error: null };
+}

+ 33 - 8
frontend/src/features/documents/documentState.ts

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { HttpError } from "../../shared/api/errors";
 import type { PageResult } from "../../shared/api/types";
 import { documentApi } from "./documentApi";
@@ -14,31 +14,44 @@ export interface RemotePageState<T> {
   data: PageResult<T> | null;
   loading: boolean;
   error: HttpError | null;
+  retry: () => void;
 }
 
 const initialPageState = <T,>(): RemotePageState<T> => ({
   data: null,
   loading: true,
   error: null,
+  retry: () => undefined,
 });
 
 function useDebouncedPage<T>(
   load: (signal: AbortSignal) => Promise<PageResult<T>>,
   dependencies: readonly unknown[],
-  delay = 300,
+  debounceKey: string | undefined,
+  enabled = true,
+  debounceMs = 300,
 ): RemotePageState<T> {
   const [state, setState] = useState<RemotePageState<T>>(initialPageState);
   const requestVersion = useRef(0);
+  const previousDebounceKey = useRef(debounceKey);
+  const [retryVersion, setRetryVersion] = useState(0);
+  const retry = useCallback(() => setRetryVersion((value) => value + 1), []);
 
   useEffect(() => {
+    if (!enabled) {
+      setState((current) => ({ ...current, loading: false, error: null, retry }));
+      return;
+    }
     const version = ++requestVersion.current;
     const controller = new AbortController();
+    const delay = previousDebounceKey.current === debounceKey ? 0 : debounceMs;
+    previousDebounceKey.current = debounceKey;
     const timer = window.setTimeout(() => {
-      setState((current) => ({ ...current, loading: true, error: null }));
+      setState((current) => ({ ...current, loading: true, error: null, retry }));
       void load(controller.signal)
         .then((data) => {
           if (version === requestVersion.current) {
-            setState({ data, loading: false, error: null });
+            setState({ data, loading: false, error: null, retry });
           }
         })
         .catch((error: unknown) => {
@@ -51,6 +64,7 @@ function useDebouncedPage<T>(
           setState({
             data: null,
             loading: false,
+            retry,
             error:
               error instanceof HttpError
                 ? error
@@ -70,14 +84,18 @@ function useDebouncedPage<T>(
       window.clearTimeout(timer);
       controller.abort();
     };
-    // The caller passes the complete primitive dependency set.
+    // The caller passes the complete primitive dependency set. Retry and
+    // enabled are appended here so errors can be retried without mutating filters.
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, dependencies);
+  }, [...dependencies, enabled, retryVersion]);
 
-  return state;
+  return { ...state, retry };
 }
 
-export function useDocumentList(query: DocumentListQuery): RemotePageState<DocumentSummary> {
+export function useDocumentList(
+  query: DocumentListQuery,
+  enabled = true,
+): RemotePageState<DocumentSummary> {
   const revision = useContentRevision("documents");
   return useDebouncedPage(
     (signal) => documentApi.list(query, signal),
@@ -96,12 +114,15 @@ export function useDocumentList(query: DocumentListQuery): RemotePageState<Docum
       query.pageSize,
       revision,
     ],
+    query.keyword,
+    enabled,
   );
 }
 
 export function useSubPlanList(
   mainPlanId: string | null,
   query: SubPlanListQuery,
+  enabled = true,
 ): RemotePageState<DocumentSummary> {
   const revision = useContentRevision("documents");
   return useDebouncedPage(
@@ -119,12 +140,16 @@ export function useSubPlanList(
       mainPlanId,
       query.keyword,
       query.status,
+      query.updatedFrom,
+      query.updatedTo,
       query.sortBy,
       query.sortDirection,
       query.page,
       query.pageSize,
       revision,
     ],
+    query.keyword,
+    enabled,
   );
 }
 

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

@@ -85,6 +85,8 @@ export interface DocumentListQuery {
 export interface SubPlanListQuery {
   keyword?: string;
   status?: DocumentStatusCode;
+  updatedFrom?: string;
+  updatedTo?: string;
   sortBy?: "documentName" | "createdAt" | "updatedAt" | "viewCount" | "downloadCount";
   sortDirection?: "asc" | "desc";
   page?: number;

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

@@ -11,10 +11,10 @@ import {
 } from "./fileState";
 
 function errorText(error: HttpError): string {
-  if (error.code === "PREVIEW_UNAVAILABLE") return "当前文件类型暂不支持在线预览";
-  if (error.code === "FILE_NOT_FOUND") return "服务端未找到该文件";
+  if (error.code === "PREVIEW_UNAVAILABLE") return "当前文件类型暂不支持在线查看";
+  if (error.code === "FILE_NOT_FOUND") return "文件不存在";
   if (error.code === "DOCUMENT_VIEW_FORBIDDEN" || error.httpStatus === 403) {
-    return "当前账号无权预览该文件";
+    return "当前账号无权查看该文件";
   }
   return error.message;
 }
@@ -43,7 +43,7 @@ export function FilePreviewModal({ item, onClose }: {
           throw new HttpError({
             httpStatus: result.httpStatus,
             code: "INTERNAL_ERROR",
-            message: "预览接口返回的内容不是PDF",
+            message: "文件内容无法查看",
             details: null,
             requestId: result.requestId,
           });
@@ -59,7 +59,7 @@ export function FilePreviewModal({ item, onClose }: {
             : new HttpError({
                 httpStatus: 0,
                 code: "INTERNAL_ERROR",
-                message: "文件预览失败",
+                message: "文件查看失败",
                 details: null,
                 requestId: "unknown",
                 cause: value,
@@ -92,12 +92,12 @@ export function FilePreviewModal({ item, onClose }: {
     <div className="fixed inset-0 z-[60] flex items-center justify-center" style={{ background: "rgba(26,82,153,.55)" }} onClick={onClose}>
       <div className="w-[900px] h-[86vh] flex flex-col bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
         <div className="h-11 px-5 flex items-center justify-between text-white" style={{ background: "#1A5299" }}>
-          <span className="font-black">文件预览 · {item.documentName}</span>
+          <span className="font-black">文件查看 · {item.documentName}</span>
           <button onClick={onClose}><X size={17} /></button>
         </div>
         <div className="flex-1 min-h-0 flex items-center justify-center" style={{ background: "#EBF4FD" }}>
-          {loading && <div className="font-bold" style={{ color: "#1A5299" }}><RefreshCw className="inline animate-spin mr-2" size={16} />正在加载预览…</div>}
-          {url && <iframe title={`${item.documentName} PDF预览`} src={url} className="w-full h-full border-0" />}
+          {loading && <div className="font-bold" style={{ color: "#1A5299" }}><RefreshCw className="inline animate-spin mr-2" size={16} />正在加载文件…</div>}
+          {url && <iframe title={`${item.documentName} PDF查看`} src={url} className="w-full h-full border-0" />}
           {error && (
             <div className="text-center p-8" style={{ color: "#1A5299" }}>
               <FileWarning size={40} className="mx-auto mb-3 opacity-60" />

+ 1 - 1
frontend/src/features/files/filename.ts

@@ -47,7 +47,7 @@ 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、PDF、XLS 或 XLSX 文件;最终安全校验由服务端完成";
+    return "请选择 DOC、DOCX、PDF、XLS 或 XLSX 文件";
   }
   return null;
 }

+ 6 - 9
frontend/src/features/mutations/MutationModals.tsx

@@ -233,7 +233,6 @@ export function SingleUploadModal({ kind, initialParentId, onClose }: {
   return (
     <Modal title={kind === "attachment" ? "上传共享附件" : "上传方案文档"} onClose={onClose}>
       <div className="p-6 space-y-4">
-        {kind === "attachment" && <div className="p-3 text-xs font-bold" style={{ color: NAVY, background: "#EBF4FD" }}>共享附件面向所有有效登录用户共享,不配置组织或人员权限;上传后可在后续阶段挂载到多个主案。</div>}
         <Field label="文件 *"><input type="file" accept={ACCEPT} disabled={submitting} onChange={(event) => {
           const selected = event.target.files?.[0] ?? null;
           setFile(selected);
@@ -243,7 +242,7 @@ export function SingleUploadModal({ kind, initialParentId, onClose }: {
         {kind === "document" && (
           <div className="grid grid-cols-2 gap-3">
             <Field label="方案类型 *"><select value={documentType} disabled={submitting} onChange={(event) => setDocumentType(event.target.value as "MAIN" | "SUB_PLAN")} className={inputClass} style={selectStyle}>{dictionaries.options("documentTypes").filter((option) => option.code !== "ATTACHMENT").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
-            {documentType === "SUB_PLAN" && <Field label="所属主案 *"><select value={parentDocumentId} disabled={submitting || references.mainPlanLoading} onChange={(event) => setParentDocumentId(event.target.value)} className={inputClass} style={selectStyle}><option value="">请选择真实主案</option>{references.mainPlans.map((plan) => <option key={plan.id} value={plan.id}>{plan.documentName}</option>)}</select></Field>}
+            {documentType === "SUB_PLAN" && <Field label="所属主案 *"><select value={parentDocumentId} disabled={submitting || references.mainPlanLoading} onChange={(event) => setParentDocumentId(event.target.value)} className={inputClass} style={selectStyle}><option value="">请选择主案</option>{references.mainPlans.map((plan) => <option key={plan.id} value={plan.id}>{plan.documentName}</option>)}</select></Field>}
           </div>
         )}
         <Field label={`${kind === "attachment" ? "附件" : "文档"}名称 *`}><input value={documentName} disabled={submitting} onChange={(event) => setDocumentName(event.target.value)} className={inputClass} style={selectStyle} /></Field>
@@ -251,7 +250,7 @@ export function SingleUploadModal({ kind, initialParentId, onClose }: {
           <Field label="附件类型 *"><select value={attachmentType} disabled={submitting} onChange={(event) => setAttachmentType(event.target.value as typeof attachmentType)} className={inputClass} style={selectStyle}>{dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
         ) : (
           <>
-            <Field label="方案分类 *"><select value={categoryId} disabled={submitting || references.categoryLoading} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}><option value="">请选择真实分类</option>{references.categories.map((category) => <option key={category.id} value={category.id} title={category.categoryPath}>{category.displayLabel}</option>)}</select></Field>
+            <Field label="方案分类 *"><select value={categoryId} disabled={submitting || references.categoryLoading} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}><option value="">请选择分类</option>{references.categories.map((category) => <option key={category.id} value={category.id} title={category.categoryPath}>{category.displayLabel}</option>)}</select></Field>
             <div className="grid grid-cols-3 gap-3">
               <Field label="密级 *"><select value={securityLevel} onChange={(event) => setSecurityLevel(event.target.value as typeof securityLevel)} className={inputClass} style={selectStyle}>{dictionaries.options("securityLevels").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
               <Field label="可见范围 *"><select value={visibilityType} onChange={(event) => setVisibilityType(event.target.value as typeof visibilityType)} className={inputClass} style={selectStyle}>{dictionaries.options("visibilityTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
@@ -500,11 +499,10 @@ export function EditMetadataModal({ item, onClose }: {
   };
 
   return (
-    <Modal title={`编辑元数据 · ${item.documentName}`} onClose={onClose}>
+    <Modal title={`编辑 · ${item.documentName}`} onClose={onClose}>
       <div className="p-6 space-y-3">
         {loading && <div className="text-sm font-bold" style={{ color: NAVY }}><RefreshCw size={14} className="inline animate-spin mr-2" />正在加载最新详情…</div>}
         {detail && <>
-          <div className="p-3 text-xs font-bold" style={{ color: NAVY, background: "#EBF4FD" }}>原文件:{detail.originalFileName} · {detail.fileExtension} · SHA-256 {detail.fileHash.slice(0, 12)}…;文件身份只读,本阶段不提供替换文件。</div>
           <Field label="名称 *"><input value={documentName} onChange={(event) => setDocumentName(event.target.value)} className={inputClass} style={selectStyle} /></Field>
           {attachment ? <Field label="附件类型"><select value={attachmentType} onChange={(event) => setAttachmentType(event.target.value as typeof attachmentType)} className={inputClass} style={selectStyle}>{dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field> : <>
             <Field label="分类"><select value={categoryId} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} title={category.categoryPath}>{category.displayLabel}</option>)}</select></Field>
@@ -571,12 +569,11 @@ export function DeleteDocumentModal({ item, onClose, onDeleted }: {
   };
 
   return (
-    <Modal title={`逻辑删除 · ${item.documentName}`} onClose={onClose}>
+    <Modal title={`删除 · ${item.documentName}`} onClose={onClose}>
       <div className="p-6 space-y-4" style={{ color: NAVY }}>
-        {loading ? <div className="text-sm font-bold"><RefreshCw size={14} className="inline animate-spin mr-2" />正在加载最新版本…</div> : <div className="text-sm font-bold leading-7">确认逻辑删除该{attachment ? "共享附件" : item.documentType === "MAIN" ? "主案" : "子方案"}?原始文件不会立即物理删除,本期不提供恢复功能。</div>}
-        {attachment && <div className="text-xs font-bold">已挂载附件将由服务端返回 ATTACHMENT_IN_USE;前端不会强制删除或自动解除挂载。</div>}
+        {loading ? <div className="text-sm font-bold"><RefreshCw size={14} className="inline animate-spin mr-2" />正在加载…</div> : <div className="text-sm font-bold leading-7">确认删除该{attachment ? "共享附件" : item.documentType === "MAIN" ? "主案" : "子方案"}?</div>}
         <ErrorBlock error={error} />
-        <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={selectStyle}>取消</button><button onClick={() => void remove()} disabled={!detail || submitting} className="px-4 py-2 text-sm font-black text-white disabled:opacity-50" style={{ background: NAVY }}><Trash2 size={13} className="inline mr-1" />{submitting ? "删除中" : "确认逻辑删除"}</button></div>
+        <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={selectStyle}>取消</button><button onClick={() => void remove()} disabled={!detail || submitting} className="px-4 py-2 text-sm font-black text-white disabled:opacity-50" style={{ background: NAVY }}><Trash2 size={13} className="inline mr-1" />{submitting ? "删除中" : "确认删除"}</button></div>
       </div>
     </Modal>
   );

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

@@ -49,10 +49,10 @@ describe("F5 scope and UI capabilities", () => {
   });
 
   it("不提供恢复、强制删除或Office伪预览入口", () => {
-    expect(modal).toContain("本期不提供恢复功能");
-    expect(modal).toContain("不会强制删除或自动解除挂载");
     expect(modal.match(/await attachmentMutationApi\.remove/g)).toHaveLength(1);
     expect(modal).toContain("await loadLatest(true)");
     expect(app + modal).not.toContain("强制删除</button>");
+    expect(modal).not.toContain("本期不提供恢复功能");
+    expect(modal).not.toContain("ATTACHMENT_IN_USE;前端不会");
   });
 });

+ 11 - 14
frontend/src/features/permissions/PermissionModal.tsx

@@ -79,8 +79,8 @@ function errorDescription(error: HttpError): string {
   if (error.httpStatus === 403 || error.code === "FORBIDDEN") return "当前账号无权限配置能力";
   if (error.code === "DUPLICATE_PERMISSION_SUBJECT") return "同一组织或人员不能重复配置";
   if (error.code === "SUB_PLAN_PERMISSION_INHERITED") return "子方案权限继承主案,不能独立保存";
-  if (error.code === "ATTACHMENT_HAS_NO_ACL") return "共享附件面向全部有效登录用户,不配置独立ACL";
-  if (error.code === "DATA_VERSION_CONFLICT") return "权限已被其他用户修改,请核对最新服务端状态后再次保存";
+  if (error.code === "ATTACHMENT_HAS_NO_ACL") return "共享附件无需单独配置权限";
+  if (error.code === "DATA_VERSION_CONFLICT") return "权限已被其他用户修改,请核对后再次保存";
   if (error.code === "RESOURCE_NOT_FOUND" || error.httpStatus === 404) return "文档不存在或已被删除";
   if (error.httpStatus === 0) return "网络请求失败;现有登录状态不会被清除";
   return error.message;
@@ -318,7 +318,7 @@ export function PermissionModal({
         : toHttpError(saveError);
       setError(httpError);
       if (httpError.code === "DATA_VERSION_CONFLICT") {
-        setNotice("已保留本地草稿,并加载最新服务端权限版本;请比较后重新确认保存。");
+        setNotice("权限已发生变化,请核对后重新保存。");
         await loadPermissions(true);
         setError(httpError);
         invalidateDetail("document", document.id);
@@ -338,8 +338,8 @@ export function PermissionModal({
       <div className="w-[1080px] max-w-full h-[86vh] flex flex-col shadow-2xl" style={{ background: WHITE, color: NAVY }} onClick={(event) => event.stopPropagation()}>
         <div className="h-12 px-5 flex items-center justify-between text-white" style={{ background: NAVY }}>
           <div>
-            <span className="font-black">{isSubPlan ? "继承权限" : isAttachment ? "共享附件访问说明" : "主案权限配置"}</span>
-            <span className="ml-3 text-xs opacity-75">{document.documentName} · ID {document.id}</span>
+            <span className="font-black">{isSubPlan ? "继承权限" : isAttachment ? "共享附件权限" : "主案权限配置"}</span>
+            <span className="ml-3 text-xs opacity-75">{document.documentName}</span>
           </div>
           <button onClick={onClose} disabled={saving}><X size={18} /></button>
         </div>
@@ -347,11 +347,11 @@ export function PermissionModal({
         {isAttachment ? (
           <div className="flex-1 flex items-center justify-center p-10">
             <div className="max-w-xl p-7 text-center font-bold leading-8" style={{ background: PALE }}>
-              共享附件不配置组织或人员 ACL,固定面向全部状态正常的已登录用户,并保持公开密级
+              共享附件对所有已登录用户可见,无需单独配置权限
             </div>
           </div>
         ) : loading && !draft ? (
-          <div className="flex-1 flex items-center justify-center font-bold"><RefreshCw size={17} className="animate-spin mr-2" />正在加载服务端权限全集…</div>
+          <div className="flex-1 flex items-center justify-center font-bold"><RefreshCw size={17} className="animate-spin mr-2" />正在加载权限…</div>
         ) : error && !draft ? (
           <div className="flex-1 flex flex-col items-center justify-center font-bold">
             <AlertCircle size={22} className="mb-3" />
@@ -362,16 +362,13 @@ export function PermissionModal({
         ) : draft && detail ? (
           <>
             <div className="px-5 py-3 border-b flex items-center gap-5 text-sm font-bold" style={{ borderColor: "rgba(49,145,235,.2)", background: PALE }}>
-              <span>文档:{detail.documentId}</span>
-              <span>权限来源:{detail.sourceDocumentId}</span>
-              <span>版本:{draft.documentRowVersion}</span>
               <span>组织 {orgCount} 项</span>
               <span>人员 {userCount} 项</span>
               <span className="ml-auto">{detail.inherited ? "动态继承" : "主案自有权限"}</span>
             </div>
             {isSubPlan && (
               <div className="mx-5 mt-4 p-3 text-sm font-black" style={{ background: PALE }}>
-                子方案权限动态继承所属主案,不能独配置。以下字段均为只读,来源主案 ID:{detail.sourceDocumentId}。
+                子方案权限继承所属主案,不能独配置。
               </div>
             )}
 
@@ -467,7 +464,7 @@ export function PermissionModal({
               {draft.entries.length === 0 ? (
                 <div className="p-10 text-center text-sm font-bold opacity-55">
                   {draft.visibilityType === VisibilityType.ALL_AUTHENTICATED
-                    ? "全部已登录用户范围不提交 ACL 权限项"
+                    ? "当前无需添加权限对象"
                     : "当前权限集合为空"}
                 </div>
               ) : (
@@ -482,7 +479,7 @@ export function PermissionModal({
                   <tbody>
                     {draft.entries.map((entry, index) => (
                       <tr key={`${entry.subjectType}:${entry.subjectId}`} className="border-b" style={{ borderColor: "rgba(49,145,235,.12)" }}>
-                        <td className="px-3 py-2"><div className="text-sm font-black">{entry.subjectName}</div><div className="text-[11px] opacity-60">{dictionaries.label("subjectTypes", entry.subjectType)} · {entry.subjectId}</div></td>
+                        <td className="px-3 py-2"><div className="text-sm font-black">{entry.subjectName}</div><div className="text-[11px] opacity-60">{dictionaries.label("subjectTypes", entry.subjectType)}</div></td>
                         {ACTIONS.map((action) => (
                           <td key={action.key} className="px-3 py-2 text-center">
                             <input
@@ -512,7 +509,7 @@ export function PermissionModal({
                 {editable && !detail.inherited && (
                   <>
                     <button
-                      onClick={() => { if (baseline) setDraft({ ...baseline, entries: baseline.entries.map((entry) => ({ ...entry })) }); setError(null); setNotice("已恢复为最近一次成功加载的服务端状态,尚未保存。"); }}
+                      onClick={() => { if (baseline) setDraft({ ...baseline, entries: baseline.entries.map((entry) => ({ ...entry })) }); setError(null); setNotice("已重置尚未保存的修改。"); }}
                       disabled={saving}
                       className="px-4 py-2 text-sm font-black border flex items-center gap-1"
                       style={{ borderColor: BLUE }}

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

@@ -38,28 +38,28 @@ describe("F6 UI scope and safety", () => {
   });
 
   it("clearly states unbinding never deletes attachment data", () => {
-    expect(unbindModal).toContain("不会删除共享附件");
-    expect(unbindModal).toContain("不会删除附件文件");
+    expect(unbindModal).toContain("附件及其文件不会被删除");
     expect(unbindModal).not.toContain(">删除附件<");
   });
 
   it("keeps conflict drafts and does not auto-retry writes", () => {
     expect(bindingModal).toContain("请核对保留的选择后再次提交");
-    expect(permissionModal).toContain("保留本地草稿");
+    expect(permissionModal).toContain("setDraft((current)");
     expect(permissionModal).toContain("loadPermissions(true)");
     expect(permissionModal.match(/permissionApi\.save/g)).toHaveLength(1);
     expect(unbindModal.match(/bindingApi\.unbind/g)).toHaveLength(1);
   });
 
   it("shows inherited sub-plan permissions without a save path", () => {
-    expect(permissionModal).toContain("子方案权限动态继承所属主案,不能独配置");
+    expect(permissionModal).toContain("子方案权限继承所属主案,不能独配置");
     expect(permissionModal).toContain("!editable || detail.inherited");
     expect(permissionModal).toContain("if (!draft || saving || !editable || isSubPlan || isAttachment) return");
   });
 
-  it("explains attachments have no ACL", () => {
-    expect(permissionModal).toContain("共享附件不配置组织或人员 ACL");
-    expect(app).toContain("共享附件面向全部有效登录用户,不配置组织或人员 ACL");
+  it("使用面向用户的共享附件权限说明", () => {
+    expect(permissionModal).toContain("共享附件对所有已登录用户可见");
+    expect(permissionModal).not.toContain("不配置组织或人员 ACL");
+    expect(app).not.toContain("F6 挂载关系与主案权限已接入");
   });
 
   it("uses real organization and user directory hooks", () => {

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

@@ -1,4 +1,5 @@
 import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
 import {
   Filter,
   RefreshCw,
@@ -82,14 +83,14 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
     setPage(1);
   };
 
-  return (
+  return createPortal(
     <div
-      className="fixed inset-0 z-[70] flex items-center justify-center"
+      className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
       style={{ background: "rgba(26,82,153,.55)" }}
       onClick={onClose}
     >
       <div
-        className="w-[1500px] max-w-[96vw] h-[88vh] flex flex-col bg-white shadow-2xl"
+        className="w-[1500px] max-w-full h-[88vh] max-h-full flex flex-col bg-white shadow-2xl"
         onClick={(event) => event.stopPropagation()}
       >
         <div
@@ -98,9 +99,6 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
         >
           <Trash2 size={17} className="mr-2" />
           <span className="font-black">文档回收站</span>
-          <span className="ml-3 text-xs opacity-70">
-            仅支持单条恢复,不提供物理删除或批量恢复
-          </span>
           <button
             onClick={onClose}
             className="ml-auto flex items-center gap-1 text-sm font-black"
@@ -393,6 +391,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
           }}
         />
       )}
-    </div>
+    </div>,
+    document.body,
   );
 }

+ 0 - 7
frontend/src/features/recycle-bin/RestoreDocumentModal.tsx

@@ -138,13 +138,6 @@ export function RestoreDocumentModal({
           >
             {RESTORE_DESCRIPTIONS[item.documentType]}
           </div>
-          <ul className="text-xs font-bold leading-6 list-disc pl-5">
-            <li>恢复不会覆盖冲突数据。</li>
-            <li>恢复不会移动或重新上传文件。</li>
-            <li>恢复失败时记录仍保留在回收站。</li>
-            <li>本功能不是物理文件恢复。</li>
-          </ul>
-
           {error && (
             <div
               className="p-3 text-sm font-bold leading-6"

+ 7 - 0
frontend/src/features/recycle-bin/__tests__/f8Scope.test.ts

@@ -85,6 +85,13 @@ describe("F8 scope and UI boundaries", () => {
     expect(listModal).toContain("恢复");
   });
 
+  it("通过顶层Portal和安全边距完整显示回收站弹窗", () => {
+    expect(listModal).toContain('createPortal(');
+    expect(listModal).toContain('document.body');
+    expect(listModal).toContain('z-[100]');
+    expect(listModal).toContain('p-4 sm:p-6');
+  });
+
   it("成功数量和跳过警告均可见", () => {
     expect(restoreModal).toContain("restoredPermissionCount");
     expect(restoreModal).toContain("skippedPermissionCount");

+ 2 - 3
frontend/src/features/statistics/DocumentStatisticsCards.tsx

@@ -14,7 +14,7 @@ export function DocumentStatisticsCards() {
         style={{ background: "#fff", color: NAVY }}
       >
         <RefreshCw size={14} className="inline mr-2 animate-spin" />
-        正在读取真实文档统计…
+        正在加载文档统计…
       </div>
     );
   }
@@ -58,7 +58,7 @@ export function DocumentStatisticsCards() {
     <div
       className="mb-2 grid grid-cols-5 gap-px"
       style={{ background: "rgba(40,118,193,.18)" }}
-      aria-label="真实文档统计"
+      aria-label="文档统计"
     >
       {cards.map(([label, value]) => (
         <div key={String(label)} className="bg-white px-4 py-2">
@@ -73,4 +73,3 @@ export function DocumentStatisticsCards() {
     </div>
   );
 }
-

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

@@ -105,6 +105,7 @@ export const AuditActionType = {
   CHANGE_PERMISSION: "CHANGE_PERMISSION",
   BIND_ATTACHMENT: "BIND_ATTACHMENT",
   UNBIND_ATTACHMENT: "UNBIND_ATTACHMENT",
+  RESTORE_DOCUMENT: "RESTORE_DOCUMENT",
 } as const;
 export type AuditActionType =
   (typeof AuditActionType)[keyof typeof AuditActionType];

+ 155 - 0
tools/export_full_demo_data.py

@@ -0,0 +1,155 @@
+"""Export the local DMS demo database rows and referenced storage files.
+
+The connection URL is read from an existing dotenv file and is never written
+to the export. The resulting SQL is data-only and targets an already migrated
+empty DMS database.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import shutil
+from datetime import date, datetime, time
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+from dotenv import dotenv_values
+from sqlalchemy import MetaData, Table, create_engine, select
+
+
+TABLES = (
+    "sys_organization",
+    "sys_user",
+    "doc_category",
+    "doc_document",
+    "doc_attachment_binding",
+    "doc_permission",
+    "sys_audit_log",
+)
+
+
+def sql_literal(value: Any) -> str:
+    if value is None:
+        return "NULL"
+    if isinstance(value, bool):
+        return "1" if value else "0"
+    if isinstance(value, (int, Decimal)):
+        return str(value)
+    if isinstance(value, float):
+        return repr(value)
+    if isinstance(value, (datetime, date, time)):
+        value = value.isoformat(sep=" ") if isinstance(value, datetime) else value.isoformat()
+    if isinstance(value, (dict, list)):
+        value = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+    if isinstance(value, bytes):
+        return "X'" + value.hex() + "'"
+    escaped = str(value).replace("\\", "\\\\").replace("'", "''")
+    return "'" + escaped + "'"
+
+
+def sha256(path: Path) -> str:
+    digest = hashlib.sha256()
+    with path.open("rb") as stream:
+        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
+def export(env_file: Path, source_storage: Path, output: Path) -> None:
+    config = dotenv_values(env_file)
+    database_url = (config.get("DMS_DATABASE_URL") or "").strip()
+    if not database_url:
+        raise RuntimeError("DMS_DATABASE_URL is missing from the source dotenv file")
+
+    output.mkdir(parents=True, exist_ok=True)
+    storage_output = output / "storage"
+    storage_output.mkdir(exist_ok=True)
+
+    engine = create_engine(database_url, pool_pre_ping=True)
+    metadata = MetaData()
+    reflected = {
+        name: Table(name, metadata, autoload_with=engine)
+        for name in TABLES
+    }
+
+    rows_by_table: dict[str, list[dict[str, Any]]] = {}
+    with engine.connect() as connection:
+        for name in TABLES:
+            rows_by_table[name] = [dict(row) for row in connection.execute(select(reflected[name])).mappings()]
+    engine.dispose()
+
+    document_paths: dict[str, dict[str, Any]] = {}
+    for row in rows_by_table["doc_document"]:
+        relative = str(row.get("file_relative_path") or "").strip().replace("\\", "/")
+        if not relative:
+            continue
+        if relative.startswith("/") or ".." in Path(relative).parts:
+            raise RuntimeError(f"Unsafe storage path in database: {relative}")
+        source = source_storage / Path(relative)
+        if not source.is_file():
+            raise RuntimeError(f"Referenced storage file is missing: {relative}")
+        expected_size = row.get("file_size")
+        if expected_size is not None and source.stat().st_size != int(expected_size):
+            raise RuntimeError(f"Referenced storage file has unexpected size: {relative}")
+        actual_hash = sha256(source)
+        expected_hash = str(row.get("file_hash") or "").lower()
+        if expected_hash and actual_hash != expected_hash:
+            raise RuntimeError(f"Referenced storage file has unexpected SHA-256: {relative}")
+        destination = storage_output / Path(relative)
+        destination.parent.mkdir(parents=True, exist_ok=True)
+        shutil.copy2(source, destination)
+        document_paths[relative] = {
+            "size": source.stat().st_size,
+            "sha256": actual_hash,
+        }
+
+    sql_path = output / "full_demo_data.sql"
+    with sql_path.open("w", encoding="utf-8", newline="\n") as sql:
+        sql.write("-- DMS full demo data export (data only; no schema or secrets)\n")
+        sql.write("SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\nSTART TRANSACTION;\n")
+        for name in reversed(TABLES):
+            sql.write(f"DELETE FROM `{name}`;\n")
+        for name in TABLES:
+            table = reflected[name]
+            # MySQL generated columns (for example active_marker) must not be
+            # supplied explicitly in INSERT statements.
+            columns = [
+                column.name
+                for column in table.columns
+                if column.computed is None
+            ]
+            quoted_columns = ", ".join(f"`{column}`" for column in columns)
+            for row in rows_by_table[name]:
+                values = ", ".join(sql_literal(row.get(column)) for column in columns)
+                sql.write(f"INSERT INTO `{name}` ({quoted_columns}) VALUES ({values});\n")
+        sql.write("COMMIT;\nSET FOREIGN_KEY_CHECKS=1;\n")
+
+    manifest = {
+        "format": "DMS full demo data v1",
+        "database_tables": {name: len(rows_by_table[name]) for name in TABLES},
+        "storage_file_count": len(document_paths),
+        "storage_total_bytes": sum(item["size"] for item in document_paths.values()),
+        "storage_files": document_paths,
+        "sql_sha256": sha256(sql_path),
+    }
+    (output / "manifest.json").write_text(
+        json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+        encoding="utf-8",
+    )
+    print(json.dumps(manifest, ensure_ascii=False, indent=2))
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--env-file", type=Path, required=True)
+    parser.add_argument("--storage", type=Path, required=True)
+    parser.add_argument("--output", type=Path, required=True)
+    arguments = parser.parse_args()
+    export(arguments.env_file.resolve(), arguments.storage.resolve(), arguments.output.resolve())
+
+
+if __name__ == "__main__":
+    main()

+ 106 - 0
tools/verify_full_demo_import.py

@@ -0,0 +1,106 @@
+"""Verify a DMS data export by importing it into an isolated scratch database."""
+
+from __future__ import annotations
+
+import argparse
+import os
+from pathlib import Path
+
+from alembic import command
+from alembic.config import Config
+from dotenv import dotenv_values
+from pymysql.constants import CLIENT
+from sqlalchemy import create_engine, text
+from sqlalchemy.engine import make_url
+
+
+TABLES = {
+    "sys_organization": 3,
+    "sys_user": 3,
+    "doc_category": 21,
+    "doc_document": 31,
+    "doc_attachment_binding": 19,
+    "doc_permission": 7,
+    "sys_audit_log": 291,
+}
+
+
+def execute_script(database_url: str, path: Path) -> None:
+    engine = create_engine(
+        database_url,
+        connect_args={"client_flag": CLIENT.MULTI_STATEMENTS},
+    )
+    connection = engine.raw_connection()
+    try:
+        driver_connection = connection.driver_connection
+        driver_connection.autocommit(True)
+        with driver_connection.cursor() as cursor:
+            cursor.execute(path.read_text(encoding="utf-8"))
+            while cursor.nextset():
+                pass
+    finally:
+        connection.close()
+        engine.dispose()
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--env-file", type=Path, required=True)
+    parser.add_argument("--schema", type=Path, required=True)
+    parser.add_argument("--data", type=Path, required=True)
+    parser.add_argument("--backend", type=Path, required=True)
+    parser.add_argument("--scratch-database", required=True)
+    args = parser.parse_args()
+
+    if not args.scratch_database.startswith("dms_full_demo_verify_"):
+        raise RuntimeError("Scratch database name must start with dms_full_demo_verify_")
+
+    source_url = make_url(dotenv_values(args.env_file).get("DMS_DATABASE_URL") or "")
+    server_url = source_url.set(database=None)
+    scratch_url = source_url.set(database=args.scratch_database)
+    server_engine = create_engine(server_url)
+    scratch_created = False
+    try:
+        with server_engine.connect().execution_options(isolation_level="AUTOCOMMIT") as connection:
+            exists = connection.execute(
+                text("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME=:name"),
+                {"name": args.scratch_database},
+            ).first()
+            if exists:
+                raise RuntimeError("Scratch database already exists; refusing to overwrite it")
+            connection.execute(text(f"CREATE DATABASE `{args.scratch_database}` CHARACTER SET utf8mb4"))
+            scratch_created = True
+
+        scratch_url_text = scratch_url.render_as_string(hide_password=False)
+        execute_script(scratch_url_text, args.schema.resolve())
+        previous_directory = Path.cwd()
+        try:
+            os.chdir(args.backend.resolve())
+            os.environ["DMS_DATABASE_URL"] = scratch_url_text
+            os.environ["DMS_JWT_SECRET"] = "verification-only-secret-not-for-deployment"
+            command.upgrade(Config("migrations/alembic.ini"), "head")
+        finally:
+            os.chdir(previous_directory)
+        execute_script(scratch_url_text, args.data.resolve())
+
+        scratch_engine = create_engine(scratch_url)
+        try:
+            with scratch_engine.connect() as connection:
+                for table_name, expected in TABLES.items():
+                    actual = connection.execute(text(f"SELECT COUNT(*) FROM `{table_name}`")).scalar_one()
+                    if actual != expected:
+                        raise RuntimeError(f"{table_name}: expected {expected}, got {actual}")
+                    print(f"{table_name}={actual}")
+        finally:
+            scratch_engine.dispose()
+        print("scratch import verification passed")
+    finally:
+        if scratch_created:
+            with server_engine.connect().execution_options(isolation_level="AUTOCOMMIT") as connection:
+                connection.execute(text(f"DROP DATABASE `{args.scratch_database}`"))
+            print("scratch database removed")
+        server_engine.dispose()
+
+
+if __name__ == "__main__":
+    main()

+ 0 - 0
verified.md