Przeglądaj źródła

1. 实现了 PDF 预览引擎可配置切换,前端通过 .env 选择 pdfjs 或 iframe 两种方案;
2. 实现了左侧标签筛选面板,支持多选 OR 匹配并聚合当前用户可见主案标签;
3. 实现了存量文档正文批量回填,对 PDF/DOCX 自动提取内容并写入 search_text 支持全文检索;
4. 实现了搜索结果片段高亮,正文命中关键词时表格行向下延展展示正文片段;
5. 实现了文档查看对话框左右分栏,右侧按空行分段展示提取文本并显示段号与字数;
6. 实现了 Windows 平台下 PyInstaller 一体化打包方案,前端 dist 与后端依赖合入单 exe 并提供 build.ps1 一键脚本;
7. 补充了 .gitignore 规则。

weisijie 1 tydzień temu
rodzic
commit
587f717f2b

+ 9 - 0
.claude/settings.local.json

@@ -0,0 +1,9 @@
+{
+  "permissions": {
+    "allow": [
+      "Skill(git-commit-log-gen)"
+    ],
+    "deny": [],
+    "ask": []
+  }
+}

+ 0 - 37
.gitattributes

@@ -1,37 +0,0 @@
-# 统一换行符:文本文件使用 LF,避免 Windows 下 CRLF 导致 diff 噪声
-* text=auto eol=lf
-
-# 显式声明常见源码文件的换行
-*.java        text eol=lf
-*.vue         text eol=lf
-*.js          text eol=lf
-*.ts          text eol=lf
-*.json        text eol=lf
-*.yml         text eol=lf
-*.yaml        text eol=lf
-*.xml         text eol=lf
-*.md          text eol=lf
-*.css         text eol=lf
-*.html        text eol=lf
-*.sh          text eol=lf
-*.sh.example  text eol=lf
-*.bat         text eol=crlf
-*.cmd         text eol=crlf
-*.bat.example text eol=crlf
-*.cmd.example text eol=crlf
-*.py          text eol=lf
-
-# Windows 专用脚本保持 CRLF
-*.ps1         text eol=crlf
-*.ps1.example text eol=crlf
-
-# 二进制文件,不做换行转换
-*.png         binary
-*.jpg         binary
-*.jpeg        binary
-*.gif         binary
-*.ico         binary
-*.pdf         binary
-*.zip         binary
-*.jar         binary
-*.class       binary

+ 13 - 0
.gitignore

@@ -138,3 +138,16 @@ deliverables/
 
 # Codex 本地会话控制目录
 .agents/
+
+# ======================
+# DMS 本地构建与发布产物(补充)
+# ======================
+# PyInstaller 中间字节码缓存;保留 build/ 下的 build.ps1 / *.spec / README.txt 作为工程资产
+build/__pycache_build/
+
+# PyInstaller --onedir 输出与历史备份
+dist-package/
+dist-package-bak/
+
+# 本地数据库导出快照(schema/数据/manifest/本地存储镜像)
+database/

+ 9 - 4
backend/.env.example

@@ -1,8 +1,11 @@
 # DMS MySQL 8连接(示例值,不包含真实凭据)
 DMS_DATABASE_URL=mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4
 
-# 留空时默认使用 backend/dms-storage/
-DMS_STORAGE_ROOT=
+# 存储根目录:留空(推荐)由启动器自动选择默认位置。
+#   - 开发模式:backend/dms-storage/
+#   - 打包模式:dms-server.exe 同级 dms-storage/
+# 如需自定义,请填写绝对路径,并取消下面一行注释。
+# DMS_STORAGE_ROOT=
 
 # 留空时使用内置中文UI字典;部署时可指向外部UTF-8 JSON,修改后需重启
 DMS_UI_DICTIONARY_CONFIG_PATH=
@@ -18,14 +21,16 @@ DMS_LIBREOFFICE_EXECUTABLE=C:\\Program Files\\LibreOffice\\program\\soffice.exe
 DMS_LIBREOFFICE_TIMEOUT_SECONDS=60
 DMS_LIBREOFFICE_MAX_CONCURRENCY=2
 
+# PDF 预览渲染引擎:iframe=浏览器内置预览(默认,适应宽度);pdfjs=自托管 pdfjs-dist
+DMS_PDF_VIEWER_ENGINE=iframe
+
 # B2配置真实随机密钥;禁止提交真实密钥
 DMS_JWT_SECRET=replace-with-a-random-secret
 
 # 仅供显式执行 python -m dms.seed_dev;密码值不得提交
 DMS_SEED_ADMIN_PASSWORD=
-DMS_SEED_AUDITOR_PASSWORD=
 DMS_SEED_USER_PASSWORD=
-# true会覆盖名同名用户的密码并使其旧Token失效,默认必须保持false
+# true会覆盖admin和user两名同名用户的密码并使其旧Token失效,默认必须保持false
 DMS_SEED_OVERWRITE_PASSWORDS=false
 
 # 既有AI配置仍独立使用 INFO_EXTRACTOR_* 环境变量

+ 37 - 2
backend/app.py

@@ -13,6 +13,7 @@ import logging
 import os
 import queue
 import re
+import sys
 import tempfile
 import threading
 import time
@@ -20,7 +21,7 @@ import uuid
 from pathlib import Path
 from typing import Any, Iterator
 
-from flask import Flask, Response, jsonify, request
+from flask import Flask, Response, jsonify, request, send_from_directory
 from flask_cors import CORS
 
 from dms import init_dms
@@ -51,6 +52,40 @@ CORS(app)
 # 不改变既有 /api/* AI接口的响应、认证或错误处理行为。
 init_dms(app)
 
+# ===== 前端静态托管(一体化分发用)=====
+def _resolve_frontend_dist() -> Path | None:
+    """定位前端 dist 目录:优先环境变量,其次 PyInstaller 资源,最后开发路径"""
+    env_path = os.environ.get("DMS_FRONTEND_DIST", "").strip()
+    if env_path:
+        p = Path(env_path)
+        return p if p.exists() else None
+    if hasattr(sys, "_MEIPASS"):
+        p = Path(sys._MEIPASS) / "frontend_dist"
+        return p if p.exists() else None
+    dev = Path(__file__).resolve().parent.parent / "frontend" / "dist"
+    return dev if dev.exists() else None
+
+
+FRONTEND_DIST = _resolve_frontend_dist()
+
+if FRONTEND_DIST is not None:
+    @app.route("/", defaults={"path": ""})
+    @app.route("/<path:path>")
+    def serve_spa(path: str):
+        # /api/* 已由上方蓝图和显式路由优先匹配;漏网的 api 请求返回 404
+        if path.startswith("api/"):
+            return jsonify({"error": "Not found"}), 404
+        candidate = FRONTEND_DIST / path
+        if path and candidate.is_file():
+            return send_from_directory(FRONTEND_DIST, path)
+        # 其余路径统一返回 index.html,交给前端路由
+        return send_from_directory(FRONTEND_DIST, "index.html")
+
+    logger.info("Frontend dist mounted at: %s", FRONTEND_DIST)
+else:
+    logger.warning("Frontend dist not found; running in API-only mode")
+# ===== 前端静态托管 END =====
+
 # 从环境变量读取后端配置
 # 例如:INFO_EXTRACTOR_API_KEY=your-api-key INFO_EXTRACTOR_MODEL_NAME=glm-5.2 INFO_EXTRACTOR_BASE_URL=https://... python app.py
 API_KEY = os.environ.get("INFO_EXTRACTOR_API_KEY", "")
@@ -558,6 +593,6 @@ def annotation():
 if __name__ == "__main__":
     app.run(
         host=os.environ.get("DMS_HOST", "0.0.0.0"),
-        port=int(os.environ.get("DMS_PORT", "8755")),
+        port=int(os.environ.get("DMS_PORT", "9345")),
         debug=False,
     )

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

@@ -121,6 +121,7 @@ from dms.api.v1 import (  # noqa: E402,F401
     documents,
     organizations,
     recycle_bin,
+    runtime_config,
     ui_dictionaries,
     users,
 )

+ 8 - 0
backend/dms/api/v1/documents.py

@@ -30,6 +30,7 @@ from dms.services.attachment_binding_service import (
 from dms.services.document_query_service import (
     get_document,
     list_documents,
+    list_main_plan_tags,
     list_sub_plans,
     parse_string_id,
 )
@@ -158,6 +159,13 @@ def documents_list_route():
     return success_response(list_documents(request.args))
 
 
+@api_v1.get("/documents/tags")
+@bearer_auth_required
+def documents_tags_route():
+    """聚合当前用户可见的主案标签,供前端左侧标签筛选条使用。"""
+    return success_response(list_main_plan_tags())
+
+
 @api_v1.post("/documents")
 @bearer_auth_required
 @admin_required

+ 28 - 0
backend/dms/api/v1/runtime_config.py

@@ -0,0 +1,28 @@
+"""运行时只读配置接口。
+
+向前端暴露由后端 ``.env`` / 启动器决定的非敏感配置项,
+让前端可以根据后端策略切换实现(如 PDF 预览引擎),
+而不必把每个环境变量都硬编码进打包产物。
+"""
+
+from __future__ import annotations
+
+from flask import current_app
+
+from dms.api.v1.blueprint import api_v1
+from dms.common.response import success_response
+
+
+@api_v1.get("/config/runtime")
+def get_runtime_config():
+    pdf_viewer_engine = current_app.config.get(
+        "DMS_PDF_VIEWER_ENGINE", "iframe"
+    )
+    if pdf_viewer_engine not in {"iframe", "pdfjs"}:
+        pdf_viewer_engine = "iframe"
+    response, status = success_response(
+        {"pdfViewerEngine": pdf_viewer_engine}
+    )
+    # 该接口仅暴露非敏感开关,允许浏览器缓存以减少首屏请求。
+    response.headers["Cache-Control"] = "public, max-age=60"
+    return response, status

+ 12 - 5
backend/dms/backfill_document_content.py

@@ -24,18 +24,25 @@ from dms.services.recycle_bin_service import _verify_file
 
 
 def _database_gate() -> None:
+    """安全闸门:必须显式配置 DMS_DATABASE_URL,且只允许连接 dms / dms_test。"""
     configured = os.environ.get("DMS_DATABASE_URL", "").strip()
     if not configured:
-        raise RuntimeError("必须显式配置DMS_DATABASE_URL并指向dms_test")
+        raise RuntimeError("必须显式配置DMS_DATABASE_URL")
     try:
         configured_database = make_url(configured).database
     except Exception as exc:
         raise RuntimeError("DMS_DATABASE_URL不是有效数据库连接配置") from exc
-    if configured_database != "dms_test":
-        raise RuntimeError("正文回填命令只允许连接dms_test")
+    allowed = {"dms", "dms_test"}
+    if configured_database not in allowed:
+        raise RuntimeError(
+            f"正文回填命令只允许连接 {' 或 '.join(sorted(allowed))},"
+            f"当前指向 {configured_database!r}"
+        )
     database_name = db.session.scalar(text("SELECT DATABASE()"))
-    if database_name != "dms_test":
-        raise RuntimeError("正文回填命令只允许连接dms_test")
+    if database_name not in allowed:
+        raise RuntimeError(
+            f"正文回填命令实际连接的库 {database_name!r} 不在允许列表内"
+        )
 
 
 def backfill() -> dict[str, int]:

+ 8 - 0
backend/dms/config.py

@@ -80,6 +80,13 @@ def load_dms_config() -> dict[str, Any]:
         "DMS_LIBREOFFICE_MAX_CONCURRENCY", 2
     )
 
+    pdf_viewer_engine_raw = (
+        os.environ.get("DMS_PDF_VIEWER_ENGINE", "iframe").strip().lower()
+    )
+    pdf_viewer_engine = (
+        pdf_viewer_engine_raw if pdf_viewer_engine_raw in {"iframe", "pdfjs"} else "iframe"
+    )
+
     return {
         "SQLALCHEMY_DATABASE_URI": database_url or DEFAULT_DATABASE_URL,
         "SQLALCHEMY_TRACK_MODIFICATIONS": False,
@@ -108,4 +115,5 @@ def load_dms_config() -> dict[str, Any]:
         "DMS_LIBREOFFICE_EXECUTABLE": libreoffice_executable,
         "DMS_LIBREOFFICE_TIMEOUT_SECONDS": libreoffice_timeout_seconds,
         "DMS_LIBREOFFICE_MAX_CONCURRENCY": libreoffice_max_concurrency,
+        "DMS_PDF_VIEWER_ENGINE": pdf_viewer_engine,
     }

+ 1 - 0
backend/dms/services/attachment_query_service.py

@@ -94,6 +94,7 @@ def attachment_detail(
                 "userCount": 0,
                 "inheritedFromMainPlan": False,
             },
+            "contentText": attachment.content_text,
         }
     )
     return result

+ 88 - 4
backend/dms/services/document_query_service.py

@@ -2,11 +2,12 @@
 
 from __future__ import annotations
 
+import json
 import logging
 from datetime import datetime, timezone
 from typing import Any, Mapping
 
-from sqlalchemy import String, cast, or_, select
+from sqlalchemy import String, and_, cast, func, or_, select
 
 from dms.common.enums import (
     AuditAction,
@@ -144,14 +145,41 @@ def _permission_summary(
     }, visibility_summary
 
 
+_SNIPPET_BEFORE = 40
+_SNIPPET_AFTER = 80
+
+
+def _content_snippet(content_text: str | None, keyword: str | None) -> str | None:
+    """当 keyword 命中正文时,截取包含命中区域的片段。
+
+    前后各保留若干字符作为上下文;若未触达正文首尾则以"……"标识截断。
+    未命中或任一参数为空时返回 None。
+    """
+    if not content_text or not keyword or not keyword.strip():
+        return None
+    kw = keyword.strip()
+    pos = content_text.lower().find(kw.lower())
+    if pos < 0:
+        return None
+    start = max(pos - _SNIPPET_BEFORE, 0)
+    end = min(pos + len(kw) + _SNIPPET_AFTER, len(content_text))
+    snippet = content_text[start:end]
+    if start > 0:
+        snippet = "……" + snippet
+    if end < len(content_text):
+        snippet += "……"
+    return snippet
+
+
 def document_summary(
     document: Document,
     context: AuthContext,
     access: PlanAccess,
+    keyword: str | None = None,
 ) -> dict[str, object]:
     _, visibility_summary = _permission_summary(access, document)
     source = access.source or document
-    return {
+    result: dict[str, object] = {
         "id": serialize_id(document.id),
         "documentName": document.document_name,
         "summary": document.summary,
@@ -182,6 +210,10 @@ def document_summary(
         "rowVersion": document.row_version,
         "allowedActions": plan_allowed_actions(document, context, access),
     }
+    snippet = _content_snippet(document.content_text, keyword)
+    if snippet is not None:
+        result["contentSnippet"] = snippet
+    return result
 
 
 def document_detail(
@@ -198,6 +230,7 @@ def document_detail(
             "fileSize": document.file_size,
             "fileHash": document.file_hash,
             "permissionSummary": permission_summary,
+            "contentText": document.content_text,
         }
     )
     return result
@@ -229,6 +262,26 @@ def _keyword(statement, keyword: str | None):
     )
 
 
+def _tags_filter(
+    statement,
+    tags_value: str | None,
+    match_value: str | None,
+):
+    """按 ``tags`` JSON 数组精确匹配;``tagsMatch=any|all`` 决定 OR/AND 语义。"""
+    if not tags_value or not tags_value.strip():
+        return statement
+    tags = [item.strip() for item in tags_value.split(",") if item.strip()]
+    if not tags:
+        return statement
+    match = (match_value or "any").strip().lower()
+    if match not in {"any", "all"}:
+        raise InvalidArgumentError("tagsMatch只允许any或all")
+    # 使用 JSON_CONTAINS 做数组元素的精确匹配;json.dumps 保证特殊字符安全转义。
+    conditions = [func.json_contains(Document.tags, json.dumps(tag)) for tag in tags]
+    combiner = and_ if match == "all" else or_
+    return statement.where(combiner(*conditions))
+
+
 def _boolean(value: str | None, name: str, default: bool = False) -> bool:
     if value is None:
         return default
@@ -289,6 +342,9 @@ def list_documents(params: Mapping[str, str]) -> dict[str, object]:
             )
         )
     statement = _keyword(statement, params.get("keyword"))
+    statement = _tags_filter(
+        statement, params.get("tags"), params.get("tagsMatch")
+    )
     visibility_filter = _enum(
         params.get("visibilityType"), VisibilityType, "visibilityType"
     )
@@ -324,7 +380,7 @@ def list_documents(params: Mapping[str, str]) -> dict[str, object]:
     selected = visible[page.offset : page.offset + page.page_size]
     return page_result(
         [
-            document_summary(document, context, access)
+            document_summary(document, context, access, keyword=params.get("keyword"))
             for document, access in selected
         ],
         page=page.page,
@@ -437,13 +493,39 @@ def list_sub_plans(
     total = len(visible)
     selected = visible[page.offset : page.offset + page.page_size]
     return page_result(
-        [document_summary(child, context, access) for child, access in selected],
+        [document_summary(child, context, access, keyword=params.get("keyword")) for child, access in selected],
         page=page.page,
         page_size=page.page_size,
         total=total,
     )
 
 
+def list_main_plan_tags() -> dict[str, object]:
+    """聚合当前用户可见的所有主案标签。
+
+    - 全局视角:标签集合不随分类、关键词等其他筛选变化;
+    - 权限:通过 ``evaluate_plan_access`` 在 Python 层逐条过滤,
+      与 ``list_documents`` 保持一致的可见性语义,避免泄露被
+      ACL/密级隔离的主案标签;
+    - 顺序:按 Unicode 升序,便于前端稳定渲染。
+    """
+    context = get_auth_context()
+    statement = select(Document).where(
+        Document.is_deleted.is_(False),
+        Document.document_type == DocumentType.MAIN.value,
+    )
+    documents = db.session.scalars(statement).all()
+    tags_set: set[str] = set()
+    for document in documents:
+        access = evaluate_plan_access(document, context)
+        if not access.allowed:
+            continue
+        if document.tags:
+            tags_set.update(document.tags)
+    tags = sorted(tags_set)
+    return {"items": tags, "total": len(tags)}
+
+
 __all__ = [
     "DOCUMENT_SORTS",
     "_date",
@@ -453,10 +535,12 @@ __all__ = [
     "_page",
     "_record_view",
     "_sort",
+    "_tags_filter",
     "document_detail",
     "document_summary",
     "get_document",
     "list_documents",
+    "list_main_plan_tags",
     "list_sub_plans",
     "parse_string_id",
 ]

+ 111 - 0
backend/launcher.py

@@ -0,0 +1,111 @@
+"""DMS 一体化启动入口(PyInstaller 打包用)。
+
+职责:
+1. 设置运行时环境变量(.env、存储目录)
+2. 延后导入并启动 Flask 应用
+
+设计约束:
+- 必须在 ``import app`` 之前完成环境变量设置,
+  因为 app.py 在导入时即调用 ``init_dms(app)`` 读取配置。
+- 自实现 .env 解析,避免新增 python-dotenv 依赖。
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import sys
+from pathlib import Path
+
+
+def _app_root() -> Path:
+    """exe 同级目录(用户可见、可写)。
+
+    - PyInstaller 打包模式:``sys.executable`` 是 exe 文件本身
+    - 开发模式:本文件位于 ``backend/launcher.py``,root 在上一级
+    """
+    if getattr(sys, "frozen", False):
+        return Path(sys.executable).resolve().parent
+    return Path(__file__).resolve().parent.parent
+
+
+def _load_dotenv(env_path: Path) -> None:
+    """简易 .env 解析器。
+
+    仅做 ``key=value`` 解析,``setdefault`` 不覆盖已有环境变量。
+    不依赖 python-dotenv,避免在 requirements.txt 中新增依赖。
+    """
+    if not env_path.exists():
+        return
+    with open(env_path, "r", encoding="utf-8") as f:
+        for raw in f:
+            line = raw.strip()
+            if not line or line.startswith("#") or "=" not in line:
+                continue
+            key, _, value = line.partition("=")
+            key = key.strip()
+            value = value.strip().strip('"').strip("'")
+            os.environ.setdefault(key, value)
+
+
+def _ensure_runtime_dirs(root: Path) -> None:
+    """在 exe 同级创建 dms-storage 子目录骨架,并写入环境变量。"""
+    storage = root / "dms-storage"
+    storage.mkdir(parents=True, exist_ok=True)
+    for sub in (
+        "extracted",
+        "original",
+        "preview",
+        "quarantine",
+        "recycle",
+        "temporary",
+    ):
+        (storage / sub).mkdir(exist_ok=True)
+    # 仅当 DMS_STORAGE_ROOT 未设置或为空时写入默认值。
+    # 不能用 setdefault:_load_dotenv 会把 .env 中的空字符串注入环境变量,
+    # 使 setdefault 误判为「已设置」,从而导致打包后默认存储路径失效。
+    current_storage = os.environ.get("DMS_STORAGE_ROOT", "").strip()
+    if not current_storage:
+        os.environ["DMS_STORAGE_ROOT"] = str(storage)
+
+
+def _bootstrap_env() -> Path:
+    """准备运行环境,返回 app_root。"""
+    root = _app_root()
+
+    env_file = root / ".env"
+    env_example = root / ".env.example"
+
+    # 首次启动:从模板复制 .env
+    if not env_file.exists() and env_example.exists():
+        shutil.copy(env_example, env_file)
+        print("=" * 60)
+        print("[首次启动] 已生成配置文件:")
+        print(f"  {env_file}")
+        print("请编辑该文件填入数据库连接(DMS_DATABASE_URL)等信息,")
+        print("保存后重新启动本程序。")
+        print("=" * 60)
+
+    _load_dotenv(env_file)
+    _ensure_runtime_dirs(root)
+    return root
+
+
+def main() -> None:
+    root = _bootstrap_env()
+
+    # 延后导入:确保上面的环境变量先生效
+    from app import app
+
+    host = os.environ.get("DMS_HOST", "0.0.0.0")
+    port = int(os.environ.get("DMS_PORT", "9345"))
+
+    print(f"[DMS] 启动中... 访问地址: http://localhost:{port}")
+    print(f"[DMS] 工作目录: {root}")
+    print(f"[DMS] 按 Ctrl+C 退出")
+    print("-" * 60)
+
+    app.run(host=host, port=port, debug=False, use_reloader=False)
+
+
+if __name__ == "__main__":
+    main()

+ 131 - 0
backend/tests/dms/test_q3f_tag_aggregation.py

@@ -0,0 +1,131 @@
+"""Q3-F 标签聚合与按标签筛选的接口契约测试。"""
+
+from __future__ import annotations
+
+import pytest
+
+
+def auth(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+def data(response):
+    return response.get_json()["data"]
+
+
+def names_of(items):
+    return {item["documentName"] for item in items}
+
+
+def test_tags_aggregation_requires_auth(b4_client):
+    response = b4_client.get("/api/v1/documents/tags")
+    assert response.status_code == 401
+    assert response.get_json()["code"] == "TOKEN_INVALID"
+
+
+def test_tags_aggregation_dedups_main_plan_tags(b4_client, token_for):
+    response = b4_client.get(
+        "/api/v1/documents/tags", headers=auth(token_for("admin"))
+    )
+    assert response.status_code == 200
+    payload = data(response)
+    assert payload["items"] == ["B4", "测试"]
+    assert payload["total"] == 2
+
+
+def test_tags_aggregation_respects_visibility(b4_client, token_for):
+    """普通用户看不到绝密/草稿/无授权主案,但可见主案的标签仍可聚合。"""
+    response = b4_client.get(
+        "/api/v1/documents/tags", headers=auth(token_for("user"))
+    )
+    assert response.status_code == 200
+    payload = data(response)
+    # B4 系列可见主案的标签都是 ["B4", "测试"],去重后稳定为这两项。
+    assert payload["items"] == ["B4", "测试"]
+
+
+def test_documents_filter_by_single_tag(b4_client, token_for):
+    response = b4_client.get(
+        "/api/v1/documents?tags=B4&pageSize=100",
+        headers=auth(token_for("admin")),
+    )
+    assert response.status_code == 200
+    items = data(response)["items"]
+    expected_visible_main_plans = {
+        "B4_TEST_全部主案",
+        "B4_TEST_组织主案",
+        "B4_TEST_自定义主案",
+        "B4_TEST_草稿主案",
+        "B4_TEST_绝密主案",
+        "B4_TEST_无授权主案",
+    }
+    assert items and names_of(items) == expected_visible_main_plans
+
+
+def test_documents_filter_by_nonexistent_tag_returns_empty(
+    b4_client, token_for
+):
+    response = b4_client.get(
+        "/api/v1/documents?tags=不存在的标签",
+        headers=auth(token_for("admin")),
+    )
+    assert response.status_code == 200
+    payload = data(response)
+    assert payload["items"] == []
+    assert payload["total"] == 0
+
+
+def test_documents_tag_filter_or_semantics(b4_client, token_for):
+    """默认 tagsMatch=any:多标签任一命中即返回。"""
+    response = b4_client.get(
+        "/api/v1/documents?tags=B4,不存在的&pageSize=100",
+        headers=auth(token_for("admin")),
+    )
+    assert response.status_code == 200
+    items = data(response)["items"]
+    assert items and all("B4_TEST_" in item["documentName"] for item in items)
+
+
+def test_documents_tag_filter_all_semantics(b4_client, token_for):
+    """tagsMatch=all:所有标签都必须命中。B4/测试 同时存在 → 全部主案。"""
+    response = b4_client.get(
+        "/api/v1/documents?tags=B4,测试&tagsMatch=all&pageSize=100",
+        headers=auth(token_for("admin")),
+    )
+    assert response.status_code == 200
+    items = data(response)["items"]
+    assert items and {item["tags"] for item in items} == {["B4", "测试"]}
+
+
+def test_documents_tag_filter_all_with_missing_excludes(
+    b4_client, token_for
+):
+    response = b4_client.get(
+        "/api/v1/documents?tags=B4,缺失&tagsMatch=all&pageSize=100",
+        headers=auth(token_for("admin")),
+    )
+    assert response.status_code == 200
+    payload = data(response)
+    assert payload["items"] == []
+    assert payload["total"] == 0
+
+
+@pytest.mark.parametrize("query", ["tagsMatch=invalid", "tagsMatch=ANY"])
+def test_documents_invalid_tags_match_rejected(b4_client, token_for, query):
+    response = b4_client.get(
+        f"/api/v1/documents?tags=B4&{query}",
+        headers=auth(token_for("admin")),
+    )
+    assert response.status_code == 400
+    assert response.get_json()["code"] == "INVALID_ARGUMENT"
+
+
+def test_documents_tag_filter_excludes_sub_plans(b4_client, token_for):
+    """标签筛选只命中主案,子方案/附件不在结果中。"""
+    response = b4_client.get(
+        "/api/v1/documents?tags=B4&pageSize=100",
+        headers=auth(token_for("admin")),
+    )
+    assert response.status_code == 200
+    types = {item["documentType"] for item in data(response)["items"]}
+    assert types == {"MAIN"}

+ 87 - 0
backend/tests/dms/test_q4a_search_highlight.py

@@ -0,0 +1,87 @@
+"""搜索关键词高亮与正文命中片段的单元测试。"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+
+BACKEND_ROOT = Path(__file__).resolve().parents[2]
+if str(BACKEND_ROOT) not in sys.path:
+    sys.path.insert(0, str(BACKEND_ROOT))
+
+from dms.services.document_query_service import _content_snippet
+
+
+class TestContentSnippet:
+    """_content_snippet 纯函数测试。"""
+
+    def test_returns_none_when_no_content(self):
+        assert _content_snippet(None, "关键词") is None
+
+    def test_returns_none_when_no_keyword(self):
+        assert _content_snippet("一些正文内容", None) is None
+        assert _content_snippet("一些正文内容", "") is None
+        assert _content_snippet("一些正文内容", "  ") is None
+
+    def test_returns_none_when_keyword_not_in_content(self):
+        assert _content_snippet("这是一段正文", "不存在") is None
+
+    def test_keyword_at_start_no_prefix_ellipsis(self):
+        content = "关键词在开头,后面有很多内容" * 10
+        result = _content_snippet(content, "关键词")
+        assert result is not None
+        assert not result.startswith("……")
+        assert result.endswith("……")
+        assert "关键词" in result
+
+    def test_keyword_at_end_no_suffix_ellipsis(self):
+        content = "前面有很多内容" * 20 + "尾部关键词"
+        result = _content_snippet(content, "尾部关键词")
+        assert result is not None
+        assert result.startswith("……")
+        assert not result.endswith("……")
+        assert "尾部关键词" in result
+
+    def test_keyword_in_middle_both_ellipsis(self):
+        content = "前面的内容。" * 20 + "目标词" + "后面的内容。" * 20
+        result = _content_snippet(content, "目标词")
+        assert result is not None
+        assert result.startswith("……")
+        assert result.endswith("……")
+        assert "目标词" in result
+
+    def test_short_content_no_ellipsis(self):
+        content = "很短的关键词"
+        result = _content_snippet(content, "关键词")
+        assert result is not None
+        assert not result.startswith("……")
+        assert not result.endswith("……")
+        assert result == content
+
+    def test_case_insensitive_match(self):
+        content = "Hello World Test"
+        result = _content_snippet(content, "hello")
+        assert result is not None
+        assert "Hello" in result
+
+    def test_chinese_case_insensitive(self):
+        """中文无大小写,但函数不应崩溃。"""
+        content = "这是一段包含关键词的正文内容"
+        result = _content_snippet(content, "关键词")
+        assert result is not None
+        assert "关键词" in result
+
+    def test_snippet_length_bounded(self):
+        content = "A" * 500 + "目标" + "B" * 500
+        result = _content_snippet(content, "目标")
+        assert result is not None
+        # 40 before + keyword + 80 after + 2 ellipsis chars
+        assert len(result) <= 40 + 2 + 80 + 4  # 126
+
+    def test_strips_keyword_whitespace(self):
+        content = "前面的内容目标词后面的内容"
+        result = _content_snippet(content, "  目标词  ")
+        assert result is not None
+        assert "目标词" in result

+ 80 - 0
build/README.txt

@@ -0,0 +1,80 @@
+========================================
+  DMS 文档管理系统 - 使用说明
+========================================
+
+【系统要求】
+  - Windows 10/11 (64 位)
+  - MySQL 8.x(自备,需自行安装并初始化数据库)
+  -(可选)LibreOffice:用于 Word 文档转 PDF 预览
+
+------------------------------------------------------------------------
+【首次启动步骤】
+------------------------------------------------------------------------
+
+■ 步骤 1:准备 MySQL 数据库
+   1) 安装 MySQL 8(如已安装可跳过)
+   2) 创建数据库(utf8mb4 字符集):
+        CREATE DATABASE dms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+   3) 如果发行包内附带 database-init.sql,则导入它:
+        mysql -u root -p dms < database-init.sql
+      这会同时创建表结构并写入示例数据。
+      若没有该文件,请向分发者索取(或自行从源码项目执行导出工具生成)。
+
+■ 步骤 2:修改配置文件
+   1) 用记事本(或 VSCode)打开本目录下的 .env 文件
+      (首次启动会自动从 .env.example 生成)
+   2) 修改以下关键项:
+        DMS_DATABASE_URL=mysql+pymysql://用户名:密码@主机:3306/dms?charset=utf8mb4
+        DMS_JWT_SECRET=请改成一串随机字符(建议 32 位以上)
+
+■ 步骤 3:启动服务
+   双击 dms-server.exe
+   看到控制台输出 "Running on http://127.0.0.1:9345" 即启动成功
+
+■ 步骤 4:访问系统
+   在浏览器打开:http://localhost:9345
+
+------------------------------------------------------------------------
+【默认登录账号】(仅当导入了示例数据时)
+------------------------------------------------------------------------
+   管理员:admin / Admin@1234
+   普通用户:user / User@1234
+   (密码来源于示例数据,可在登录后修改)
+
+------------------------------------------------------------------------
+【常见问题】
+------------------------------------------------------------------------
+
+Q: 启动后浏览器无法访问?
+A: 1) 检查控制台是否有错误信息(特别是数据库连接错误)
+   2) 确认 .env 中的 DMS_DATABASE_URL 配置正确
+   3) 确认 MySQL 服务已启动
+   4) 确认已执行步骤 1 的 SQL 导入
+
+Q: 端口冲突?
+A: 编辑 .env,添加一行:
+   DMS_PORT=9350(或其他端口),重启 exe
+
+Q: Word 文档预览失败?
+A: 安装 LibreOffice,并确认 .env 中 DMS_LIBREOFFICE_EXECUTABLE
+   路径正确(默认 C:\Program Files\LibreOffice\program\soffice.exe)
+   或在 .env 中设置 DMS_OFFICE_PREVIEW_ENABLED=false 关闭该功能。
+
+Q: 上传的文件存储在哪?
+A: 程序同级目录的 dms-storage/ 下,请勿删除。
+
+Q: 如何停止服务?
+A: 在 dms-server.exe 的控制台窗口按 Ctrl+C,或直接关闭窗口。
+
+------------------------------------------------------------------------
+【目录结构】
+------------------------------------------------------------------------
+  dms-server.exe              主程序
+  _internal/                  PyInstaller 运行时依赖(请勿删除)
+  .env                        配置文件(首次启动生成)
+  .env.example                配置模板
+  database-init.sql           数据库初始化脚本(如已导出)
+  dms-storage/                运行时文件存储目录
+  README.txt                  本说明文件
+
+========================================

+ 135 - 0
build/build.ps1

@@ -0,0 +1,135 @@
+# DMS 一体化构建脚本
+# 用法:在项目根目录执行 .\build\build.ps1
+[CmdletBinding()]
+param(
+    [switch]$SkipFrontend,
+    [switch]$SkipPyInstallerInstall
+)
+
+$ErrorActionPreference = "Stop"
+$ProjectRoot = Split-Path $PSScriptRoot -Parent
+$Backend = Join-Path $ProjectRoot "backend"
+$Frontend = Join-Path $ProjectRoot "frontend"
+$Dist = Join-Path $ProjectRoot "dist-package"
+$BuildCache = Join-Path $PSScriptRoot "__pycache_build"
+
+Write-Host "========== DMS 一体化构建 ==========" -ForegroundColor Cyan
+Write-Host "项目根目录: $ProjectRoot"
+
+# ---------- 1. 前端构建 ----------
+if (-not $SkipFrontend) {
+    Write-Host "`n[1/5] 构建前端..." -ForegroundColor Yellow
+    Push-Location $Frontend
+    try {
+        if (-not (Test-Path "node_modules")) {
+            Write-Host "  npm install..." -ForegroundColor DarkGray
+            npm install
+            if ($LASTEXITCODE -ne 0) { throw "npm install 失败" }
+        }
+        npm run build
+        if ($LASTEXITCODE -ne 0) { throw "前端构建失败" }
+    } finally { Pop-Location }
+} else {
+    Write-Host "`n[1/5] 跳过前端构建 (-SkipFrontend)" -ForegroundColor DarkGray
+}
+
+$indexHtml = Join-Path $Frontend "dist\index.html"
+if (-not (Test-Path $indexHtml)) {
+    throw "前端产物不存在: $indexHtml - 请先执行 npm run build"
+}
+
+# ---------- 2. 检查 PyInstaller ----------
+Write-Host "`n[2/5] 检查 PyInstaller..." -ForegroundColor Yellow
+Push-Location $Backend
+try {
+    $pyinstallerVersion = & python -m PyInstaller --version 2>$null
+    if ($LASTEXITCODE -ne 0 -or -not $pyinstallerVersion) {
+        if ($SkipPyInstallerInstall) {
+            throw "PyInstaller 未安装且 -SkipPyInstallerInstall 已指定"
+        }
+        Write-Host "  安装 PyInstaller..." -ForegroundColor DarkGray
+        python -m pip install pyinstaller
+        if ($LASTEXITCODE -ne 0) { throw "PyInstaller 安装失败" }
+    } else {
+        Write-Host "  PyInstaller 版本: $pyinstallerVersion" -ForegroundColor DarkGray
+    }
+} finally { Pop-Location }
+
+# ---------- 3. PyInstaller 打包 ----------
+Write-Host "`n[3/5] PyInstaller 打包..." -ForegroundColor Yellow
+if (Test-Path $BuildCache) { Remove-Item $BuildCache -Recurse -Force }
+if (Test-Path (Join-Path $Dist "dms-server")) {
+    Remove-Item (Join-Path $Dist "dms-server") -Recurse -Force
+}
+
+Push-Location $Backend
+try {
+    python -m PyInstaller `
+        --noconfirm `
+        --clean `
+        --distpath $Dist `
+        --workpath $BuildCache `
+        (Join-Path $PSScriptRoot "dms-server.spec")
+    if ($LASTEXITCODE -ne 0) { throw "PyInstaller 打包失败" }
+} finally { Pop-Location }
+
+# ---------- 4. 整理发行包 ----------
+Write-Host "`n[4/5] 整理发行包..." -ForegroundColor Yellow
+$ReleaseDir = Join-Path $Dist "dms-server"
+if (-not (Test-Path $ReleaseDir)) { throw "发行目录未找到: $ReleaseDir" }
+
+# 复制配置模板(不含真实 .env,保护凭据)
+Copy-Item (Join-Path $Backend ".env.example") $ReleaseDir -Force
+# 尝试调用项目自带的导出工具,把示例数据库导出为 SQL 文件
+$ExportTool = Join-Path $ProjectRoot "tools\export_full_demo_data.py"
+$ExportedSql = Join-Path $ReleaseDir "database-init.sql"
+if (Test-Path $ExportTool) {
+    Write-Host "  导出示例数据库 SQL..." -ForegroundColor DarkGray
+    Push-Location $Backend
+    try {
+        # 通过当前 Python 环境运行导出工具
+        python $ExportTool --output $ExportedSql 2>&1 | Out-Host
+        # 不判断退出码(不同版本的导出工具参数可能不同),仅检查产物
+        if (-not (Test-Path $ExportedSql)) {
+            Write-Warning "导出工具未生成 $ExportedSql,请手动准备 SQL 文件"
+        }
+    } catch {
+        Write-Warning "导出工具执行失败:$_"
+    } finally { Pop-Location }
+} else {
+    Write-Warning "未找到 tools\export_full_demo_data.py,跳过自动导出"
+}
+# 复制说明文档
+Copy-Item (Join-Path $PSScriptRoot "README.txt") $ReleaseDir -Force
+
+# ---------- 5. 从备份恢复 .env 和 dms-storage ----------
+Write-Host "`n[5/5] 从备份恢复 .env 和 dms-storage..." -ForegroundColor Yellow
+$BackupDir = Join-Path $ProjectRoot "dist-package-bak\dms-server"
+if (-not (Test-Path $BackupDir)) {
+    Write-Warning "备份目录不存在: $BackupDir - 跳过恢复"
+} else {
+    $BackupEnv = Join-Path $BackupDir ".env"
+    if (Test-Path $BackupEnv) {
+        Copy-Item $BackupEnv $ReleaseDir -Force
+        Write-Host "  已恢复 .env" -ForegroundColor DarkGray
+    } else {
+        Write-Warning "备份中没有 .env,跳过"
+    }
+    $BackupStorage = Join-Path $BackupDir "dms-storage"
+    if (Test-Path $BackupStorage) {
+        $TargetStorage = Join-Path $ReleaseDir "dms-storage"
+        if (Test-Path $TargetStorage) {
+            Remove-Item $TargetStorage -Recurse -Force
+        }
+        Copy-Item $BackupStorage $TargetStorage -Recurse -Force
+        Write-Host "  已恢复 dms-storage" -ForegroundColor DarkGray
+    } else {
+        Write-Warning "备份中没有 dms-storage,跳过"
+    }
+}
+
+Write-Host "`n========== 构建完成 ==========" -ForegroundColor Green
+Write-Host "发行包目录: $ReleaseDir" -ForegroundColor Green
+$sizeMB = [math]::Round((Get-ChildItem $ReleaseDir -Recurse | Measure-Object -Property Length -Sum).Sum / 1MB, 1)
+Write-Host "包大小: ${sizeMB} MB" -ForegroundColor Green
+Write-Host "将该目录整体压缩即可分发给最终用户" -ForegroundColor Green

Plik diff jest za duży
+ 662 - 18
frontend/package-lock.json


+ 1 - 0
frontend/package.json

@@ -52,6 +52,7 @@
     "lucide-react": "0.487.0",
     "motion": "12.23.24",
     "next-themes": "0.4.6",
+    "pdfjs-dist": "^3.11.174",
     "react": "18.3.1",
     "react-day-picker": "8.10.1",
     "react-dnd": "16.0.1",

+ 71 - 26
frontend/src/app/App.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
 import {
   Search, ChevronRight, ChevronDown, Download, Eye, Upload, Trash2, X,
   Edit2, Key, User, Activity, LogOut, Plus, RefreshCw, Check, Lock,
@@ -54,8 +54,10 @@ import { documentApi } from "../features/documents/documentApi";
 import {
   loadDetailOnce,
   useDocumentList,
+  useDocumentTags,
   useSubPlanList,
 } from "../features/documents/documentState";
+import { TagPanel } from "../features/documents/TagPanel";
 import type {
   DocumentDetail,
   DocumentSummary,
@@ -665,12 +667,14 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory, allC
         <span className="text-sm font-black" style={{ color: N }}>方案分类</span>
       </div>
       <div className="flex-1 overflow-hidden flex flex-col p-3">
-        <CategoryPanel
-          admin={admin}
-          selectedCategoryId={selectedCategoryId}
-          onSelectCategory={onSelectCategory}
-          allCount={allCount}
-        />
+        <div className="flex-1 min-h-0 flex flex-col">
+          <CategoryPanel
+            admin={admin}
+            selectedCategoryId={selectedCategoryId}
+            onSelectCategory={onSelectCategory}
+            allCount={allCount}
+          />
+        </div>
         {children}
       </div>
     </div>
@@ -2216,8 +2220,9 @@ function F4Pager({ page, totalPages, onPage }: {
   );
 }
 
-function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode = false, onReverse, onView, onEdit, onDelete, onPermission, onUnbind, onFileError }: {
+function F4DocumentTable({ items, keyword, selectedId, onSelect, onDetail, bindingMode = false, onReverse, onView, onEdit, onDelete, onPermission, onUnbind, onFileError }: {
   items: (DocumentSummary | AttachmentBindingSummary)[];
+  keyword?: string;
   selectedId?: string | null;
   onSelect?: (item: DocumentSummary) => void;
   onDetail: (item: DocumentSummary, attachment: boolean) => void;
@@ -2236,7 +2241,7 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
       <thead className="sticky top-0 z-10">
         <tr style={{ background: N }}>
           {["序号", "名称", "类型/分类", "密级", "概述与标签", bindingMode ? "挂载顺序" : "更新时间", "操作"].map((heading) => (
-            <th key={heading} className="px-4 py-3 text-left text-sm font-black whitespace-nowrap" style={{ color: W }}>{heading}</th>
+            <th key={heading} className={`px-4 py-3 text-sm font-black whitespace-nowrap ${heading === "序号" ? "text-center" : "text-left"}`} style={{ color: W }}>{heading}</th>
           ))}
         </tr>
       </thead>
@@ -2248,9 +2253,10 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
           const canEdit = item.allowedActions.includes(AllowedAction.EDIT);
           const canDelete = item.allowedActions.includes(AllowedAction.DELETE);
           const binding = bindingMode ? item as AttachmentBindingSummary : null;
+          const kw = (keyword ?? "").trim();
           return (
+            <Fragment key={binding?.bindingId ?? item.id}>
             <tr
-              key={binding?.bindingId ?? item.id}
               onClick={() => onSelect?.(item)}
               className="border-b cursor-pointer"
               style={{
@@ -2258,9 +2264,9 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
                 background: selectedId === item.id ? "rgba(49,145,235,0.12)" : index % 2 ? "rgba(40,118,193,0.025)" : W,
               }}
             >
-              <td className="px-4 py-3 text-sm font-bold" style={{ color: N }}>{index + 1}</td>
+              <td className="px-4 py-3 text-sm font-bold text-center" 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-sm font-black" style={{ color: N }}>{kw ? <HighlightText text={item.documentName} keyword={kw} /> : item.documentName}</div>
                 {attachment && <div className="text-[11px] font-bold opacity-55" style={{ color: N }}>
                   {item.fileExtension ? item.fileExtension.toUpperCase() : "无扩展名"}
                 </div>}
@@ -2270,9 +2276,9 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
               </td>
               <td className="px-4 py-3"><LevelBadge level={dictionaries.label("securityLevels", item.securityLevel)} /></td>
               <td className="px-4 py-3 max-w-[360px]">
-                <div className="text-xs line-clamp-2" style={{ color: "rgba(26,82,153,.65)" }}>{item.summary || "暂无概述"}</div>
+                <div className="text-xs line-clamp-2" style={{ color: "rgba(26,82,153,.65)" }}>{kw ? <HighlightText text={item.summary || "暂无概述"} keyword={kw} /> : (item.summary || "暂无概述")}</div>
                 <div className="flex flex-wrap gap-1 mt-1">
-                  {item.tags.map((tag) => <span key={tag} className="px-1.5 py-0.5 text-[10px] font-black" style={{ color: B, background: "rgba(49,145,235,.1)" }}>{tag}</span>)}
+                  {item.tags.map((tag) => <span key={tag} className="px-1.5 py-0.5 text-[10px] font-black" style={{ color: B, background: "rgba(49,145,235,.1)" }}>{kw ? <HighlightText text={tag} keyword={kw} /> : tag}</span>)}
                 </div>
               </td>
               <td className="px-4 py-3 text-xs font-bold whitespace-nowrap" style={{ color: N }}>
@@ -2290,6 +2296,22 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
                 {attachment && onReverse && <button onClick={(event) => { event.stopPropagation(); onReverse(item); }} className="text-xs font-black" style={{ color: B }}>挂载主案({item.mountedPlanCount ?? 0})</button>}
               </td>
             </tr>
+            {item.contentSnippet && (
+              <tr style={{
+                borderColor: "rgba(40,118,193,0.1)",
+                background: selectedId === item.id ? "rgba(49,145,235,0.12)" : index % 2 ? "rgba(40,118,193,0.025)" : W,
+              }}>
+                <td className="px-4 py-3 text-center align-top" style={{ borderBottom: "1px solid rgba(40,118,193,0.1)" }}>
+                  <span className="text-xs font-black opacity-50" style={{ color: N }}>正文</span>
+                </td>
+                <td colSpan={6} className="px-4 py-3" style={{ borderBottom: "1px solid rgba(40,118,193,0.1)" }}>
+                  <div className="text-xs py-1" style={{ color: N }}>
+                    {kw ? <HighlightText text={item.contentSnippet} keyword={kw} /> : item.contentSnippet}
+                  </div>
+                </td>
+              </tr>
+            )}
+            </Fragment>
           );
         })}
       </tbody>
@@ -2457,6 +2479,7 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
   const [updatedFrom, setUpdatedFrom] = useState("");
   const [updatedTo, setUpdatedTo] = useState("");
   const [categoryId, setCategoryId] = useState<string | null>(null);
+  const [selectedTags, setSelectedTags] = useState<string[]>([]);
   const [allCategoryCount, setAllCategoryCount] = useState<number | null>(null);
   const [page, setPage] = useState(1);
   const [selected, setSelected] = useState<DocumentSummary | null>(null);
@@ -2483,12 +2506,14 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
     categoryId: categoryId ?? undefined,
     includeDescendants: categoryId ? true : undefined,
     keyword: keyword.trim() || undefined,
+    tags: selectedTags.length > 0 ? selectedTags : undefined,
     ...mainRange.query,
     sortBy: "documentName",
     sortDirection: "asc",
     page,
     pageSize: 20,
   }, !mainRange.error);
+  const tagsState = useDocumentTags();
   const selectedMainId = selected?.documentType === DocumentType.MAIN ? selected.id : null;
   const subPlans = useSubPlanList(selectedMainId, {
     keyword: subKeyword.trim() || undefined,
@@ -2511,6 +2536,16 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
     if (categoryId === null && documents.data) setAllCategoryCount(documents.data.total);
   }, [categoryId, documents.data]);
 
+  const toggleTag = useCallback((tag: string) => {
+    setSelectedTags((current) =>
+      current.includes(tag)
+        ? current.filter((item) => item !== tag)
+        : [...current, tag],
+    );
+    setPage(1);
+    setSelected(null);
+  }, []);
+
   useEffect(() => {
     setSubKeyword("");
     setSubStatus("");
@@ -2526,7 +2561,17 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
 
   return (
     <PageFrame title="方案管理系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending} currentUser={currentUser}>
-      <LeftPanel allCount={allCategoryCount} selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPage(1); setSelected(null); setPreview(null); }} />
+      <LeftPanel allCount={allCategoryCount} selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPage(1); setSelected(null); setPreview(null); }}>
+        <TagPanel
+          tags={tagsState.data}
+          selectedTags={selectedTags}
+          loading={tagsState.loading}
+          error={tagsState.error?.message ?? null}
+          onToggleTag={toggleTag}
+          onClear={() => { setSelectedTags([]); setPage(1); setSelected(null); }}
+          onRetry={tagsState.retry}
+        />
+      </LeftPanel>
       <div className="flex-1 flex flex-col m-3 ml-0 min-h-0">
         <DocumentSearchToolbar
           ariaLabel="方案检索"
@@ -2542,12 +2587,12 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
         />
         <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} onRetry={documents.retry} />
-          {documents.data && documents.data.items.length > 0 && <F4DocumentTable items={documents.data.items} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} />}
+          {documents.data && documents.data.items.length > 0 && <F4DocumentTable items={documents.data.items} keyword={keyword} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} />}
           {documents.data && <F4Pager page={page} totalPages={documents.data.totalPages} onPage={(value) => { setPage(value); setSelected(null); }} />}
         </div>
         <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: "已挂载共享附件" }] as const).map((item) => (
+            {([{ id: "sub", label: "子方案" }, { id: "mounted", 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>
@@ -2557,12 +2602,12 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
               <div className="p-12 text-center text-sm font-bold opacity-50" style={{ color: N }}>请选择主案查看关联数据</div>
             ) : lowerTab === "sub" ? (
               <>
-                <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); } }}
+                <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} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} /> : null}
+                {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} keyword={subKeyword} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} /> : null}
                 {subPlans.data && <F4Pager page={subPage} totalPages={subPlans.data.totalPages} onPage={setSubPage} />}
               </>
             ) : (
@@ -2669,13 +2714,13 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
               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} onRetry={plans.retry} />
-              {plans.data?.items.length ? <F4DocumentTable items={plans.data.items} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
+              {plans.data?.items.length ? <F4DocumentTable items={plans.data.items} keyword={keyword} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} 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-[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>
+                <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>
                 {selected?.documentType === DocumentType.MAIN && selected.allowedActions.includes(AllowedAction.BIND_ATTACHMENT) && <button onClick={() => setBindingPlan(selected)} className="ml-3 my-1.5 px-3 text-xs font-black text-white" style={{ background: B }}>挂载资料</button>}
                 {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>
@@ -2683,13 +2728,13 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
               <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" ? (
                   <>
-                    <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); } }}
+                    <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} />
                     <div className="px-4 py-2 text-xs font-bold" style={{ color: N }}>权限继承自主案:{selected.documentName}</div>
-                    {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onFileError={setFileError} /> : null}
+                    {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} keyword={subKeyword} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onFileError={setFileError} /> : null}
                     {subPlans.data && <F4Pager page={subPage} totalPages={subPlans.data.totalPages} onPage={setSubPage} />}
                   </>
                 ) : (

+ 118 - 0
frontend/src/features/documents/TagPanel.tsx

@@ -0,0 +1,118 @@
+import { Tag, X } from "lucide-react";
+
+export interface TagPanelProps {
+  /** 已加载的全部主案标签;null 表示尚未加载。 */
+  tags: string[] | null;
+  /** 当前选中的标签集合(多选 OR 语义)。 */
+  selectedTags: string[];
+  /** 是否处于加载中。 */
+  loading: boolean;
+  /** 错误信息;非空时显示重试入口。 */
+  error: string | null;
+  /** 切换某个标签的选中状态:未选→选、已选→取消。 */
+  onToggleTag: (tag: string) => void;
+  /** 清空全部选中标签。 */
+  onClear: () => void;
+  /** 错误状态下用户点击重试。 */
+  onRetry: () => void;
+}
+
+/**
+ * 左侧主案标签筛选条。
+ *
+ * - 换行布局(`flex flex-wrap`),标签数量不限;
+ * - 多选 OR 语义由父组件维护(这里只负责展示与点击);
+ * - 不显示计数,保持视觉简洁;
+ * - 空标签集(后端返回无标签)显示"暂无标签"占位。
+ */
+export function TagPanel({
+  tags,
+  selectedTags,
+  loading,
+  error,
+  onToggleTag,
+  onClear,
+  onRetry,
+}: TagPanelProps) {
+  const hasSelection = selectedTags.length > 0;
+  const isEmpty = !loading && !error && (tags?.length ?? 0) === 0;
+
+  return (
+    <section
+      aria-label="主案标签筛选"
+      className="mt-3 rounded-md border bg-white"
+      style={{ borderColor: "rgba(40,118,193,.25)" }}
+    >
+      <header
+        className="flex items-center justify-between px-3 py-2 text-white rounded-t-md"
+        style={{ background: "#1A5299" }}
+      >
+        <div className="flex items-center gap-1.5 text-[12px] font-black tracking-wide">
+          <Tag size={13} />
+          标签
+        </div>
+        {hasSelection && (
+          <button
+            type="button"
+            onClick={onClear}
+            className="flex items-center gap-1 text-[11px] font-bold text-white/80 hover:text-white"
+            aria-label="清除标签筛选"
+          >
+            <X size={11} />
+            清除
+          </button>
+        )}
+      </header>
+      <div className="p-3">
+        {loading && (
+          <div className="text-[11px] font-bold opacity-60" style={{ color: "#1A5299" }}>
+            正在加载标签…
+          </div>
+        )}
+        {error && (
+          <div className="flex flex-col items-start gap-2">
+            <span className="text-[11px] font-bold text-red-600">{error}</span>
+            <button
+              type="button"
+              onClick={onRetry}
+              className="px-2 py-1 text-[11px] font-black border"
+              style={{ borderColor: "#3191eb", color: "#1A5299" }}
+            >
+              重试
+            </button>
+          </div>
+        )}
+        {isEmpty && (
+          <div className="text-[11px] font-bold opacity-50" style={{ color: "#1A5299" }}>
+            暂无标签
+          </div>
+        )}
+        {tags && tags.length > 0 && (
+          <div className="flex flex-wrap gap-1.5">
+            {tags.map((tag) => {
+              const selected = selectedTags.includes(tag);
+              return (
+                <button
+                  key={tag}
+                  type="button"
+                  onClick={() => onToggleTag(tag)}
+                  aria-pressed={selected}
+                  title={selected ? `取消筛选:${tag}` : `按标签筛选:${tag}`}
+                  className={[
+                    "px-2 py-1 text-[11px] font-bold rounded-full border transition-colors",
+                    selected
+                      ? "text-white border-transparent"
+                      : "border-[rgba(40,118,193,.4)] text-[#1A5299] hover:bg-[rgba(49,145,235,.1)]",
+                  ].join(" ")}
+                  style={selected ? { background: "#1A5299" } : undefined}
+                >
+                  {tag}
+                </button>
+              );
+            })}
+          </div>
+        )}
+      </div>
+    </section>
+  );
+}

+ 16 - 0
frontend/src/features/documents/__tests__/documentApi.test.ts

@@ -57,4 +57,20 @@ describe("documentApi", () => {
       requestId: "request-id",
     });
   });
+
+  it("GET /documents/tags 透传 signal 并返回字符串数组", async () => {
+    const client = { get: vi.fn().mockResolvedValue(result({ items: ["保密", "涉外"], total: 2 })) };
+    const api = createDocumentApi(client);
+    const signal = new AbortController().signal;
+    await expect(api.listTags(signal)).resolves.toEqual(["保密", "涉外"]);
+    expect(client.get).toHaveBeenCalledWith("documents/tags", { signal });
+  });
+
+  it("GET /documents/tags 拒绝非字符串数组", async () => {
+    const client = { get: vi.fn().mockResolvedValue(result({ items: ["保密", 12], total: 2 })) };
+    await expect(createDocumentApi(client).listTags()).rejects.toMatchObject({
+      code: "INTERNAL_ERROR",
+      requestId: "request-id",
+    });
+  });
 });

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

@@ -10,7 +10,7 @@ const attachmentApi = readFileSync(new URL("../../attachments/attachmentApi.ts",
 describe("Q2-F检索范围与并发保护", () => {
   it("三个真实区域均使用服务端查询和统一时间工具栏", () => {
     expect(app).toContain('ariaLabel="方案检索"');
-    expect(app).toContain('ariaLabel="直属子方案检索"');
+    expect(app).toContain('ariaLabel="子方案检索"');
     expect(app).toContain('ariaLabel="已挂载附件检索"');
     expect(app).toContain("toUpdatedTimeRange");
     expect(app).toContain("...mainRange.query");

+ 16 - 0
frontend/src/features/documents/documentApi.ts

@@ -63,6 +63,22 @@ export function createDocumentApi(client: DocumentHttpClient = httpClient) {
       }
       return result.data;
     },
+
+    async listTags(signal?: AbortSignal): Promise<string[]> {
+      const result = await client.get<unknown>("documents/tags", { signal });
+      const payload = result.data as { items?: unknown; total?: unknown } | null;
+      if (
+        !payload ||
+        typeof payload !== "object" ||
+        !Array.isArray((payload as { items?: unknown }).items) ||
+        !(payload as { items: unknown[] }).items.every(
+          (item) => typeof item === "string",
+        )
+      ) {
+        throw contractError(result, "主案标签");
+      }
+      return (payload as { items: string[] }).items;
+    },
   };
 }
 

+ 76 - 0
frontend/src/features/documents/documentState.ts

@@ -97,12 +97,17 @@ export function useDocumentList(
   enabled = true,
 ): RemotePageState<DocumentSummary> {
   const revision = useContentRevision("documents");
+  // tags 是数组,依赖比较需用稳定字符串;空数组与 undefined 都映射为空键,
+  // 避免父组件每次重渲染都传新数组引用触发无限刷新。
+  const tagsKey = query.tags && query.tags.length > 0 ? query.tags.join("\n") : "";
   return useDebouncedPage(
     (signal) => documentApi.list(query, signal),
     [
       query.documentType,
       query.categoryId,
       query.keyword,
+      tagsKey,
+      query.tagsMatch,
       query.securityLevel,
       query.visibilityType,
       query.status,
@@ -119,6 +124,77 @@ export function useDocumentList(
   );
 }
 
+export interface RemoteTagsState {
+  data: string[] | null;
+  loading: boolean;
+  error: HttpError | null;
+  retry: () => void;
+}
+
+const initialTagsState = (): RemoteTagsState => ({
+  data: null,
+  loading: true,
+  error: null,
+  retry: () => undefined,
+});
+
+/**
+ * 加载左侧标签筛选条所需的全部主案标签。
+ *
+ * - 后端已按可见性过滤,前端无需再做权限处理;
+ * - 失败时静默到空数组,避免拖垮主案列表的可用性(标签条仅是辅助筛选)。
+ */
+export function useDocumentTags(enabled = true): RemoteTagsState {
+  const [state, setState] = useState<RemoteTagsState>(initialTagsState);
+  const requestVersion = useRef(0);
+  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();
+    setState((current) => ({ ...current, loading: true, error: null, retry }));
+    void documentApi
+      .listTags(controller.signal)
+      .then((tags) => {
+        if (version === requestVersion.current) {
+          setState({ data: tags, loading: false, error: null, retry });
+        }
+      })
+      .catch((error: unknown) => {
+        if (version !== requestVersion.current || controller.signal.aborted) {
+          return;
+        }
+        setState({
+          data: null,
+          loading: false,
+          retry,
+          error:
+            error instanceof HttpError
+              ? error
+              : new HttpError({
+                  httpStatus: 0,
+                  code: "INTERNAL_ERROR",
+                  message: "请求失败",
+                  details: null,
+                  requestId: "unknown",
+                  cause: error,
+                }),
+        });
+      });
+    return () => {
+      controller.abort();
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [enabled, retryVersion]);
+
+  return { ...state, retry };
+}
+
 export function useSubPlanList(
   mainPlanId: string | null,
   query: SubPlanListQuery,

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

@@ -41,6 +41,7 @@ export interface DocumentSummary {
   rowVersion: number;
   allowedActions: AllowedActionCode[];
   mountedPlanCount?: number;
+  contentSnippet?: string | null;
 }
 
 export interface DocumentDetail extends DocumentSummary {
@@ -53,6 +54,7 @@ export interface DocumentDetail extends DocumentSummary {
     userCount: number;
     inheritedFromMainPlan: boolean;
   };
+  contentText?: string | null;
 }
 
 export interface MainPlanBrief {
@@ -72,6 +74,8 @@ export interface DocumentListQuery {
   categoryId?: Id;
   includeDescendants?: boolean;
   keyword?: string;
+  tags?: string[];
+  tagsMatch?: "any" | "all";
   securityLevel?: SecurityLevelCode;
   visibilityType?: VisibilityTypeCode;
   status?: DocumentStatusCode;
@@ -153,7 +157,8 @@ export function isDocumentSummary(value: unknown): value is DocumentSummary {
     utcPattern.test(value.updatedAt) &&
     isInteger(value.rowVersion) &&
     hasOnlyAllowedActions(value.allowedActions) &&
-    (!("mountedPlanCount" in value) || isInteger(value.mountedPlanCount))
+    (!("mountedPlanCount" in value) || isInteger(value.mountedPlanCount)) &&
+    (!("contentSnippet" in value) || value.contentSnippet === null || value.contentSnippet === undefined || typeof value.contentSnippet === "string")
   );
 }
 
@@ -169,7 +174,8 @@ export function isDocumentDetail(value: unknown): value is DocumentDetail {
     isRecord(permission) &&
     isInteger(permission.organizationCount) &&
     isInteger(permission.userCount) &&
-    typeof permission.inheritedFromMainPlan === "boolean"
+    typeof permission.inheritedFromMainPlan === "boolean" &&
+    (!("contentText" in value) || value.contentText === null || value.contentText === undefined || typeof value.contentText === "string")
   );
 }
 

+ 109 - 24
frontend/src/features/files/FilePreviewModal.tsx

@@ -1,10 +1,14 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
 import { Download, FileWarning, RefreshCw, X } from "lucide-react";
 import { renderAsync } from "docx-preview";
 import { HttpError } from "../../shared/api/errors";
-import { AllowedAction } from "../../shared/api/enums";
+import { AllowedAction, DocumentType } from "../../shared/api/enums";
+import { usePdfViewerEngine } from "../../shared/config/runtimeConfig";
+import { attachmentApi } from "../attachments/attachmentApi";
 import type { DocumentSummary } from "../documents/documentTypes";
-import { acquirePreview, createPreviewUrl, downloadFile, revokeObjectUrl } from "./fileState";
+import { documentApi } from "../documents/documentApi";
+import { acquirePreview, downloadFile } from "./fileState";
+import { PdfViewer } from "./PdfViewer";
 
 const PDF_MIME = "application/pdf";
 const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
@@ -53,21 +57,69 @@ export function FilePreviewModal({ item, onClose, mode = "side" }: {
   onClose: () => void;
   mode?: "side" | "modal";
 }) {
-  const [url, setUrl] = useState<string | null>(null);
+  const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
   const [error, setError] = useState<HttpError | null>(null);
   const [loading, setLoading] = useState(true);
   const [downloading, setDownloading] = useState(false);
   const [retryVersion, setRetryVersion] = useState(0);
+  const [contentText, setContentText] = useState<string | null>(null);
+  const [contentLoading, setContentLoading] = useState(false);
   const docxContainer = useRef<HTMLDivElement>(null);
   const requestVersion = useRef(0);
   const canDownload = item.allowedActions.includes(AllowedAction.DOWNLOAD);
+  const pdfViewerEngine = usePdfViewerEngine();
+  const isModal = mode === "modal";
+
+  // iframe 兜底需要对象 URL;pdfjs 模式直接消费 Blob。
+  // 这里按 Blob 生命周期派生 URL 并在卸载/切换时释放,避免内存泄漏。
+  const pdfUrl = useMemo(
+    () => (pdfBlob && pdfViewerEngine === "iframe" ? URL.createObjectURL(pdfBlob) : null),
+    [pdfBlob, pdfViewerEngine],
+  );
+  useEffect(() => {
+    if (!pdfUrl) return;
+    return () => URL.revokeObjectURL(pdfUrl);
+  }, [pdfUrl]);
+
+  // modal 模式下额外拉取 detail 以展示提取文本(contentText 仅在 detail DTO 暴露)。
+  // side 模式宽度不足以分栏,跳过拉取以节省请求。
+  useEffect(() => {
+    setContentText(null);
+    setContentLoading(false);
+    if (!isModal) return;
+    const controller = new AbortController();
+    setContentLoading(true);
+    const isAttachment = item.documentType === DocumentType.ATTACHMENT;
+    const request = isAttachment
+      ? attachmentApi.detail(item.id, controller.signal)
+      : documentApi.detail(item.id, controller.signal);
+    request
+      .then((detail) => {
+        if (!controller.signal.aborted) setContentText(detail.contentText ?? null);
+      })
+      .catch(() => {
+        // 静默失败:右侧降级为"暂无正文"
+      })
+      .finally(() => {
+        if (!controller.signal.aborted) setContentLoading(false);
+      });
+    return () => controller.abort();
+  }, [item.id, item.documentType, isModal]);
+
+  // 按空行(≥1 个空行)分段;过滤纯空白段。
+  const paragraphs = useMemo(() => {
+    if (!contentText) return [];
+    return contentText
+      .split(/\n\s*\n+/)
+      .map((segment) => segment.trim())
+      .filter((segment) => segment.length > 0);
+  }, [contentText]);
 
   useEffect(() => {
     const version = ++requestVersion.current;
     const preview = acquirePreview(item.id, `${item.rowVersion}:${item.updatedAt}:${retryVersion}`);
-    let objectUrl: string | null = null;
     let responseRequestId = "unknown";
-    setUrl(null);
+    setPdfBlob(null);
     setError(null);
     setLoading(true);
     if (docxContainer.current) docxContainer.current.replaceChildren();
@@ -79,12 +131,8 @@ export function FilePreviewModal({ item, onClose, mode = "side" }: {
       const extension = item.fileExtension.trim().toLowerCase();
       // 1.6 预览:PDF/DOC/DOCX 统一返回 application/pdf;XLS/XLSX 在后端返回 415
       if ((extension === "pdf" || extension === "doc" || extension === "docx") && mime === PDF_MIME) {
-        objectUrl = createPreviewUrl(result.blob);
-        if (version !== requestVersion.current) {
-          revokeObjectUrl(objectUrl);
-          return;
-        }
-        setUrl(objectUrl);
+        if (version !== requestVersion.current) return;
+        setPdfBlob(result.blob);
         return;
       }
       if (extension === "docx") {
@@ -116,7 +164,6 @@ export function FilePreviewModal({ item, onClose, mode = "side" }: {
     return () => {
       requestVersion.current += 1;
       preview.release();
-      revokeObjectUrl(objectUrl);
       if (docxContainer.current) docxContainer.current.replaceChildren();
     };
   }, [item.id, item.rowVersion, item.updatedAt, retryVersion, mode]);
@@ -133,11 +180,54 @@ export function FilePreviewModal({ item, onClose, mode = "side" }: {
     }
   };
 
+  const previewChildren = (
+    <>
+      {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-sm font-black" style={{ color: "#1A5299", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
+      {pdfBlob && pdfViewerEngine === "pdfjs" && <PdfViewer blob={pdfBlob} documentName={item.documentName} />}
+      {pdfUrl && pdfViewerEngine === "iframe" && (
+        <iframe
+          src={`${pdfUrl}#view=FitH`}
+          title={item.documentName}
+          className="w-full h-full border-0 bg-white"
+        />
+      )}
+      <div ref={docxContainer} className={`q3-docx-preview min-h-full overflow-auto [&_.docx-wrapper]:!bg-[#d8e8f5] [&_section.docx]:!shadow-sm ${mode === "side" ? "[&_.docx-wrapper]:!p-3 [&_section.docx]:!max-w-full" : "[&_.docx-wrapper]:!p-8"}`} />
+      {error && <div className="p-8 text-center" style={{ color: "#1A5299" }}>
+        <FileWarning size={38} className="mx-auto mb-3 opacity-60" />
+        <div className="text-sm font-black leading-6">{errorText(error, item.fileExtension)}</div>
+        {error.requestId && <div className="text-xs font-bold opacity-60 mt-2">错误编号:{error.requestId}</div>}
+        {error.httpStatus !== 401 && <button onClick={() => setRetryVersion((value) => value + 1)} className="mt-4 px-3 py-1.5 text-xs font-black border" style={{ borderColor: "#3191eb" }}>重试预览</button>}
+      </div>}
+    </>
+  );
+
+  const textPanel = (
+    <aside className="w-[360px] flex-shrink-0 flex flex-col border-l bg-white min-h-0" style={{ borderColor: "rgba(40,118,193,.2)" }}>
+      <div className="px-4 py-3 text-sm font-black flex-shrink-0" style={{ color: "#1A5299", borderBottom: "1px solid rgba(40,118,193,.15)" }}>
+        提取文本
+      </div>
+      <div className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-2.5">
+        {contentLoading && <div className="text-xs font-bold opacity-60">正在加载正文…</div>}
+        {!contentLoading && paragraphs.length === 0 && (
+          <div className="text-xs font-bold opacity-60">暂无正文</div>
+        )}
+        {paragraphs.map((paragraph, index) => (
+          <div key={index} className="rounded-sm px-2.5 py-2" style={{ background: "rgba(40,118,193,.04)" }}>
+            <div className="text-xs leading-6 whitespace-pre-wrap break-words" style={{ color: "#1A5299" }}>{paragraph}</div>
+            <div className="mt-1.5 text-[10px] font-black opacity-50" style={{ color: "#1A5299" }}>
+              第 {index + 1} 段 · 共 {paragraph.length} 字
+            </div>
+          </div>
+        ))}
+      </div>
+    </aside>
+  );
+
   const panel = (
     <aside
       aria-label={mode === "modal" ? "完整文档查看" : "文档右侧预览"}
       className={mode === "modal"
-        ? "w-[min(1100px,92vw)] h-[90vh] flex flex-col bg-white shadow-2xl min-h-0"
+        ? "w-[min(1400px,95vw)] h-[90vh] flex flex-col bg-white shadow-2xl min-h-0"
         : "w-[400px] max-w-[28vw] min-w-[360px] flex-shrink-0 flex flex-col border-l bg-white min-h-0"}
       style={{ borderColor: "rgba(40,118,193,.2)" }}
     >
@@ -148,16 +238,11 @@ export function FilePreviewModal({ item, onClose, mode = "side" }: {
         </div>
         <button aria-label="关闭预览" onClick={onClose}><X size={17} /></button>
       </div>
-      <div className="flex-1 min-h-0 overflow-hidden relative" style={{ background: "#EBF4FD" }}>
-        {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-sm font-black" style={{ color: "#1A5299", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
-        {url && <iframe title={`${item.documentName} PDF预览`} src={`${url}#zoom=125`} className="w-full h-full border-0" />}
-        <div ref={docxContainer} className={`q3-docx-preview min-h-full overflow-auto [&_.docx-wrapper]:!bg-[#d8e8f5] [&_section.docx]:!shadow-sm ${mode === "side" ? "[&_.docx-wrapper]:!p-3 [&_section.docx]:!max-w-full" : "[&_.docx-wrapper]:!p-8"}`} />
-        {error && <div className="p-8 text-center" style={{ color: "#1A5299" }}>
-          <FileWarning size={38} className="mx-auto mb-3 opacity-60" />
-          <div className="text-sm font-black leading-6">{errorText(error, item.fileExtension)}</div>
-          {error.requestId && <div className="text-xs font-bold opacity-60 mt-2">错误编号:{error.requestId}</div>}
-          {error.httpStatus !== 401 && <button onClick={() => setRetryVersion((value) => value + 1)} className="mt-4 px-3 py-1.5 text-xs font-black border" style={{ borderColor: "#3191eb" }}>重试预览</button>}
-        </div>}
+      <div className={`flex-1 min-h-0 overflow-hidden relative ${isModal ? "flex flex-row" : ""}`} style={{ background: "#EBF4FD" }}>
+        {isModal ? (
+          <div className="flex-1 min-w-0 relative overflow-hidden">{previewChildren}</div>
+        ) : previewChildren}
+        {isModal && textPanel}
       </div>
       {canDownload && <button disabled={downloading} onClick={() => void download()} className="m-3 px-3 py-2 text-sm font-black text-white disabled:opacity-60" style={{ background: "#3191eb" }}><Download size={14} className="inline mr-2" />{downloading ? "正在下载…" : "下载原文件"}</button>}
     </aside>

+ 141 - 0
frontend/src/features/files/PdfViewer.tsx

@@ -0,0 +1,141 @@
+import { useEffect, useRef, useState } from "react";
+import {
+  GlobalWorkerOptions,
+  getDocument,
+  type PDFDocumentLoadingTask,
+  type RenderTask,
+} from "pdfjs-dist";
+// Vite 通过 ?url 后缀把 worker 文件作为 URL 资源返回,保证打包后路径正确。
+import workerUrl from "pdfjs-dist/build/pdf.worker.min.js?url";
+
+GlobalWorkerOptions.workerSrc = workerUrl;
+
+type RenderStatus = "loading" | "done" | "error";
+
+/**
+ * 自托管 PDF 渲染组件。
+ *
+ * 设计要点:
+ * - 适应宽度:用 `containerWidth / pageWidth` 计算缩放比,按容器宽度全宽渲染。
+ * - 单页填不满 → 上下居中:靠 flex 子项的 `margin: auto` 实现。
+ *   flex 的内置行为是:内容超出容器时 `margin: auto` 退化为 0(不会让顶部被裁剪),
+ *   因此多页或刚好填满时自然顶对齐,无需额外判断。
+ */
+export function PdfViewer({ blob, documentName }: { blob: Blob; documentName: string }) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const wrapperRef = useRef<HTMLDivElement>(null);
+  const [status, setStatus] = useState<RenderStatus>("loading");
+  const [errorMessage, setErrorMessage] = useState("");
+
+  useEffect(() => {
+    let cancelled = false;
+    let loadingTask: PDFDocumentLoadingTask | null = null;
+    let renderTask: RenderTask | null = null;
+
+    const render = async () => {
+      try {
+        setStatus("loading");
+        const arrayBuffer = await blob.arrayBuffer();
+        if (cancelled) return;
+
+        loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) });
+        const pdf = await loadingTask.promise;
+        if (cancelled) return;
+
+        const container = containerRef.current;
+        const wrapper = wrapperRef.current;
+        if (!container || !wrapper) return;
+
+        // 等浏览器完成布局,避免读到 clientWidth=0。
+        await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
+        if (cancelled) return;
+
+        const containerWidth = Math.floor(container.clientWidth);
+        if (containerWidth <= 0) {
+          throw new Error("预览容器宽度为 0,无法渲染 PDF");
+        }
+
+        wrapper.replaceChildren();
+
+        for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
+          if (cancelled) return;
+          const page = await pdf.getPage(pageNum);
+          const baseViewport = page.getViewport({ scale: 1 });
+          const scale = containerWidth / baseViewport.width;
+          const viewport = page.getViewport({ scale });
+
+          const canvas = document.createElement("canvas");
+          canvas.width = Math.floor(viewport.width);
+          canvas.height = Math.floor(viewport.height);
+          canvas.style.width = `${Math.floor(viewport.width)}px`;
+          canvas.style.height = `${Math.floor(viewport.height)}px`;
+          canvas.style.display = "block";
+          canvas.style.background = "#fff";
+          canvas.style.boxShadow = "0 1px 4px rgba(0,0,0,.15)";
+          canvas.setAttribute("aria-label", `${documentName} 第 ${pageNum} 页`);
+
+          const ctx = canvas.getContext("2d");
+          if (!ctx) continue;
+
+          renderTask = page.render({ canvasContext: ctx, viewport });
+          await renderTask.promise;
+          renderTask = null;
+          if (cancelled) return;
+
+          wrapper.appendChild(canvas);
+        }
+
+        if (!cancelled) setStatus("done");
+      } catch (e) {
+        if (cancelled) return;
+        setStatus("error");
+        setErrorMessage(e instanceof Error ? e.message : "PDF 加载失败");
+      }
+    };
+
+    void render();
+
+    return () => {
+      cancelled = true;
+      if (renderTask) {
+        try {
+          renderTask.cancel();
+        } catch {
+          /* ignore */
+        }
+      }
+      if (loadingTask) {
+        try {
+          void loadingTask.destroy();
+        } catch {
+          /* ignore */
+        }
+      }
+    };
+  }, [blob, documentName]);
+
+  return (
+    <div
+      ref={containerRef}
+      className="w-full h-full overflow-auto flex flex-col"
+      style={{ background: "#d8e8f5" }}
+    >
+      <div
+        ref={wrapperRef}
+        className="flex flex-col items-center my-auto"
+        style={{ gap: "12px", paddingTop: "12px", paddingBottom: "12px" }}
+      />
+      {status === "loading" && (
+        <div
+          className="text-center text-sm font-black py-6"
+          style={{ color: "#1A5299" }}
+        >
+          正在渲染 PDF…
+        </div>
+      )}
+      {status === "error" && (
+        <div className="text-center text-sm py-6 text-red-600">{errorMessage}</div>
+      )}
+    </div>
+  );
+}

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

@@ -71,7 +71,7 @@ describe("Q3-F 1.4 business alignment", () => {
   it("USER页面不挂独立共享附件库且仍挂载已关联附件", () => {
     expect(mainPage).not.toContain("<F4AttachmentLibrary");
     expect(mainPage).toContain("useMountedAttachments");
-    expect(mainPage).toContain("已挂载共享附件");
+    expect(mainPage).toContain("配套资料");
   });
 
   it("USER页面无继承权限按钮,管理员显示只读继承说明", () => {
@@ -109,10 +109,10 @@ describe("Q3-F right preview", () => {
     expect(preview).toContain('className="fixed inset-0 z-[80] flex items-center justify-center"');
   });
 
-  it("PDF使用iframe和Object URL并在清理时撤销", () => {
+  it("PDF通过自托管PdfViewer渲染并卸载时释放资源", () => {
     expect(preview).toContain("mime === PDF_MIME");
-    expect(preview).toContain("<iframe");
-    expect(preview).toContain("revokeObjectUrl(objectUrl)");
+    expect(preview).toContain("<PdfViewer");
+    expect(preview).toContain('from "./PdfViewer"');
   });
 
   it("DOCX严格校验MIME、arrayBuffer并调用renderAsync", () => {
@@ -148,7 +148,7 @@ describe("Q3-F right preview", () => {
   });
 
   it("401之外可重试且错误不会保留上一文档内容", () => {
-    expect(preview).toContain("setUrl(null)");
+    expect(preview).toContain("setPdfBlob(null)");
     expect(preview).toContain("error.httpStatus !== 401");
     expect(preview).toContain("重试预览");
   });

+ 75 - 0
frontend/src/shared/config/runtimeConfig.ts

@@ -0,0 +1,75 @@
+import { useEffect, useState } from "react";
+import { httpClient } from "../api/httpClient";
+
+/**
+ * 前端可读的后端运行时配置。
+ *
+ * 这些配置由后端 ``.env`` 决定,不涉及敏感信息,
+ * 通过 ``GET /api/v1/config/runtime`` 暴露。
+ * 为了避免每个组件都重复请求,这里维护一个模块级缓存。
+ */
+export interface RuntimeConfig {
+  /** PDF 预览渲染策略:``iframe`` 使用浏览器内置预览器,``pdfjs`` 使用自托管 pdfjs-dist。 */
+  pdfViewerEngine: "iframe" | "pdfjs";
+}
+
+const DEFAULT_CONFIG: RuntimeConfig = { pdfViewerEngine: "iframe" };
+
+let inflight: Promise<RuntimeConfig> | null = null;
+let cached: RuntimeConfig | null = null;
+
+function normalizeConfig(value: unknown): RuntimeConfig {
+  if (!value || typeof value !== "object") return DEFAULT_CONFIG;
+  const engine = (value as { pdfViewerEngine?: unknown }).pdfViewerEngine;
+  return {
+    pdfViewerEngine:
+      engine === "pdfjs" || engine === "iframe" ? engine : "iframe",
+  };
+}
+
+/**
+ * 加载运行时配置;同进程内只发起一次请求并缓存结果。
+ * 失败时静默回退到默认值,确保离线/降级时仍可使用 iframe 兜底。
+ */
+export function loadRuntimeConfig(): Promise<RuntimeConfig> {
+  if (cached) return Promise.resolve(cached);
+  if (inflight) return inflight;
+  inflight = httpClient
+    .get<RuntimeConfig>("config/runtime", { auth: false })
+    .then((result) => {
+      cached = normalizeConfig(result.data);
+      return cached;
+    })
+    .catch(() => {
+      cached = DEFAULT_CONFIG;
+      return cached;
+    })
+    .finally(() => {
+      inflight = null;
+    });
+  return inflight;
+}
+
+/** 仅用于测试:重置模块级缓存。 */
+export function __resetRuntimeConfigForTests(): void {
+  cached = null;
+  inflight = null;
+}
+
+/**
+ * 在 React 组件中订阅 PDF 预览引擎。
+ * 默认返回 ``iframe``,待运行时配置加载完成后切换。
+ */
+export function usePdfViewerEngine(): "iframe" | "pdfjs" {
+  const [engine, setEngine] = useState<"iframe" | "pdfjs">("iframe");
+  useEffect(() => {
+    let cancelled = false;
+    void loadRuntimeConfig().then((config) => {
+      if (!cancelled) setEngine(config.pdfViewerEngine);
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+  return engine;
+}

+ 78 - 0
prompts.md

@@ -0,0 +1,78 @@
+我在 Windows 环境中。我需要打一个 exe 文件,将 python 依赖打进去,让用户避免手动 pip install 依赖。此外,我还需要将前端生成的 dist 打进去,前后端一体启动。探索项目,给出可行方案。
+
+---
+
+我复制了一份新的代码过来。请确认前后端是否可以一体启动。如果不可以,对app.py做相同修改。最后,还是创建ps1脚本,用于打exe文件。
+
+---
+
+我需要在 Mysql 中创建 dms_app 用户,密码是“asdqwe123!”;还要创建 dms 数据库,与 dms_app 用户关联。将建库和建用户语句写入 bak/database/dms_database_0003.sql
+
+---
+
+dist-package/dms-server 下,执行 dms-server.exe ,然后访问系统。报错:sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError) (1045, "Access denied for user 'dms_app'@'123.118.6.154' (using password: YES)")
+
+---
+
+所有文档,点开以后,右侧预览界面都是空白。我已经配置了 LibreOffice 的路径。浏览器报错:
+:9345/api/v1/documents/3/preview:1 
+Failed to load resource: the server responded with a status of 404 (NOT FOUND)
+
+---
+
+查看 database/dms_data_clean_0003.sql,并查看 dist-package/dms-server/dms-storage 下的文件,看是否可以对上。
+
+---
+
+1. 点击某个文档后,右侧预览窗口,默认适应宽度;
+2. 如果适应宽度后,文档只有1页,填不满预览区域高度,则让它上下居中。否则,样式不变。
+
+---
+
+页面里,“直属子方案”标签页改为“子方案”,“已挂载共享附件”改为“配套资料”。
+
+---
+
+Uncaught ReferenceError: Iterator is not defined
+    at index-B9mNHSvn.js:319:15989
+
+---
+
+"已挂载附件"也修改为“配套资料”;代码内部也进行修改吧
+
+---
+
+pdf 显示,恢复之前的方案,不要用 pdfjs 了。然后只做默认适应宽度即可,不再要求上下居中。
+但 pdfjs 保留。 在 .env 中增加一个配置,选择使用哪个方案。
+
+---
+
+1. 将当前所有的标签,显示在左侧分类树下方,换行排列,点击对应标签,可筛选出对应的主方案;
+2. 针对全文检索,实现基于关键词进行标题+概述+标签+内容检索的统一检索接口。
+
+---
+
+build.ps1 中,增加一步:打包完成后,将 dist-package-bak 中的 .env 和 dm-storage 复制到 dist-package 中。
+
+我希望搜索结果高亮,标题中则标题对应部分高亮,概述和标签也是一样。
+特别地:如果检索命中了正文,那么这一条记录在表格中,高度向下延伸一半,延伸出的部分高亮显示正文命中区域,并将命中部分前后的一部分文字显示出来。如果前后显示不全,增加显示 “……”。
+
+---
+
+点击左侧标签,提示:服务器内部错误
+
+---
+
+目前搜索不到正文。是不是没做文本解析和存储?
+
+---
+
+检索结果的正文部分,高度可以再高一些,“正文”两个字可以缩进再大一些,与后面实际内容间隔也可以再大一些;可以不要那个浅色的背景。
+
+---
+
+检索结果,正文实际内容与表格行的“标题”一列左对齐。各行序号,与“序号”表头居中对齐;“正文”二字,也与序号居中对齐。
+
+---
+
+点击“查看”按钮后,弹出的对话框,除原有文件预览区域外,右侧展示提取文本,按照换行分隔,分段展示。每段可以写一些基本信息上去。

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików