Bläddra i källkod

1. 实现了 PDF 检索文内高亮,pdfjs 按命中坐标叠加高亮块(当前命中深橙、其余淡黄),跨页全局序号与提取文本面板联动跳转,命中跨行按行拆分矩形;
2. 完成了后端 Python 与 Java 双版的达梦数据库适配,涵盖 LIKE ESCAPE、TO_CHAR 周归一、JSON_CONTAINS 改写、死锁重试与 dmPython 驱动引入;
3. 修复了 PDF 正文解析标题与正文粘连问题,双版同步引入相邻行主字体签名突变切分,并以标题行 ≥2 字符守卫防误切,补充单元测试;
4. 修复了构建脚本的示例数据库导出调用,按工具契约补齐 --env-file/--storage 参数并导出至临时目录取 SQL,重打包时保留用户 .env 不再丢失;
5. 实现了“小/中/大”字号联动顶级分类页签,绕开 theme.css 对 button 的显式字号规则改为内联指定;
6. 更新了prompts.md需求记录。

weisijie 1 månad sedan
förälder
incheckning
f6685e369a

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

@@ -55,7 +55,7 @@ public interface AttachmentBindingMapper {
     @Select("<script>SELECT b.main_document_id FROM doc_attachment_binding b "
             + "JOIN doc_document d ON d.id = b.attachment_document_id "
             + "WHERE b.is_deleted = 0 AND d.document_type = 'ATTACHMENT' AND d.is_deleted = 0 "
-            + "AND d.document_name LIKE #{pattern} ESCAPE '\\\\' AND b.main_document_id IN "
+            + "AND d.document_name LIKE #{pattern} ESCAPE '\\' AND b.main_document_id IN "
             + "<foreach collection='mainIds' item='m' open='(' separator=',' close=')'>"
             + "#{m}</foreach></script>")
     List<Long> listHitMainIds(@Param("mainIds") List<Long> mainIds,

+ 26 - 11
backend-java/src/main/java/com/dms/mapper/AuditLogMapper.java

@@ -25,12 +25,12 @@ public interface AuditLogMapper {
             + "<if test='q.operationResult != null'>AND operation_result = #{q.operationResult}</if>"
             + "<if test='q.targetType != null'>AND target_type = #{q.targetType}</if>"
             + "<if test='q.keyword != null'>"
-            + "AND (username LIKE #{q.keyword} ESCAPE '\\\\'"
-            + " OR real_name LIKE #{q.keyword} ESCAPE '\\\\'"
-            + " OR organization_name LIKE #{q.keyword} ESCAPE '\\\\'"
-            + " OR target_name LIKE #{q.keyword} ESCAPE '\\\\'"
-            + " OR failure_reason LIKE #{q.keyword} ESCAPE '\\\\'"
-            + " OR request_id LIKE #{q.keyword} ESCAPE '\\\\')</if>";
+            + "AND (username LIKE #{q.keyword} ESCAPE '\\'"
+            + " OR real_name LIKE #{q.keyword} ESCAPE '\\'"
+            + " OR organization_name LIKE #{q.keyword} ESCAPE '\\'"
+            + " OR target_name LIKE #{q.keyword} ESCAPE '\\'"
+            + " OR failure_reason LIKE #{q.keyword} ESCAPE '\\'"
+            + " OR request_id LIKE #{q.keyword} ESCAPE '\\')</if>";
 
     /** 时间窗过滤片段(q: createdFrom/createdTo)。 */
     String TIME_FILTER =
@@ -65,15 +65,30 @@ public interface AuditLogMapper {
     long countTodayViewers(@Param("start") LocalDateTime start,
                            @Param("end") LocalDateTime end);
 
-    /** 对齐 _period_expression:DAY/MONTH 直取格式化,WEEK 归一到周一。 */
-    @Select("<script>SELECT "
-            + "<choose>"
+    /** 对齐 _period_expression:DAY/MONTH 直取格式化,WEEK 归一到周一。
+     *  达梦无 FROM_DAYS 且不支持 GROUP BY 列别名:dm 分支用 TO_CHAR/TRUNC('IW')
+     *  (与 MySQL FROM_DAYS(TO_DAYS-WEEKDAY) 周一语义一致),分组排序均按表达式。 */
+    String DM_PERIOD_EXPR =
+            "<choose>"
+            + "<when test=\"q.granularity == 'DAY'\">TO_CHAR(created_at, 'YYYY-MM-DD')</when>"
+            + "<when test=\"q.granularity == 'MONTH'\">TO_CHAR(created_at, 'YYYY-MM')</when>"
+            + "<otherwise>TO_CHAR(TRUNC(created_at, 'IW'), 'YYYY-MM-DD')</otherwise>"
+            + "</choose>";
+
+    String MYSQL_PERIOD_EXPR =
+            "<choose>"
             + "<when test=\"q.granularity == 'DAY'\">DATE_FORMAT(created_at, '%Y-%m-%d')</when>"
             + "<when test=\"q.granularity == 'MONTH'\">DATE_FORMAT(created_at, '%Y-%m')</when>"
             + "<otherwise>DATE_FORMAT(FROM_DAYS(TO_DAYS(created_at) - WEEKDAY(created_at)), '%Y-%m-%d')</otherwise>"
-            + "</choose>"
+            + "</choose>";
+
+    String PERIOD_EXPR = "<choose><when test='q.dm'>" + DM_PERIOD_EXPR
+            + "</when><otherwise>" + MYSQL_PERIOD_EXPR + "</otherwise></choose>";
+
+    @Select("<script>SELECT " + PERIOD_EXPR
             + " AS period, COUNT(id) AS operationCount FROM sys_audit_log WHERE 1=1 "
-            + TIME_FILTER + " GROUP BY period ORDER BY period ASC</script>")
+            + TIME_FILTER + " GROUP BY " + PERIOD_EXPR + " ORDER BY " + PERIOD_EXPR
+            + " ASC</script>")
     List<Map<String, Object>> listTrendPeriods(@Param("q") Map<String, Object> filters);
 
     /** 对齐 action_statistics:按 action_type 分组,count 降序、action 升序。 */

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

@@ -93,20 +93,20 @@ public interface DocumentMapper {
             + "<if test='parentIds != null'>AND parent_document_id IN "
             + "<foreach collection='parentIds' item='p' open='(' separator=',' close=')'>"
             + "#{p}</foreach></if>"
-            + "<if test='titlePattern != null'>AND document_name LIKE #{titlePattern} ESCAPE '\\\\'</if>"
-            + "<if test='contentPattern != null'>AND (content_text LIKE #{contentPattern} ESCAPE '\\\\' "
-            + "OR search_text LIKE #{contentPattern} ESCAPE '\\\\')</if>"
+            + "<if test='titlePattern != null'>AND document_name LIKE #{titlePattern} ESCAPE '\\'</if>"
+            + "<if test='contentPattern != null'>AND (content_text LIKE #{contentPattern} ESCAPE '\\' "
+            + "OR search_text LIKE #{contentPattern} ESCAPE '\\')</if>"
             + "<if test='securityLevels != null'>AND security_level IN "
             + "<foreach collection='securityLevels' item='l' open='(' separator=',' close=')'>"
             + "#{l}</foreach></if>"
-            + "<if test='orgPattern != null'>AND organization_name LIKE #{orgPattern} ESCAPE '\\\\'</if>"
+            + "<if test='orgPattern != null'>AND organization_name LIKE #{orgPattern} ESCAPE '\\'</if>"
             + "<if test='attachmentType != null'>AND attachment_type = #{attachmentType}</if>"
             + "<if test='fileExtension != null'>AND file_extension = #{fileExtension}</if>"
             + "<if test='tags != null'>AND (<choose>"
             + "<when test='tagsMatch == \"all\"'>"
-            + "<foreach collection='tags' item='tg' separator=' AND '>JSON_CONTAINS(tags, #{tg})"
+            + "<foreach collection='tags' item='tg' separator=' AND '>tags LIKE #{tg} ESCAPE '\\'"
             + "</foreach></when><otherwise>"
-            + "<foreach collection='tags' item='tg' separator=' OR '>JSON_CONTAINS(tags, #{tg})"
+            + "<foreach collection='tags' item='tg' separator=' OR '>tags LIKE #{tg} ESCAPE '\\'"
             + "</foreach></otherwise></choose>)</if>"
             + "<if test='securityLevel != null'>AND security_level = #{securityLevel}</if>"
             + "<if test='status != null'>AND document_status = #{status}</if>"
@@ -403,9 +403,9 @@ public interface DocumentMapper {
             + "<if test='f.documentType != null'>AND document_type = #{f.documentType}</if>"
             + "<if test='f.categoryId != null'>AND category_id = #{f.categoryId}</if>"
             + "<if test='f.keywordPattern != null'>AND (document_name LIKE #{f.keywordPattern} "
-            + "ESCAPE '\\\\' OR summary LIKE #{f.keywordPattern} ESCAPE '\\\\' "
-            + "OR search_text LIKE #{f.keywordPattern} ESCAPE '\\\\' "
-            + "OR original_file_name LIKE #{f.keywordPattern} ESCAPE '\\\\')</if>"
+            + "ESCAPE '\\' OR summary LIKE #{f.keywordPattern} ESCAPE '\\' "
+            + "OR search_text LIKE #{f.keywordPattern} ESCAPE '\\' "
+            + "OR original_file_name LIKE #{f.keywordPattern} ESCAPE '\\')</if>"
             + "<if test='f.deletedFrom != null'>AND deleted_at &gt;= #{f.deletedFrom}</if>"
             + "<if test='f.deletedTo != null'>AND deleted_at &lt; #{f.deletedTo}</if>"
             + " ORDER BY ${f.orderBy}, id ${f.idOrder} "
@@ -419,9 +419,9 @@ public interface DocumentMapper {
             + "<if test='f.documentType != null'>AND document_type = #{f.documentType}</if>"
             + "<if test='f.categoryId != null'>AND category_id = #{f.categoryId}</if>"
             + "<if test='f.keywordPattern != null'>AND (document_name LIKE #{f.keywordPattern} "
-            + "ESCAPE '\\\\' OR summary LIKE #{f.keywordPattern} ESCAPE '\\\\' "
-            + "OR search_text LIKE #{f.keywordPattern} ESCAPE '\\\\' "
-            + "OR original_file_name LIKE #{f.keywordPattern} ESCAPE '\\\\')</if>"
+            + "ESCAPE '\\' OR summary LIKE #{f.keywordPattern} ESCAPE '\\' "
+            + "OR search_text LIKE #{f.keywordPattern} ESCAPE '\\' "
+            + "OR original_file_name LIKE #{f.keywordPattern} ESCAPE '\\')</if>"
             + "<if test='f.deletedFrom != null'>AND deleted_at &gt;= #{f.deletedFrom}</if>"
             + "<if test='f.deletedTo != null'>AND deleted_at &lt; #{f.deletedTo}</if>"
             + "</script>")

+ 2 - 0
backend-java/src/main/java/com/dms/service/AuditQueryService.java

@@ -140,6 +140,8 @@ public class AuditQueryService {
         Map<String, Object> filters = Payloads.orderedMap();
         applyTimeFilters(params, filters);
         filters.put("granularity", granularity);
+        // 达梦无 FROM_DAYS:mapper 按 dm 标志切换 TO_CHAR/TRUNC 周期表达式。
+        filters.put("dm", properties.jdbcUrl().startsWith("jdbc:dm:"));
         List<Map<String, Object>> rows = auditLogMapper.listTrendPeriods(filters);
         List<Map<String, Object>> items = new ArrayList<>();
         for (Map<String, Object> row : rows) {

+ 87 - 15
backend-java/src/main/java/com/dms/service/DocumentContentService.java

@@ -29,7 +29,7 @@ import org.springframework.stereotype.Service;
  * (Python fitz/PyMuPDF → PDFBox,python-docx → POI)。
  *
  * <p>阶段10同步:① PDF正文按"版面块"识别段落(块边界即段落边界,块内合并
- * 段内软换行,相邻行主字号突变亦视为段落边界);② 整页为逐行等距同字号的
+ * 段内软换行,相邻行主字号或主字体签名突变亦视为段落边界);② 整页为逐行等距同字号的
  * 退化网格(扫描件文本层典型特征)时回退纯文本启发式合并;③ 提取结果携带
  * 脱敏失败原因(content_extract_error),供管理员"编辑"界面排障与重新解析。</p>
  */
@@ -138,12 +138,48 @@ public class DocumentContentService {
     // ------------------------------------------------------------ PDF 提取(版面块段落化)
 
     /**
-     * 块内相邻行主字号相对差达到该比例时视为段落边界(标题与正文同块的兜底)。
+     * 块内相邻行主字号相对差达到该比例、或主字体签名不同时视为段落边界
+     * (标题与正文同块的兜底:公文字号体系下黑体/楷体标题与仿宋正文同字号,
+     * 仅字体家族或加粗不同,字号差信号失灵,需字体信号互补)。
      * 注:PDFBox 的字形高度(getHeightDir)较 fitz 的字号度量压缩了相对差异
      * (实测16pt/11pt标题正文 ≈ 0.195),故取 0.15 以对齐 Python 版 0.2 的效果。
      */
     private static final double FONT_SIZE_SPLIT_RATIO = 0.15;
 
+    /** 字体名中的风格 token:剥离后不参与家族比较(加粗位单独保留在签名中)。 */
+    private static final List<String> FONT_STYLE_TOKENS = List.of("bold", "italic", "oblique");
+
+    /**
+     * 对齐 _font_signature:剥离子集前缀与风格 token 的家族名(小写)+ 加粗位,
+     * 如 "ABCDEE+Calibri-Bold" → "calibri|b"。返回空串表示无字体信息。
+     * 加粗位仅取字体名 token(PDFBox 无 fitz span flags 的加粗位)。
+     */
+    static String fontSignature(String raw) {
+        if (raw == null || raw.isEmpty()) {
+            return "";
+        }
+        String cleaned = raw.replaceFirst("^[A-Za-z]{6}\\+", "");
+        String[] parts = cleaned.split("[-,]");
+        StringBuilder family = new StringBuilder();
+        boolean bold = false;
+        for (String part : parts) {
+            if (part.isEmpty()) {
+                continue;
+            }
+            String token = part.toLowerCase(Locale.ROOT);
+            if (token.equals("bold")) {
+                bold = true;
+            }
+            if (!FONT_STYLE_TOKENS.contains(token)) {
+                family.append(token);
+            }
+        }
+        if (family.isEmpty()) {
+            return "";
+        }
+        return bold ? family + "|b" : family.toString();
+    }
+
     /** 退化版面判定:逐行等距网格的间距容差(pt)与字号容差(pt)。 */
     private static final double DEGENERATE_GAP_TOLERANCE = 2.0;
     private static final double DEGENERATE_SIZE_TOLERANCE = 0.5;
@@ -161,15 +197,18 @@ public class DocumentContentService {
     private static final Pattern SENTENCE_END = Pattern.compile(
             "[。!?!?;;::…..\u201d\u2019」』))\\]}>》]+$");
 
-    /** 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章")。 */
+    /**
+     * 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章" "一、")。
+     * 数字含全角0-9,分隔符含全角句点"."(公文"1."式三级标题)。
+     */
     private static final Pattern LIST_START = Pattern.compile(
-            "^(?:\\d{1,3}[.、))]"
-                    + "|[((][一二三四五六七八九十百\\d]{1,4}[))]"
+            "^(?:[0-90-9]{1,3}[.、)).]"
+                    + "|[((][一二三四五六七八九十百0-90-9]{1,4}[))]"
                     + "|[一二三四五六七八九十]{1,3}、"
-                    + "|第[一二三四五六七八九十百千\\d]{1,5}[章节条部分篇])");
+                    + "|第[一二三四五六七八九十百千0-90-9]{1,5}[章节条部分篇])");
 
-    /** 版面行:(文本, 主字号, bbox顶, bbox底)。 */
-    private record LineItem(String text, double mainSize, double top, double bottom) {
+    /** 版面行:(文本, 主字号, 主字体签名, bbox顶, bbox底)。 */
+    private record LineItem(String text, double mainSize, String font, double top, double bottom) {
     }
 
     /** 版面块:(行列表, bbox顶, bbox底)。 */
@@ -179,13 +218,21 @@ public class DocumentContentService {
     /**
      * 逐字符收集版面行(对齐 fitz page.get_text("dict") 的 lines 结构):
      * PDFTextStripper 按 sortByPosition 顺序回调 writeString,
-     * writeLineSeparator 处收行;行主字号取行内非空白字符的最大高度。
+     * writeLineSeparator 处收行;行主字号取行内非空白字符的最大高度,
+     * 行主字体签名按非空白字符数取众数(对齐 _line_style 的加权口径,
+     * 避免行首少量西文数字导致正文的行主字体来回漂移)。
      */
     private static final class LineCollector extends PDFTextStripper {
 
         private final List<LineItem> lines = new ArrayList<>();
         private final StringBuilder current = new StringBuilder();
         private final List<Double> sizes = new ArrayList<>();
+        /** 签名权重:保持插入顺序以对齐 Python max(首次插入者优先)。 */
+        private final java.util.LinkedHashMap<String, Integer> fontWeights =
+                new java.util.LinkedHashMap<>();
+        /** 原始字体名 → 签名缓存:每页字体种类有限,避免逐字形重算。 */
+        private final java.util.HashMap<String, String> signatureCache =
+                new java.util.HashMap<>();
         private double top = Double.MAX_VALUE;
         private double bottom = -Double.MAX_VALUE;
 
@@ -203,6 +250,10 @@ public class DocumentContentService {
                 current.append(unicode);
                 if (!unicode.isBlank()) {
                     sizes.add((double) position.getHeightDir());
+                    String rawFont = position.getFont().getName();
+                    String signature = signatureCache.computeIfAbsent(
+                            rawFont == null ? "" : rawFont, DocumentContentService::fontSignature);
+                    fontWeights.merge(signature, 1, Integer::sum);
                 }
                 double y = position.getYDirAdj();
                 double height = position.getHeightDir();
@@ -230,10 +281,19 @@ public class DocumentContentService {
                 for (double size : sizes) {
                     mainSize = Math.max(mainSize, size);
                 }
-                lines.add(new LineItem(text, mainSize, top, bottom));
+                String dominantFont = "";
+                int dominantWeight = 0;
+                for (var entry : fontWeights.entrySet()) {
+                    if (entry.getValue() > dominantWeight) {
+                        dominantFont = entry.getKey();
+                        dominantWeight = entry.getValue();
+                    }
+                }
+                lines.add(new LineItem(text, mainSize, dominantFont, top, bottom));
             }
             current.setLength(0);
             sizes.clear();
+            fontWeights.clear();
             top = Double.MAX_VALUE;
             bottom = -Double.MAX_VALUE;
         }
@@ -351,20 +411,30 @@ public class DocumentContentService {
         return maxSize - minSize <= DEGENERATE_SIZE_TOLERANCE;
     }
 
-    /** 对齐 _merge_line_items:条目编号/字号突变/句末标点切分,其余按软换行合并。 */
+    /**
+     * 对齐 _merge_line_items:条目编号/字号或主字体签名突变/句末标点切分,其余按软换行合并。
+     * 字号/字体为空表示无版面信息,不参与比较;字体切分要求前一行至少 2 个字符——
+     * 单字符行如项目符号"•"不可能是标题,避免符号与条目文本被字体差异拆开。
+     */
     private static List<String> mergeLineItems(List<LineItem> items) {
         List<String> paragraphs = new ArrayList<>();
         StringBuilder buffer = new StringBuilder();
         double previousSize = 0.0;
+        String previousFont = "";
+        String previousText = "";
         for (LineItem item : items) {
             String text = item.text();
             double size = item.mainSize();
+            String font = item.font();
             boolean split = false;
             if (!buffer.isEmpty() && LIST_START.matcher(text).find()) {
                 split = true;
-            } else if (!buffer.isEmpty() && previousSize > 0 && size > 0
-                    && Math.abs(size - previousSize) / Math.max(size, previousSize)
-                    >= FONT_SIZE_SPLIT_RATIO) {
+            } else if (!buffer.isEmpty()
+                    && (previousSize > 0 && size > 0
+                            && Math.abs(size - previousSize) / Math.max(size, previousSize)
+                            >= FONT_SIZE_SPLIT_RATIO
+                    || !previousFont.isEmpty() && !font.isEmpty() && !font.equals(previousFont)
+                            && previousText.length() >= 2)) {
                 split = true;
             }
             if (buffer.isEmpty()) {
@@ -377,6 +447,8 @@ public class DocumentContentService {
                 joinLines(buffer, text);
             }
             previousSize = size;
+            previousFont = font;
+            previousText = text;
             Matcher endMatcher = SENTENCE_END.matcher(buffer);
             if (!buffer.isEmpty() && endMatcher.find()) {
                 paragraphs.add(buffer.toString());
@@ -415,7 +487,7 @@ public class DocumentContentService {
                 paragraphs.addAll(mergeLineItems(group));
                 group = new ArrayList<>();
             } else {
-                group.add(new LineItem(line, 0.0, 0.0, 0.0));
+                group.add(new LineItem(line, 0.0, "", 0.0, 0.0));
             }
         }
         paragraphs.addAll(mergeLineItems(group));

+ 4 - 1
backend-java/src/main/java/com/dms/service/DocumentQueryService.java

@@ -689,7 +689,10 @@ public class DocumentQueryService {
         }
         List<String> encoded = new ArrayList<>();
         for (String tag : tags) {
-            encoded.add(encodeTag(tag));
+            // 达梦无 JSON_CONTAINS:对 JSON 编码后的标签(含引号)做 LIKE 精确包含,
+            // escapeLike 处理 %、_ 与 JSON 转义产生的反斜杠。
+            encoded.add("%" + encodeTag(tag).replace("\\", "\\\\")
+                    .replace("%", "\\%").replace("_", "\\_") + "%");
         }
         filters.put("tags", encoded);
         filters.put("tagsMatch", match);

+ 9 - 3
backend/dms/backfill_document_content.py

@@ -33,13 +33,19 @@ def _database_gate() -> None:
     except Exception as exc:
         raise RuntimeError("DMS_DATABASE_URL不是有效数据库连接配置") from exc
     allowed = {"dms", "dms_test"}
-    if configured_database not in allowed:
+    if configured_database.lower() not in allowed:
         raise RuntimeError(
             f"正文回填命令只允许连接 {' 或 '.join(sorted(allowed))},"
             f"当前指向 {configured_database!r}"
         )
-    database_name = db.session.scalar(text("SELECT DATABASE()"))
-    if database_name not in allowed:
+    # MySQL用SELECT DATABASE();达梦无此函数,改查当前SCHEMA(达梦库名为大写DMS)。
+    if db.session.get_bind().dialect.name == "dm":
+        database_name = db.session.scalar(
+            text("SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA')")
+        )
+    else:
+        database_name = db.session.scalar(text("SELECT DATABASE()"))
+    if (database_name or "").lower() not in allowed:
         raise RuntimeError(
             f"正文回填命令实际连接的库 {database_name!r} 不在允许列表内"
         )

+ 9 - 3
backend/dms/backfill_preview_cache.py

@@ -44,10 +44,16 @@ def _database_gate() -> None:
         configured_database = make_url(configured).database
     except Exception as exc:
         raise RuntimeError("DMS_DATABASE_URL不是有效数据库连接配置") from exc
-    if configured_database != "dms_test":
+    if configured_database.lower() != "dms_test":
         raise RuntimeError("预览缓存回填命令只允许连接dms_test")
-    database_name = db.session.scalar(text("SELECT DATABASE()"))
-    if database_name != "dms_test":
+    # MySQL用SELECT DATABASE();达梦无此函数,改查当前SCHEMA(达梦库名为大写DMS)。
+    if db.session.get_bind().dialect.name == "dm":
+        database_name = db.session.scalar(
+            text("SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA')")
+        )
+    else:
+        database_name = db.session.scalar(text("SELECT DATABASE()"))
+    if (database_name or "").lower() != "dms_test":
         raise RuntimeError("预览缓存回填命令只允许连接dms_test")
 
 

+ 9 - 0
backend/dms/config.py

@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import json
 import os
 from pathlib import Path
 from typing import Any
@@ -10,6 +11,11 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 import shutil
 
 
+def _json_serializer(value: Any) -> str:
+    """SQLAlchemy JSON 列序列化:原文中文(ensure_ascii=False),对齐达梦存量与 Java 版。"""
+    return json.dumps(value, ensure_ascii=False)
+
+
 
 BACKEND_ROOT = Path(__file__).resolve().parents[1]
 DEFAULT_STORAGE_ROOT = BACKEND_ROOT / "dms-storage"
@@ -116,6 +122,9 @@ def load_dms_config() -> dict[str, Any]:
             "pool_pre_ping": True,
             "pool_recycle": 1800,
             "connect_args": connect_args,
+            # JSON 列统一原文中文序列化(ensure_ascii=False),与达梦存量数据及
+            # Java 版 Jackson 写入格式一致;否则 \uXXXX 转义会导致 LIKE 检索漏配。
+            "json_serializer": _json_serializer,
         },
         "DMS_STORAGE_ROOT": str(storage_root.resolve()),
         "DMS_MAX_UPLOAD_SIZE": max_upload_size,

+ 7 - 2
backend/dms/database/transaction.py

@@ -15,6 +15,8 @@ from dms.extensions import db
 
 T = TypeVar("T")
 MYSQL_RETRYABLE_ERROR_CODES = {1205, 1213}
+# 达梦等非MySQL驱动错误码不统一,死锁/锁超时以异常消息文本兜底识别。
+DEADLOCK_MESSAGE_MARKERS = ("死锁", "deadlock", "DeadLock", "lock wait timeout")
 
 
 @contextmanager
@@ -40,7 +42,7 @@ def execute_with_deadlock_retry(
     max_attempts: int = 3,
     base_delay_seconds: float = 0.05,
 ) -> T:
-    """仅重试MySQL锁等待超时和死锁,供后续业务事务显式调用。"""
+    """重试数据库死锁与锁等待超时(MySQL错误码或消息特征),供业务事务显式调用。"""
     if max_attempts < 1:
         raise ValueError("max_attempts必须大于等于1")
 
@@ -50,7 +52,10 @@ def execute_with_deadlock_retry(
         except OperationalError as exc:
             db.session.rollback()
             error_code = exc.orig.args[0] if getattr(exc.orig, "args", ()) else None
-            if error_code not in MYSQL_RETRYABLE_ERROR_CODES or attempt == max_attempts:
+            retryable = error_code in MYSQL_RETRYABLE_ERROR_CODES or any(
+                marker in str(exc.orig) for marker in DEADLOCK_MESSAGE_MARKERS
+            )
+            if not retryable or attempt == max_attempts:
                 raise
             sleep(base_delay_seconds * attempt)
 

+ 14 - 1
backend/dms/services/audit_query_service.py

@@ -6,7 +6,7 @@ from datetime import UTC, datetime, time, timedelta
 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 from flask import current_app
-from sqlalchemy import case, func, or_, select
+from sqlalchemy import case, func, literal_column, or_, select
 from werkzeug.datastructures import MultiDict
 
 from dms.common.enums import AuditAction, AuditResult, AuditTarget
@@ -273,6 +273,19 @@ def document_statistics(params: MultiDict) -> dict:
 
 
 def _period_expression(granularity: str):
+    if db.session.get_bind().dialect.name == "dm":
+        # 达梦无 FROM_DAYS(TO_DAYS/WEEKDAY 依赖兼容模式,不跨环境):
+        # 统一用原生 TO_CHAR 格式化,WEEK 以 TRUNC(date,'IW') 归一到周一,
+        # 与 MySQL FROM_DAYS(TO_DAYS-WEEKDAY) 语义一致;格式串用 literal_column
+        # 内联字面量(探针实测形态,绑定参数在 TRUNC 第二参上类型不可靠)。
+        if granularity == "DAY":
+            return func.to_char(AuditLog.created_at, literal_column("'YYYY-MM-DD'"))
+        if granularity == "MONTH":
+            return func.to_char(AuditLog.created_at, literal_column("'YYYY-MM'"))
+        return func.to_char(
+            func.trunc(AuditLog.created_at, literal_column("'IW'")),
+            literal_column("'YYYY-MM-DD'"),
+        )
     if granularity == "DAY":
         return func.date_format(AuditLog.created_at, "%Y-%m-%d")
     if granularity == "MONTH":

+ 83 - 32
backend/dms/services/document_content_service.py

@@ -77,16 +77,49 @@ def _word_text(path: Path) -> str:
     return "\n".join(values).strip()
 
 
-# 块内相邻行主字号相对差达到该比例时视为段落边界(标题与正文同块的兜底)
+# 块内相邻行主字号相对差达到该比例、或主字体签名不同时视为段落边界
+# (标题与正文同块的兜底:公文字号体系下黑体/楷体标题与仿宋正文同字号,
+# 仅字体家族或加粗不同,字号差信号失灵,需字体信号互补)
 _FONT_SIZE_SPLIT_RATIO = 0.2
 
+# 字体名中的风格 token:剥离后不参与家族比较(加粗位单独保留在签名中)
+_FONT_STYLE_TOKENS = {"bold", "italic", "oblique"}
 
-def _line_main_size(line) -> float:
-    """取行内最大 span 字号作为该行主字号。"""
-    return max(
-        (span["size"] for span in line["spans"] if span["text"].strip()),
-        default=0.0,
-    )
+
+def _font_signature(name: str, flags: int) -> str:
+    """字体签名:剥离子集前缀与风格 token 的家族名(小写)+ 加粗位。
+
+    返回空串表示无字体信息(不参与相邻行字体比较)。
+    """
+    cleaned = re.sub(r"^[A-Za-z]{6}\+", "", name or "")
+    parts = [part for part in re.split(r"[-,]", cleaned) if part]
+    family = "".join(
+        part for part in parts if part.lower() not in _FONT_STYLE_TOKENS
+    ).lower()
+    if not family:
+        return ""
+    bold = bool(flags & 16) or any(part.lower() == "bold" for part in parts)
+    return f"{family}|b" if bold else family
+
+
+def _line_style(line) -> tuple[float, str]:
+    """行主样式:(行内最大字号, 行内占非空白字符数最多的字体签名)。
+
+    主字体按字符数加权取众数而非取首 span,避免行首少量西文数字
+    (与中文不同字体嵌入)导致正文的行主字体来回漂移。
+    """
+    main_size = 0.0
+    weights: dict[str, int] = {}
+    for span in line["spans"]:
+        text = span["text"].strip()
+        if not text:
+            continue
+        main_size = max(main_size, span["size"])
+        signature = _font_signature(span["font"], span["flags"])
+        if signature:
+            weights[signature] = weights.get(signature, 0) + len(text)
+    dominant = max(weights, key=weights.get) if weights else ""
+    return main_size, dominant
 
 
 # 退化版面判定:逐行等距网格的间距容差(pt)与字号容差(pt)
@@ -99,21 +132,24 @@ def _pdf_page_text(page) -> str:
 
     PyMuPDF 按版面邻近度将行分组为块,标题/落款等因字号或间距不同独立成块,
     无需依赖标点猜测即可与正文分开。块内沿用启发式合并(句末标点/条目编号切分,
-    相邻行字号突变亦视为段落边界)。整页为逐行等距同字号的网格(如扫描件文本层
+    相邻行字号突变或主字体突变亦视为段落边界——公文标题与正文常同字号、
+    仅字体家族或加粗不同)。整页为逐行等距同字号的网格(如扫描件文本层
     逐行定位)时块结构无分组价值,回退纯文本启发式。
     """
     data = page.get_text("dict", flags=fitz.TEXTFLAGS_TEXT)
-    # 每块:(行列表[(文本, 主字号)], bbox顶, bbox底)
-    blocks: list[tuple[list[tuple[str, float]], float, float]] = []
+    # 每块:(行列表[(文本, 主字号, 主字体签名)], bbox顶, bbox底)
+    blocks: list[tuple[list[tuple[str, float, str]], float, float]] = []
     line_count = 0
     for block in data["blocks"]:
         if block.get("type") != 0:
             continue
-        items = [
-            (text, _line_main_size(line))
-            for line in block["lines"]
-            if (text := "".join(span["text"] for span in line["spans"]).strip())
-        ]
+        items: list[tuple[str, float, str]] = []
+        for line in block["lines"]:
+            text = "".join(span["text"] for span in line["spans"]).strip()
+            if not text:
+                continue
+            size, font = _line_style(line)
+            items.append((text, size, font))
         if items:
             blocks.append((items, block["bbox"][1], block["bbox"][3]))
             line_count += len(items)
@@ -128,7 +164,7 @@ def _pdf_page_text(page) -> str:
 
 
 def _is_degenerate_line_grid(
-    blocks: list[tuple[list[tuple[str, float]], float, float]],
+    blocks: list[tuple[list[tuple[str, float, str]], float, float]],
     line_count: int,
 ) -> bool:
     """判断整页是否为"每行一块、等距同字号"的退化网格(扫描件文本层典型特征)。
@@ -160,12 +196,13 @@ def _pdf_text(path: Path) -> str:
 # 句末标点:一行以此结尾视为自然段落结束,不再与下一行合并
 _SENTENCE_END_RE = re.compile(r"[。!?!?;;::…..\"”’」』))\]}>》]+$")
 # 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章" "一、")
+# 数字含全角0-9,分隔符含全角句点"."(公文"1."式三级标题)
 _LIST_START_RE = re.compile(
     r"^(?:"
-    r"\d{1,3}[.、))]"          # 1. / 1、 / 1)
-    r"|[((][一二三四五六七八九十百\d]{1,4}[))]"  # (一) / (1)
+    r"[0-90-9]{1,3}[.、)).]"          # 1. / 1、 / 1) / 1.
+    r"|[((][一二三四五六七八九十百0-90-9]{1,4}[))]"  # (一) / (1)
     r"|[一二三四五六七八九十]{1,3}、"   # 一、
-    r"|第[一二三四五六七八九十百千\d]{1,5}[章节条部分篇]"  # 第3章 / 第五节
+    r"|第[一二三四五六七八九十百千0-90-9]{1,5}[章节条部分篇]"  # 第3章 / 第五节
     r")"
 )
 
@@ -178,29 +215,41 @@ def _join_lines(previous: str, following: str) -> str:
     return previous + following
 
 
-def _merge_line_items(items: list[tuple[str, float]]) -> list[str]:
-    """将一组行(文本、主字号)合并为段落列表。
+def _merge_line_items(items: list[tuple[str, float, str]]) -> list[str]:
+    """将一组行(文本、主字号、主字体签名)合并为段落列表。
 
     新段落(切分)的情况:
     1. 下一行以条目/章节编号开头;
-    2. 相邻行主字号相对差 ≥ _FONT_SIZE_SPLIT_RATIO(标题与正文同块的兜底,
-       字号为 0 表示无版面信息,不参与比较);
+    2. 相邻行主字号相对差 ≥ _FONT_SIZE_SPLIT_RATIO,或主字体签名不同
+       (标题与正文同块的兜底:公文字号体系下黑体/楷体标题与仿宋正文
+       同字号,仅字体家族或加粗不同;字号/字体为空表示无版面信息,
+       不参与比较。字体切分要求前一行至少 2 个字符——单字符行如项目
+       符号"•"不可能是标题,避免符号与条目文本被字体差异拆开);
     3. 当前缓冲以句末标点结尾(在追加该行后立即收尾)。
     其余情况视为段内软换行,按 _join_lines 规则合并。
     """
     paragraphs: list[str] = []
     buffer = ""
     previous_size = 0.0
-    for text, size in items:
+    previous_font = ""
+    previous_text = ""
+    for text, size, font in items:
         if buffer and _LIST_START_RE.match(text):
             paragraphs.append(buffer)
             buffer = text
-        elif (
-            buffer
-            and previous_size
-            and size
-            and abs(size - previous_size) / max(size, previous_size)
-            >= _FONT_SIZE_SPLIT_RATIO
+        elif buffer and (
+            (
+                previous_size
+                and size
+                and abs(size - previous_size) / max(size, previous_size)
+                >= _FONT_SIZE_SPLIT_RATIO
+            )
+            or (
+                previous_font
+                and font
+                and font != previous_font
+                and len(previous_text) >= 2
+            )
         ):
             paragraphs.append(buffer)
             buffer = text
@@ -209,6 +258,8 @@ def _merge_line_items(items: list[tuple[str, float]]) -> list[str]:
         else:
             buffer = text
         previous_size = size
+        previous_font = font
+        previous_text = text
         if buffer and _SENTENCE_END_RE.search(buffer):
             paragraphs.append(buffer)
             buffer = ""
@@ -227,14 +278,14 @@ def _merge_wrapped_lines(page_text: str) -> str:
     其余情况视为段内软换行,直接合并(中文不加空格,英文单词间补一个空格)。
     """
     paragraphs: list[str] = []
-    group: list[tuple[str, float]] = []
+    group: list[tuple[str, float, str]] = []
     for raw_line in page_text.splitlines():
         line = raw_line.strip()
         if not line:
             paragraphs.extend(_merge_line_items(group))
             group = []
         else:
-            group.append((line, 0.0))
+            group.append((line, 0.0, ""))
     paragraphs.extend(_merge_line_items(group))
     return "\n".join(paragraphs)
 

+ 10 - 3
backend/dms/services/document_query_service.py

@@ -7,7 +7,7 @@ import logging
 from datetime import datetime, timezone
 from typing import Any, Mapping
 
-from sqlalchemy import String, and_, cast, func, or_, select
+from sqlalchemy import String, and_, cast, or_, select
 
 from dms.common.enums import (
     AuditAction,
@@ -398,8 +398,15 @@ def _tags_filter(
     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]
+    # 达梦无 JSON_CONTAINS:对 JSON 编码后的标签(含引号边界)做 LIKE 包含。
+    # ensure_ascii=False 保持原文中文,与达梦存量数据及 Java 版写入格式一致;
+    # _escape_like 处理 %、_ 与反斜杠。
+    conditions = [
+        Document.tags.like(
+            f"%{_escape_like(json.dumps(tag, ensure_ascii=False))}%", escape="\\"
+        )
+        for tag in tags
+    ]
     combiner = and_ if match == "all" else or_
     return statement.where(combiner(*conditions))
 

+ 4 - 0
backend/requirements.txt

@@ -6,6 +6,10 @@ flask>=3.0.0
 flask-cors>=4.0.0
 Flask-SQLAlchemy>=3.1,<4.0
 SQLAlchemy>=2.0,<3.0
+# 达梦驱动(不在PyPI,需从达梦安装目录本地安装,详见 README 或 docs):
+#   uv pip install --no-deps E:/dmdbms2/drivers/python/dmSQLAlchemy/dmSQLAlchemy2.0
+#   DM_HOME=E:/dmdbms2 uv pip install E:/dmdbms2/drivers/python/dmPython
+#   并补 site-packages/dpi.py(os.add_dll_directory(r'E:\dmdbms2\bin'))与 dmPython.pth(import dpi)
 PyMySQL>=1.1,<2.0
 alembic>=1.13,<2.0
 PyYAML>=6.0,<7.0

+ 0 - 7
backend/tests/dms/conftest.py

@@ -17,13 +17,6 @@ if str(BACKEND_ROOT) not in sys.path:
 
 @pytest.fixture(scope="session")
 def flask_app():
-    # 本组用例不真正连库,仅需 SQLAlchemy 可解析的方言;达梦 dmPython
-    # 属部署期驱动(测试环境未安装),默认 DM 链接会导致引擎创建失败,
-    # 故未显式配置 DMS_DATABASE_URL 时固定注入 MySQL 占位链接。
-    os.environ.setdefault(
-        "DMS_DATABASE_URL",
-        "mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4",
-    )
     from app import app
 
     app.config.update(TESTING=True)

+ 54 - 3
backend/tests/dms/services/test_document_content_service.py

@@ -166,16 +166,67 @@ def test_pdf_degenerate_layout_falls_back_to_heuristic(tmp_path: Path):
 
 
 def test_merge_line_items_splits_on_font_size_jump():
-    items = [("方案名称", 18.0), ("本方案用于说明字号差异", 11.0)]
+    items = [("方案名称", 18.0, ""), ("本方案用于说明字号差异", 11.0, "")]
     assert _merge_line_items(items) == ["方案名称", "本方案用于说明字号差异"]
 
 
 def test_merge_line_items_merges_same_font_size():
-    items = [("段内被换行", 11.0), ("打断的中文正文", 11.0)]
+    items = [("段内被换行", 11.0, "fangsong"), ("打断的中文正文", 11.0, "fangsong")]
     assert _merge_line_items(items) == ["段内被换行打断的中文正文"]
 
 
 def test_merge_line_items_ignores_font_size_when_unavailable():
     # 字号为 0(纯文本兜底路径)不参与字号比较,仍按启发式合并
-    items = [("段内被换行", 0.0), ("打断的中文正文", 0.0)]
+    items = [("段内被换行", 0.0, ""), ("打断的中文正文", 0.0, "")]
     assert _merge_line_items(items) == ["段内被换行打断的中文正文"]
+
+
+def test_merge_line_items_splits_on_font_family_change():
+    # 公文字号体系:黑体标题与仿宋正文同字号,仅家族不同
+    items = [("一、总体要求", 16.0, "simhei"), ("为保证方案落地,制定本要求。", 16.0, "fangsong")]
+    assert _merge_line_items(items) == ["一、总体要求", "为保证方案落地,制定本要求。"]
+
+
+def test_merge_line_items_splits_on_bold_change():
+    # 同家族仅加粗不同(仿宋加粗三级标题 → 仿宋正文)
+    items = [("主要职责范围", 16.0, "fangsong|b"), ("各成员单位按照职责分工落实。", 16.0, "fangsong")]
+    assert _merge_line_items(items) == ["主要职责范围", "各成员单位按照职责分工落实。"]
+
+
+def test_merge_line_items_ignores_font_when_unavailable():
+    # 字体为空(纯文本兜底路径)不参与字体比较,仍按启发式合并
+    items = [("段内被换行", 11.0, ""), ("打断的中文正文", 11.0, "")]
+    assert _merge_line_items(items) == ["段内被换行打断的中文正文"]
+
+
+def test_merge_line_items_keeps_single_char_symbol_with_following_text():
+    # 前一行为单字符项目符号"•"(SymbolMT):不可能是标题,字体差异不切分
+    items = [("•", 11.0, "symbolmt"), ("组织机构;", 11.0, "dengxian")]
+    assert _merge_line_items(items) == ["•组织机构;"]
+
+
+def test_merge_line_items_splits_when_current_line_single_char():
+    # 前一行为多字符标题、当前行为单字符(如表头"周"):字体差异仍应切分
+    items = [("表1  工作计划", 12.0, "simhei"), ("周", 10.6, "calibri|b")]
+    assert _merge_line_items(items) == ["表1  工作计划", "周"]
+
+
+def test_font_signature_normalization():
+    from dms.services.document_content_service import _font_signature
+
+    # 子集前缀剥离 + 风格 token 剥离 + 加粗位保留
+    assert _font_signature("ABCDEE+Calibri-Bold", 0) == "calibri|b"
+    assert _font_signature("ABCDEE+Calibri-Bold", 16) == "calibri|b"
+    assert _font_signature("SimHei", 0) == "simhei"
+    assert _font_signature("DengXian", 16) == "dengxian|b"
+    assert _font_signature("", 0) == ""
+
+
+def test_list_start_matches_fullwidth_number_and_period():
+    from dms.services.document_content_service import _LIST_START_RE
+
+    assert _LIST_START_RE.match("1.主要任务")
+    assert _LIST_START_RE.match("1.主要任务")
+    assert _LIST_START_RE.match("(2)保障措施")
+    assert _LIST_START_RE.match("第3章组织指挥")
+    assert not _LIST_START_RE.match("2025 年度工作安排")

+ 55 - 11
build/build-java.ps1

@@ -52,8 +52,21 @@ if (-not (Test-Path $JarFile)) { throw "未找到打包产物: $JarFile" }
 # ---------- 3. 整理发行包 ----------
 Write-Host "`n[3/4] 整理发行包..." -ForegroundColor Yellow
 $ReleaseDir = Join-Path $Dist "dms-server"
-if (Test-Path $ReleaseDir) { Remove-Item $ReleaseDir -Recurse -Force }
-New-Item $ReleaseDir -ItemType Directory | Out-Null
+# 暂存现有 .env:删除发行目录前移出,重打包后自动还原,避免用户配置每次丢失
+$KeepEnv = Join-Path $Dist ".env.keep"
+if (Test-Path (Join-Path $ReleaseDir ".env")) {
+    Copy-Item (Join-Path $ReleaseDir ".env") $KeepEnv -Force
+}
+if (Test-Path $ReleaseDir) {
+    try {
+        Remove-Item $ReleaseDir -Recurse -Force -ErrorAction Stop
+    } catch {
+        # 目录被其他进程占用(如命令行窗口停留在该目录内):保留目录节点,清空内容
+        Write-Warning "发行目录被其他进程占用,改为清空目录内容后继续"
+        Get-ChildItem $ReleaseDir -Force | Remove-Item -Recurse -Force
+    }
+}
+New-Item $ReleaseDir -ItemType Directory -Force | Out-Null
 
 Copy-Item $JarFile $ReleaseDir -Force
 # 配置模板与 Python 版共用同一份 .env.example
@@ -66,19 +79,43 @@ Copy-Item (Join-Path $Templates "upgrade-db.sh") $ReleaseDir -Force
 Copy-Item (Join-Path $Templates "README.txt") $ReleaseDir -Force
 
 # 示例数据库 SQL(与后端语言无关,复用同一导出工具)
+# 工具三参数均必填:--env-file 提供库连接、--storage 提供文件根、--output 为输出目录
+# (产物含 full_demo_data.sql 与 storage 文件副本),仅取 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 (Join-Path $ProjectRoot "backend")
-    try {
-        python $ExportTool --output $ExportedSql 2>&1 | Out-Host
-        if (-not (Test-Path $ExportedSql)) {
-            Write-Warning "导出工具未生成 $ExportedSql,请手动准备 SQL 文件"
+    # 导出工具是 Python 版组件:env/storage 按 Python 后端目录与运行时 .env 位置查找
+    # 运行时 .env 不在 backend 目录:按优先级取最新的现有配置(暂存 > 备份 > backend)
+    $PyBackend = Join-Path $ProjectRoot "backend"
+    $ExportEnv = @(
+        $KeepEnv,
+        (Join-Path $ProjectRoot "dist-package-java-bak\dms-server\.env"),
+        (Join-Path $PyBackend ".env")
+    ) | Where-Object { Test-Path $_ } | Select-Object -First 1
+    $ExportStorage = Join-Path $PyBackend "dms-storage"
+    if (-not $ExportEnv) {
+        Write-Warning "未找到可用的 .env(暂存 .env.keep / 备份目录 / backend\.env),跳过自动导出"
+    } elseif (-not (Test-Path $ExportStorage)) {
+        Write-Warning "未找到存储目录 $ExportStorage,跳过自动导出"
+    } else {
+        Write-Host "  导出示例数据库 SQL..." -ForegroundColor DarkGray
+        $ExportTmp = Join-Path ([System.IO.Path]::GetTempPath()) "dms-demo-export"
+        if (Test-Path $ExportTmp) { Remove-Item $ExportTmp -Recurse -Force }
+        try {
+            python $ExportTool --env-file $ExportEnv --storage $ExportStorage --output $ExportTmp 2>&1 | Out-Host
+            $ExportedSource = Join-Path $ExportTmp "full_demo_data.sql"
+            if (Test-Path $ExportedSource) {
+                Copy-Item $ExportedSource $ExportedSql -Force
+                Write-Host "  已生成 database-init.sql" -ForegroundColor DarkGray
+            } else {
+                Write-Warning "导出工具未生成 full_demo_data.sql,请手动准备 SQL 文件"
+            }
+        } catch {
+            Write-Warning "导出工具执行失败:$_"
+        } finally {
+            if (Test-Path $ExportTmp) { Remove-Item $ExportTmp -Recurse -Force }
         }
-    } catch {
-        Write-Warning "导出工具执行失败:$_"
-    } finally { Pop-Location }
+    }
 } else {
     Write-Warning "未找到 tools\export_full_demo_data.py,跳过自动导出"
 }
@@ -109,6 +146,13 @@ if (-not (Test-Path $BackupDir)) {
     }
 }
 
+# 还原打包前暂存的 .env(优先级高于备份目录,始终保留用户最新配置)
+if (Test-Path $KeepEnv) {
+    Copy-Item $KeepEnv (Join-Path $ReleaseDir ".env") -Force
+    Remove-Item $KeepEnv -Force
+    Write-Host "  已保留重打包前的 .env" -ForegroundColor DarkGray
+}
+
 Write-Host "`n========== 构建完成(Java 版) ==========" -ForegroundColor Green
 Write-Host "发行包目录: $ReleaseDir" -ForegroundColor Green
 $sizeMB = [math]::Round((Get-ChildItem $ReleaseDir -Recurse | Measure-Object -Property Length -Sum).Sum / 1MB, 1)

+ 53 - 14
build/build.ps1

@@ -57,9 +57,21 @@ try {
 
 # ---------- 3. PyInstaller 打包 ----------
 Write-Host "`n[3/5] PyInstaller 打包..." -ForegroundColor Yellow
+$ReleaseDir = Join-Path $Dist "dms-server"
+# 暂存现有 .env:删除发行目录前移出,重打包后自动还原,避免用户配置每次丢失
+$KeepEnv = Join-Path $Dist ".env.keep"
+if (Test-Path (Join-Path $ReleaseDir ".env")) {
+    Copy-Item (Join-Path $ReleaseDir ".env") $KeepEnv -Force
+}
 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
+if (Test-Path $ReleaseDir) {
+    try {
+        Remove-Item $ReleaseDir -Recurse -Force -ErrorAction Stop
+    } catch {
+        # 目录被其他进程占用(如命令行窗口停留在该目录内):保留目录节点,清空内容
+        Write-Warning "发行目录被其他进程占用,改为清空目录内容后继续"
+        Get-ChildItem $ReleaseDir -Force | Remove-Item -Recurse -Force
+    }
 }
 
 Push-Location $Backend
@@ -75,27 +87,47 @@ try {
 
 # ---------- 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 文件
+# 工具三参数均必填:--env-file 提供库连接、--storage 提供文件根、--output 为输出目录
+# (产物含 full_demo_data.sql 与 storage 文件副本),仅取 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 文件"
+    # 运行时 .env 不在 backend 目录:按优先级取最新的现有配置(暂存 > 备份 > backend)
+    $ExportEnv = @(
+        $KeepEnv,
+        (Join-Path $ProjectRoot "dist-package-bak\dms-server\.env"),
+        (Join-Path $Backend ".env")
+    ) | Where-Object { Test-Path $_ } | Select-Object -First 1
+    $ExportStorage = Join-Path $Backend "dms-storage"
+    if (-not $ExportEnv) {
+        Write-Warning "未找到可用的 .env(暂存 .env.keep / 备份目录 / backend\.env),跳过自动导出"
+    } elseif (-not (Test-Path $ExportStorage)) {
+        Write-Warning "未找到存储目录 $ExportStorage,跳过自动导出"
+    } else {
+        Write-Host "  导出示例数据库 SQL..." -ForegroundColor DarkGray
+        $ExportTmp = Join-Path ([System.IO.Path]::GetTempPath()) "dms-demo-export"
+        if (Test-Path $ExportTmp) { Remove-Item $ExportTmp -Recurse -Force }
+        try {
+            # 通过当前 Python 环境运行导出工具
+            python $ExportTool --env-file $ExportEnv --storage $ExportStorage --output $ExportTmp 2>&1 | Out-Host
+            $ExportedSource = Join-Path $ExportTmp "full_demo_data.sql"
+            if (Test-Path $ExportedSource) {
+                Copy-Item $ExportedSource $ExportedSql -Force
+                Write-Host "  已生成 database-init.sql" -ForegroundColor DarkGray
+            } else {
+                Write-Warning "导出工具未生成 full_demo_data.sql,请手动准备 SQL 文件"
+            }
+        } catch {
+            Write-Warning "导出工具执行失败:$_"
+        } finally {
+            if (Test-Path $ExportTmp) { Remove-Item $ExportTmp -Recurse -Force }
         }
-    } catch {
-        Write-Warning "导出工具执行失败:$_"
-    } finally { Pop-Location }
+    }
 } else {
     Write-Warning "未找到 tools\export_full_demo_data.py,跳过自动导出"
 }
@@ -130,6 +162,13 @@ if (-not (Test-Path $BackupDir)) {
     }
 }
 
+# 还原打包前暂存的 .env(优先级高于备份目录,始终保留用户最新配置)
+if (Test-Path $KeepEnv) {
+    Copy-Item $KeepEnv (Join-Path $ReleaseDir ".env") -Force
+    Remove-Item $KeepEnv -Force
+    Write-Host "  已保留重打包前的 .env" -ForegroundColor DarkGray
+}
+
 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)

+ 24 - 3
build/deploy-java.sh

@@ -42,9 +42,30 @@ cp "$ROOT/backend/.env.example" "$RELEASE/"
 cp "$TEMPLATES/run.bat" "$TEMPLATES/run.sh" "$TEMPLATES/upgrade-db.bat" \
    "$TEMPLATES/upgrade-db.sh" "$TEMPLATES/README.txt" "$RELEASE/"
 if [ -f "$ROOT/tools/export_full_demo_data.py" ]; then
-  echo "  导出示例数据库 SQL..."
-  (cd "$ROOT/backend" && python "$ROOT/tools/export_full_demo_data.py" \
-      --output "$RELEASE/database-init.sql") || echo "警告:导出示例数据库 SQL 失败,跳过"
+  # 工具三参数均必填:--env-file 提供库连接、--storage 提供文件根、--output 为输出目录
+  # (产物含 full_demo_data.sql 与 storage 文件副本),仅取 SQL 进发行包,临时目录用完即删
+  EXPORT_ENV=""
+  for CAND in "$ROOT/backend/.env" "$ROOT/dist-package-java-bak/dms-server/.env"; do
+    if [ -f "$CAND" ]; then EXPORT_ENV="$CAND"; break; fi
+  done
+  EXPORT_STORAGE="$ROOT/backend/dms-storage"
+  if [ -z "$EXPORT_ENV" ]; then
+    echo "警告:未找到可用的 .env(backend/.env 或备份目录),跳过导出示例数据库 SQL"
+  elif [ ! -d "$EXPORT_STORAGE" ]; then
+    echo "警告:未找到存储目录 $EXPORT_STORAGE,跳过导出示例数据库 SQL"
+  else
+    echo "  导出示例数据库 SQL..."
+    EXPORT_TMP="$(mktemp -d)"
+    if python "$ROOT/tools/export_full_demo_data.py" \
+        --env-file "$EXPORT_ENV" --storage "$EXPORT_STORAGE" --output "$EXPORT_TMP" \
+        && [ -f "$EXPORT_TMP/full_demo_data.sql" ]; then
+      cp "$EXPORT_TMP/full_demo_data.sql" "$RELEASE/database-init.sql"
+      echo "  已生成 database-init.sql"
+    else
+      echo "警告:导出示例数据库 SQL 失败,跳过"
+    fi
+    rm -rf "$EXPORT_TMP"
+  fi
 fi
 
 # ---------- 4. 从备份恢复 .env 和 dms-storage ----------

+ 3 - 0
frontend/src/app/App.tsx

@@ -679,6 +679,9 @@ function CategoryPanel({ admin, selectedCategoryId, onSelectCategory, allCount,
                   {...tipProps(admin ? "按住拖动可调整顶级顺序" : undefined)}
                   className={`flex-shrink-0 rounded-l-sm font-black cursor-pointer transition-colors ${isTabActive ? "" : "hover:bg-[rgba(49,145,235,0.15)]"}`}
                   style={{
+                    // 内联字号跟随"小/中/大"切换:theme.css 对 button 有显式 font-size 规则,
+                    // 会阻断从面板根容器的继承,需在此显式指定
+                    fontSize,
                     lineHeight: 1.2,
                     padding: "10px 7px",
                     minHeight: 56,

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

@@ -188,6 +188,9 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
   const requestVersion = useRef(0);
   const pdfViewerEngine = usePdfViewerEngine();
   const isModal = mode === "modal";
+  // 决策3:查看弹窗内固定 pdfjs 渲染(文内高亮/跳转依赖其文本层坐标);
+  // side 窄栏预览无检索 UI,仍遵循运行时配置(默认 iframe)。
+  const effectivePdfEngine: "iframe" | "pdfjs" = isModal ? "pdfjs" : pdfViewerEngine;
   // 关联方案跳转:modal 模式下点击关联项后在同一弹窗内切换查看目标方案;
   // side 模式不展示关联区块,navigated 恒为 null,current 即 item。
   const [navigated, setNavigated] = useState<DocumentSummary | null>(null);
@@ -235,8 +238,8 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
   // iframe 兜底需要对象 URL;pdfjs 模式直接消费 Blob。
   // 这里按 Blob 生命周期派生 URL 并在卸载/切换时释放,避免内存泄漏。
   const pdfUrl = useMemo(
-    () => (pdfBlob && pdfViewerEngine === "iframe" ? URL.createObjectURL(pdfBlob) : null),
-    [pdfBlob, pdfViewerEngine],
+    () => (pdfBlob && effectivePdfEngine === "iframe" ? URL.createObjectURL(pdfBlob) : null),
+    [pdfBlob, effectivePdfEngine],
   );
   useEffect(() => {
     if (!pdfUrl) return;
@@ -340,7 +343,11 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
   // 正文搜索:在提取文本上做跨空白(含换行)匹配,主要解决 PDF 转换后
   // 关键词被换行符拆开导致直接 indexOf 漏匹配的问题。仅 modal 模式展示
   // 搜索 UI;side 模式下 contentText 始终为 null,hook 内部为空操作。
-  const search = useKeywordSearch(contentText, paragraphs);
+  // 决策1:pdfjs 渲染激活时以 PDF 侧实际可定位的命中数为计数权威;
+  // 提取文本面板保留自身高亮,序号与 PDF 侧共用同一 currentIndex。
+  const [pdfMatchCount, setPdfMatchCount] = useState<number | null>(null);
+  const pdfSearchActive = !!pdfBlob && effectivePdfEngine === "pdfjs";
+  const search = useKeywordSearch(contentText, paragraphs, pdfSearchActive ? pdfMatchCount : null);
   const textPanelRef = useRef<HTMLElement>(null);
 
   // 补充说明:加载/保存成功后草稿与服务端值对齐
@@ -403,6 +410,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
     if (!canView) {
       // 无权限:不发预览请求,直接给出提示(复用 403 文案路径)。
       setPdfBlob(null);
+      setPdfMatchCount(null);
       setImageBlob(null);
       setError(new HttpError({
         httpStatus: 403,
@@ -422,6 +430,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
     );
     let responseRequestId = "unknown";
     setPdfBlob(null);
+    setPdfMatchCount(null);
     setImageBlob(null);
     setError(null);
     setLoading(true);
@@ -500,8 +509,17 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
   const previewChildren = (
     <>
       {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-base font-black" style={{ color: "#1A5299", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
-      {pdfBlob && pdfViewerEngine === "pdfjs" && <PdfViewer blob={pdfBlob} documentName={current.documentName} onImageActivate={setLightboxSrc} />}
-      {pdfUrl && pdfViewerEngine === "iframe" && (
+      {pdfBlob && effectivePdfEngine === "pdfjs" && (
+        <PdfViewer
+          blob={pdfBlob}
+          documentName={current.documentName}
+          onImageActivate={setLightboxSrc}
+          searchKeyword={search.keyword}
+          currentMatchIndex={search.currentIndex}
+          onMatchCount={setPdfMatchCount}
+        />
+      )}
+      {pdfUrl && effectivePdfEngine === "iframe" && (
         <iframe
           src={`${pdfUrl}#toolbar=0&view=FitH`}
           title={current.documentName}

+ 175 - 1
frontend/src/features/files/PdfViewer.tsx

@@ -3,12 +3,14 @@ import {
   GlobalWorkerOptions,
   ImageKind,
   OPS,
+  Util,
   getDocument,
   type PDFDocumentLoadingTask,
   type PDFPageProxy,
   type PageViewport,
   type RenderTask,
 } from "pdfjs-dist";
+import { findKeywordMatches } from "./keywordSearch";
 // Vite 通过 ?url 后缀把 worker 文件作为 URL 资源返回,保证打包后路径正确。
 import workerUrl from "pdfjs-dist/build/pdf.worker.min.js?url";
 
@@ -227,6 +229,116 @@ async function renderRegionFallback(
   return out.toDataURL("image/png");
 }
 
+// ─── 文内关键词高亮 ──────────────────────────────────────────────────────────
+// 查看弹窗固定 pdfjs 渲染(见 FilePreviewModal),检索词除右侧"提取文本"
+// 面板高亮外,也在 PDF 页面上叠加高亮块并支持上/下一处跳转。
+// 文本项只作为坐标数据使用,不落可选中的 DOM 文本层,遵守全局禁复制约束。
+
+/** hasEOL 补齐用的换行符(避免源码字面量中的转义序列)。 */
+const EOL = String.fromCharCode(10);
+
+/** 单条文本项的页面像素矩形(viewport 坐标,y 向下)。 */
+interface PdfTextItem {
+  str: string;
+  hasEOL: boolean;
+  left: number;
+  top: number;
+  width: number;
+  height: number;
+}
+
+/** 当前渲染代次的页面数据:高亮计算复用,随 blob 变化整体重建。 */
+interface PdfPageData {
+  page: PDFPageProxy;
+  viewport: PageViewport;
+  pageWrap: HTMLDivElement;
+  /** 惰性提取的文本项(首次检索时填充)。 */
+  items?: PdfTextItem[];
+}
+
+interface PdfHighlightRect {
+  left: number;
+  top: number;
+  width: number;
+  height: number;
+}
+
+/** 提取一页的文本项并换算为 canvas 像素坐标(与文本层同源:tx[5] 是基线)。 */
+async function extractPageTextItems(
+  page: PDFPageProxy,
+  viewport: PageViewport,
+): Promise<PdfTextItem[]> {
+  const textContent = await page.getTextContent();
+  const items: PdfTextItem[] = [];
+  for (const raw of textContent.items) {
+    if (!("str" in raw) || !raw.str) continue;
+    const tx = Util.transform(viewport.transform, raw.transform);
+    const fontHeight = Math.hypot(tx[2], tx[3]);
+    if (fontHeight <= 0) continue;
+    items.push({
+      str: raw.str,
+      hasEOL: raw.hasEOL,
+      left: tx[4],
+      top: tx[5] - fontHeight,
+      width: raw.width * viewport.scale,
+      height: fontHeight,
+    });
+  }
+  return items;
+}
+
+/** 按字符占比切出命中在单个文本项内的子矩形(CJK 等宽近似,足够高亮定位)。 */
+function itemSliceRect(
+  item: PdfTextItem,
+  start: number,
+  end: number,
+): PdfHighlightRect | null {
+  const len = item.str.length;
+  if (len === 0) return null;
+  const from = Math.max(0, Math.min(len, start));
+  const to = Math.max(from, Math.min(len, end));
+  if (to <= from) return null;
+  return {
+    left: item.left + (from / len) * item.width,
+    top: item.top,
+    width: ((to - from) / len) * item.width,
+    height: item.height,
+  };
+}
+
+/**
+ * 计算一页内的全部命中矩形。
+ * 拼接规则与 pdf.js 查找器一致:项内直拼、项末 hasEOL 补换行;
+ * findKeywordMatches 的空白归一化使跨行关键词与"提取文本"面板语义对齐。
+ */
+function computePageMatches(
+  items: PdfTextItem[],
+  keyword: string,
+  firstGlobalIndex: number,
+): { globalIndex: number; rects: PdfHighlightRect[] }[] {
+  const parts: string[] = [];
+  const offsets: number[] = [];
+  let cursor = 0;
+  for (const item of items) {
+    offsets.push(cursor);
+    parts.push(item.str);
+    cursor += item.str.length;
+    if (item.hasEOL) {
+      parts.push(EOL);
+      cursor += 1;
+    }
+  }
+  const ranges = findKeywordMatches(parts.join(""), keyword);
+  return ranges.map((range, index) => {
+    const rects: PdfHighlightRect[] = [];
+    for (let k = 0; k < items.length; k++) {
+      const rect = itemSliceRect(items[k], range.start - offsets[k], range.end - offsets[k]);
+      if (rect) rects.push(rect);
+    }
+    return { globalIndex: firstGlobalIndex + index, rects };
+  });
+}
+
 /**
  * 自托管 PDF 渲染组件。
  *
@@ -238,16 +350,26 @@ async function renderRegionFallback(
  * - 图片点击放大:每页包一层相对定位容器,canvas 上叠透明热点,
  *   点击后把提取到的图片 dataURL 经 onImageActivate 上抛(不传则不检测,零开销)。
  */
-export function PdfViewer({ blob, documentName, onImageActivate }: {
+export function PdfViewer({ blob, documentName, onImageActivate, searchKeyword, currentMatchIndex = 0, onMatchCount }: {
   blob: Blob;
   documentName: string;
   /** 点击文档内图片时回调(收到可直接展示的 dataURL)。 */
   onImageActivate?: (src: string) => void;
+  /** 文内检索关键词:非空时在页面坐标上叠加高亮块。 */
+  searchKeyword?: string;
+  /** 当前命中序号(全局跨页,从 0 开始),用于深色突出与滚动定位。 */
+  currentMatchIndex?: number;
+  /** 命中总数上报(关键词为空/无高亮时报 null,由调用方回退到文本计数)。 */
+  onMatchCount?: (count: number | null) => void;
 }) {
   const containerRef = useRef<HTMLDivElement>(null);
   const wrapperRef = useRef<HTMLDivElement>(null);
   const [status, setStatus] = useState<RenderStatus>("loading");
   const [errorMessage, setErrorMessage] = useState("");
+  // 本代次渲染的页面数据(页面代理/视口/包裹层),供高亮层复用
+  const pageDataRef = useRef<PdfPageData[]>([]);
+  // 高亮异步构建代次:关键词/索引快速变化时丢弃过期结果
+  const highlightVersion = useRef(0);
 
   useEffect(() => {
     let cancelled = false;
@@ -278,6 +400,7 @@ export function PdfViewer({ blob, documentName, onImageActivate }: {
         }
 
         wrapper.replaceChildren();
+        pageDataRef.current = [];
 
         for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
           if (cancelled) return;
@@ -329,6 +452,7 @@ export function PdfViewer({ blob, documentName, onImageActivate }: {
             }
           }
           wrapper.appendChild(pageWrap);
+          pageDataRef.current.push({ page, viewport, pageWrap });
         }
 
         if (!cancelled) setStatus("done");
@@ -360,6 +484,56 @@ export function PdfViewer({ blob, documentName, onImageActivate }: {
     };
   }, [blob, documentName, onImageActivate]);
 
+  // ─── 文内高亮层 ──────────────────────────────────────────────────────────
+  // 关键词/当前命中变化时全量重建高亮块(文本项已缓存,重建只是纯 DOM 操作),
+  // 并把命中总数上报给弹窗作为计数权威。关键词为空时清空高亮并上报 null。
+  useEffect(() => {
+    const pages = pageDataRef.current;
+    for (const data of pages) {
+      data.pageWrap
+        .querySelectorAll("[data-pdf-highlight]")
+        .forEach((node) => node.remove());
+    }
+    const keyword = (searchKeyword ?? "").trim();
+    if (!keyword || status !== "done" || pages.length === 0) {
+      onMatchCount?.(null);
+      return;
+    }
+    const version = ++highlightVersion.current;
+    void (async () => {
+      let total = 0;
+      let currentEl: HTMLElement | null = null;
+      for (const data of pages) {
+        if (!data.items) {
+          data.items = await extractPageTextItems(data.page, data.viewport);
+        }
+        if (version !== highlightVersion.current) return;
+        const matches = computePageMatches(data.items, keyword, total);
+        total += matches.length;
+        for (const match of matches) {
+          const isCurrent = match.globalIndex === currentMatchIndex;
+          for (const rect of match.rects) {
+            const mark = document.createElement("div");
+            mark.dataset.pdfHighlight = isCurrent ? "current" : "other";
+            mark.style.cssText =
+              `position:absolute;pointer-events:none;z-index:4;` +
+              `left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px;` +
+              `mix-blend-mode:multiply;` +
+              (isCurrent
+                ? "background:rgba(255,145,0,.85);outline:1px solid rgba(200,90,0,.9);"
+                : "background:rgba(255,235,59,.6);");
+            data.pageWrap.appendChild(mark);
+            if (isCurrent && !currentEl) currentEl = mark;
+          }
+        }
+      }
+      if (version !== highlightVersion.current) return;
+      onMatchCount?.(total);
+      // 跳转:当前命中所在页可能未滚动到可视区,统一滚动居中
+      currentEl?.scrollIntoView({ block: "center" });
+    })();
+  }, [searchKeyword, currentMatchIndex, status, onMatchCount]);
+
   return (
     <div
       ref={containerRef}

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

@@ -143,6 +143,26 @@ describe("Q3-F right preview", () => {
     expect(preview).toContain('from "./PdfViewer"');
   });
 
+  it("查看弹窗固定pdfjs渲染以支持文内高亮,side预览仍遵循运行时配置", () => {
+    expect(preview).toContain('isModal ? "pdfjs" : pdfViewerEngine');
+    expect(preview).toContain("effectivePdfEngine");
+  });
+
+  it("PDF文内关键词高亮:文本层坐标换算+高亮块+命中数上报为计数权威", () => {
+    const viewer = read("features/files/PdfViewer.tsx");
+    expect(viewer).toContain("getTextContent");
+    expect(viewer).toContain("Util.transform(viewport.transform");
+    expect(viewer).toContain("dataset.pdfHighlight");
+    expect(viewer).toContain("[data-pdf-highlight]");
+    expect(viewer).toContain("findKeywordMatches");
+    expect(viewer).toContain("onMatchCount?.(total)");
+    expect(viewer).toContain('scrollIntoView({ block: "center" })');
+    expect(preview).toContain("searchKeyword={search.keyword}");
+    expect(preview).toContain("currentMatchIndex={search.currentIndex}");
+    expect(preview).toContain("onMatchCount={setPdfMatchCount}");
+    expect(preview).toContain("pdfSearchActive ? pdfMatchCount : null");
+  });
+
   it("PDF内图片可点击放大:热点检测+位图提取并复用图片遮罩", () => {
     const viewer = read("features/files/PdfViewer.tsx");
     expect(viewer).toContain("paintImageXObject");

+ 8 - 1
frontend/src/features/files/keywordSearch.tsx

@@ -159,6 +159,11 @@ export interface KeywordSearchState {
 export function useKeywordSearch(
   contentText: string | null,
   paragraphs: string[],
+  /**
+   * 命中总数外部权威(可选):PDF 文内高亮启用时以 PDF 侧实际可定位的
+   * 命中数为准(决策1),避免与提取文本面板计数不一致;null/缺省用文本计数。
+   */
+  totalOverride?: number | null,
 ): KeywordSearchState {
   const [keyword, setKeyword] = useState("");
   const [currentIndex, setCurrentIndex] = useState(0);
@@ -168,11 +173,13 @@ export function useKeywordSearch(
     [paragraphs, keyword],
   );
 
-  const totalCount = useMemo(
+  const textTotal = useMemo(
     () => paragraphMatches.reduce((acc, p) => acc + p.ranges.length, 0),
     [paragraphMatches],
   );
 
+  const totalCount = totalOverride ?? textTotal;
+
   useEffect(() => {
     setCurrentIndex(0);
   }, [keyword]);

+ 38 - 1
prompts.md

@@ -746,4 +746,41 @@ PDF 段内软换行合并,可能误伤很多应换行的区域。如标题行
 
 ---
 
-我打算把数据库切换为达梦数据库,主机不变,端口号为5236,用户名和库名都是DMS,密码不变。数据还未迁移,另一个agent在做。你来改造程序吧。原MYSQL链接保留但注释掉,用新的DM链接。
+我打算把数据库切换为达梦数据库,主机不变,端口号为5236,用户名和库名都是DMS,密码不变。数据还未迁移,另一个agent在做。你来改造程序吧。原MYSQL链接保留但注释掉,用新的DM链接。
+
+---
+
+还记得刚刚关于PDF检索的决策吗?
+PDF文内高亮三个决策点按默认建议执行:1)命中计数以PDF侧(pdfjs实际可高亮命中)为权威;2)PDF文本层不可选中(仅作高亮坐标数据,维持禁复制约束);3)查看弹窗内固定pdfjs渲染。已实现:PdfViewer叠加高亮块(当前命中深橙/其余淡黄,multiply混合),跨页全局序号与右侧"提取文本"面板联动跳转,命中跨行时按行拆多个高亮矩形。
+
+---
+
+E:\dmdbms2\drivers 下包含驱动,去把Python驱动拿过来吧
+
+---
+
+temp/sql 下,有从 MySQL sql 文件转换来的 DM sql 文件。尝试导入 ecs.bitwsj.cn:5236 实例,用户名和库名都是DMS,密码“asdqwe123!”
+
+---
+
+run-java.bat 文件中,重新打包时,不要删除原 .env 文件,否则每次都需要重新生成,每次启动都不对;run.ps1 文件中也是一样。
+
+---
+
+目前,pdf文本解析,对于标题行,还是有概率和标题后的正文识别到一起。
+
+---
+
+左侧“小”“中”“大”按钮,同样控制最左侧顶级分类Tab页签上的文字大小吧。
+
+---
+
+两个 build 脚本调用 export_full_demo_data.py 时传 --output,而工具要求 --env-file/--storage,导出会告警失败(不影响打包)。这个问题如何修改?
+
+---
+
+1. 再次确认Java的所有功能与Python一致。
+2. 我的软件即将被拿去做源代码检测和漏洞扫描。请根据常见漏洞列表和编码规范,修改完善。
+
+---
+