Просмотр исходного кода

知识库,文件名和对应字段 对比功能开发

徐滕 11 часов назад
Родитель
Сommit
39fc30fb1e

+ 564 - 0
src/main/java/com/jeeplus/common/utils/excel/utils/FileMatcher.java

@@ -0,0 +1,564 @@
+package com.jeeplus.common.utils.excel.utils;
+
+import com.jeeplus.common.config.Global;
+import com.jeeplus.common.oss.OSSClientUtil;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.text.Normalizer;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 知识库文件匹配工具
+ *
+ * 功能:读取 Excel 中的标准名称和文号,与指定目录下的文件进行匹配
+ * 匹配策略:文号归一化 + 名称模糊匹配,与 Python 版本逻辑一致
+ *
+ * 依赖:Apache POI (poi + poi-ooxml)
+ *
+ * Maven 依赖:
+ * <dependency>
+ *     <groupId>org.apache.poi</groupId>
+ *     <artifactId>poi</artifactId>
+ *     <version>5.2.5</version>
+ * </dependency>
+ * <dependency>
+ *     <groupId>org.apache.poi</groupId>
+ *     <artifactId>poi-ooxml</artifactId>
+ *     <version>5.2.5</version>
+ * </dependency>
+ */
+public class FileMatcher {
+
+    // ==================== 数据模型 ====================
+
+    /** Excel 中的一条记录 */
+    public static class ExcelEntry {
+        public int row;            // Excel 行号
+        public String node;        // 所属节点名称
+        public String name;        // 标准名称
+        public String docNum;      // 文号
+
+        public ExcelEntry(int row, String node, String name, String docNum) {
+            this.row = row;
+            this.node = node;
+            this.name = name;
+            this.docNum = docNum;
+        }
+    }
+
+    /** 目录中的一个文件 */
+    public static class FileInfo {
+        public String filename;    // 文件名(含扩展名)
+        public String subdir;      // 子目录名
+        public String fullPath;    // 完整路径
+
+        public FileInfo(String filename, String subdir, String fullPath) {
+            this.filename = filename;
+            this.subdir = subdir;
+            this.fullPath = fullPath;
+        }
+    }
+
+    /** 匹配结果 */
+    public static class MatchResult {
+        public ExcelEntry entry;
+        public FileInfo file;
+        public double score;
+
+        public MatchResult(ExcelEntry entry, FileInfo file, double score) {
+            this.entry = entry;
+            this.file = file;
+            this.score = score;
+        }
+    }
+
+    // ==================== 核心匹配逻辑 ====================
+
+    /**
+     * 归一化处理:统一括号、全角半角等
+     * 对应 Python 中的 normalize() 函数
+     */
+    public static String normalize(String s) {
+        if (s == null) return "";
+        // NFKC 归一化(全角转半角)
+        s = Normalizer.normalize(s, Normalizer.Form.NFKC);
+        // 统一中文方括号 〔〕 → []
+        s = s.replace('\u3014', '[').replace('\u3015', ']');
+        // 统一全角方括号 [] → []
+        s = s.replace('\uFF3B', '[').replace('\uFF3D', ']');
+        // 统一中文圆括号 () → ()
+        s = s.replace('\uFF08', '(').replace('\uFF09', ')');
+        // 全角空格 → 半角空格
+        s = s.replace('\u3000', ' ');
+        // 合并多余空格
+        s = s.replaceAll("\\s+", " ").trim();
+        return s;
+    }
+
+    /**
+     * 计算两个名称的相似度 (0.0 ~ 1.0)
+     * 对应 Python 中的 name_similarity() 函数
+     */
+    public static double nameSimilarity(String n1, String n2) {
+        // 只保留中文和字母数字
+        String clean1 = n1.replaceAll("[^\u4e00-\u9fff\\w]", "");
+        String clean2 = n2.replaceAll("[^\u4e00-\u9fff\\w]", "");
+
+        if (clean1.equals(clean2)) return 1.0;
+        if (clean1.contains(clean2) || clean2.contains(clean1)) return 0.9;
+
+        // 计算公共字符比例
+        int common = 0;
+        for (char c : clean1.toCharArray()) {
+            if (clean2.indexOf(c) >= 0) common++;
+        }
+        int total = Math.max(clean1.length(), clean2.length());
+        return total > 0 ? (double) common / total : 0;
+    }
+
+    /**
+     * 从文件名中提取名称部分(去掉文号)
+     * 对应 Python 中的 extract_name_from_filename() 函数
+     */
+    public static String extractNameFromFilename(String filename) {
+        // 去掉扩展名
+        String name = filename.replaceFirst("\\.[^.]+$", "");
+        // 去掉开头的文号模式:xxx[20xx]xxx号 或 xxx(20xx)xxx号
+        name = name.replaceFirst("^[^\\s]+\\s*[\\[\\(][^\\s]+[\\]\\)][^\\s]*号\\s*", "");
+        // 去掉中间残留的括号内容
+        name = name.replaceAll("[\\[\\(][^\\s]*[\\]\\)]", "");
+        return name.trim();
+    }
+
+    /**
+     * 对单个 Excel 条目和单个文件计算匹配分数
+     */
+    private static double calcScore(ExcelEntry entry, FileInfo file) {
+        String normNum = normalize(entry.docNum);
+        String normName = normalize(entry.name);
+        String fnNoExt = file.filename.replaceFirst("\\.[^.]+$", "");
+        String normFn = normalize(fnNoExt);
+        String fnName = normalize(extractNameFromFilename(file.filename));
+
+        double score = 0;
+
+        if (!normNum.isEmpty()) {
+            if (normFn.contains(normNum)) {
+                // 文号匹配 +50
+                score += 50;
+                // 名称相似度 *50
+                score += nameSimilarity(normName, fnName) * 50;
+            } else {
+                // 文号不完全匹配,看名称
+                double sim = nameSimilarity(normName, fnName);
+                if (sim > 0.7) {
+                    score = sim * 60;
+                }
+            }
+        } else {
+            // 没有文号,只按名称匹配
+            score = nameSimilarity(normName, fnName) * 80;
+        }
+
+        return score;
+    }
+
+    // ==================== 主匹配方法 ====================
+
+    /** 匹配分数阈值,低于此分数视为未匹配 */
+    private static final double SCORE_THRESHOLD = 60.0;
+
+    /**
+     * 执行匹配
+     *
+     * @param entries   Excel 中读取的条目列表
+     * @param files     目录中扫描到的文件列表
+     * @return 匹配结果 Map:key=ExcelEntry, value=MatchResult(null 表示未匹配)
+     */
+    public static Map<ExcelEntry, MatchResult> match(List<ExcelEntry> entries, List<FileInfo> files) {
+        Map<ExcelEntry, MatchResult> results = new LinkedHashMap<>();
+
+        for (ExcelEntry entry : entries) {
+            FileInfo bestFile = null;
+            double bestScore = 0;
+
+            for (FileInfo file : files) {
+                double score = calcScore(entry, file);
+                if (score > bestScore) {
+                    bestScore = score;
+                    bestFile = file;
+                }
+            }
+
+            if (bestFile != null && bestScore >= SCORE_THRESHOLD) {
+                results.put(entry, new MatchResult(entry, bestFile, bestScore));
+            } else {
+                results.put(entry, null);
+            }
+        }
+
+        return results;
+    }
+
+    // ==================== Excel 读取 ====================
+
+    /**
+     * 从 Excel 文件读取条目
+     *
+     * @param excelPath  Excel 文件路径
+     * @param nameCol    标准名称所在列(从0开始)
+     * @param docNumCol  文号所在列(从0开始)
+     * @param nodeCol    所属节点所在列(从0开始),-1 表示没有此列
+     * @param startRow   数据起始行(从0开始,跳过表头)
+     */
+    public static List<ExcelEntry> readExcel(String excelPath, int nameCol, int docNumCol, int nodeCol, int startRow)
+            throws Exception {
+        List<ExcelEntry> entries = new ArrayList<>();
+
+        try (FileInputStream fis = new FileInputStream(excelPath);
+             Workbook wb = WorkbookFactory.create(fis)) {
+
+            Sheet sheet = wb.getSheetAt(0);
+            for (int i = startRow; i <= sheet.getLastRowNum(); i++) {
+                Row row = sheet.getRow(i);
+                if (row == null) continue;
+
+                String name = getCellString(row, nameCol);
+                if (name.isEmpty()) continue;
+
+                String docNum = getCellString(row, docNumCol);
+                String node = nodeCol >= 0 ? getCellString(row, nodeCol) : "";
+
+                entries.add(new ExcelEntry(i + 1, node, name, docNum));
+            }
+        }
+        return entries;
+    }
+
+    private static String getCellString(Row row, int col) {
+        Cell cell = row.getCell(col);
+        if (cell == null) return "";
+        cell.setCellType(CellType.STRING); // 强制按字符串读取
+        return cell.getStringCellValue().trim();
+    }
+
+    // ==================== 目录扫描 ====================
+
+    /**
+     * 递归扫描目录中的所有文件
+     *
+     * @param dirPath 根目录路径
+     */
+    public static List<FileInfo> scanDirectory(String dirPath) throws Exception {
+        List<FileInfo> files = new ArrayList<>();
+        Path root = Paths.get(dirPath);
+
+        Files.walk(root)
+                .filter(Files::isRegularFile)
+                .forEach(path -> {
+                    String relPath = root.relativize(path).toString();
+                    String[] parts = relPath.replace("\\", "/").split("/");
+                    String subdir = parts.length > 1 ? parts[0] : "";
+                    files.add(new FileInfo(path.getFileName().toString(), subdir, path.toString()));
+                });
+
+        return files;
+    }
+
+    // ==================== 对照表 Excel 输出 ====================
+
+    /**
+     * 将匹配结果输出为对照表 Excel
+     * 列:序号 | 标准名称 | 文号 | 匹配文件路径 | 匹配百分比 | 匹配状态
+     *
+     * @param results    匹配结果
+     * @param outputPath 输出 Excel 文件路径
+     */
+    public static void writeComparisonExcel(Map<ExcelEntry, MatchResult> results, String outputPath) throws Exception {
+        try (Workbook wb = new XSSFWorkbook()) {
+            Sheet sheet = wb.createSheet("文件匹配对照表");
+
+            // 创建表头样式
+            CellStyle headerStyle = wb.createCellStyle();
+            Font headerFont = wb.createFont();
+            headerFont.setBold(true);
+            headerFont.setFontHeightInPoints((short) 11);
+            headerStyle.setFont(headerFont);
+            headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
+            headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+            headerStyle.setBorderBottom(BorderStyle.THIN);
+            headerStyle.setBorderTop(BorderStyle.THIN);
+            headerStyle.setBorderLeft(BorderStyle.THIN);
+            headerStyle.setBorderRight(BorderStyle.THIN);
+            headerStyle.setAlignment(HorizontalAlignment.CENTER);
+
+            // 创建百分比样式
+            CellStyle percentStyle = wb.createCellStyle();
+            percentStyle.setDataFormat(wb.createDataFormat().getFormat("0.00\"%\""));
+
+            // 创建匹配/未匹配状态样式
+            CellStyle matchedStyle = wb.createCellStyle();
+            Font greenFont = wb.createFont();
+            greenFont.setColor(IndexedColors.GREEN.getIndex());
+            greenFont.setBold(true);
+            matchedStyle.setFont(greenFont);
+            matchedStyle.setAlignment(HorizontalAlignment.CENTER);
+
+            CellStyle unmatchedStyle = wb.createCellStyle();
+            Font redFont = wb.createFont();
+            redFont.setColor(IndexedColors.RED.getIndex());
+            redFont.setBold(true);
+            unmatchedStyle.setFont(redFont);
+            unmatchedStyle.setAlignment(HorizontalAlignment.CENTER);
+
+            // 表头
+            String[] headers = {"序号", "标准名称", "文号", "匹配文件路径", "匹配百分比", "匹配状态"};
+            Row headerRow = sheet.createRow(0);
+            for (int i = 0; i < headers.length; i++) {
+                Cell cell = headerRow.createCell(i);
+                cell.setCellValue(headers[i]);
+                cell.setCellStyle(headerStyle);
+            }
+
+            // 设置列宽
+            sheet.setColumnWidth(0, 8 * 256);    // 序号
+            sheet.setColumnWidth(1, 50 * 256);   // 标准名称
+            sheet.setColumnWidth(2, 35 * 256);   // 文号
+            sheet.setColumnWidth(3, 70 * 256);   // 匹配文件路径
+            sheet.setColumnWidth(4, 15 * 256);   // 匹配百分比
+            sheet.setColumnWidth(5, 12 * 256);   // 匹配状态
+
+            // 数据行
+            int rowNum = 1;
+            for (Map.Entry<ExcelEntry, MatchResult> e : results.entrySet()) {
+                ExcelEntry entry = e.getKey();
+                MatchResult result = e.getValue();
+
+                Row row = sheet.createRow(rowNum++);
+
+                // 序号
+                row.createCell(0).setCellValue(entry.row - 1); // 显示原始序号(去掉表头偏移)
+
+                // 标准名称
+                row.createCell(1).setCellValue(entry.name);
+
+                // 文号
+                row.createCell(2).setCellValue(entry.docNum != null ? entry.docNum : "");
+
+                if (result != null) {
+                    // 匹配文件路径
+                    row.createCell(3).setCellValue(result.file.fullPath);
+
+                    // 匹配百分比(score 满分100)
+                    Cell percentCell = row.createCell(4);
+                    percentCell.setCellValue(result.score);
+                    percentCell.setCellStyle(percentStyle);
+
+                    // 匹配状态
+                    Cell statusCell = row.createCell(5);
+                    statusCell.setCellValue("匹配成功");
+                    statusCell.setCellStyle(matchedStyle);
+                } else {
+                    row.createCell(3).setCellValue("");
+                    Cell percentCell = row.createCell(4);
+                    percentCell.setCellValue(0);
+                    percentCell.setCellStyle(percentStyle);
+
+                    Cell statusCell = row.createCell(5);
+                    statusCell.setCellValue("未匹配");
+                    statusCell.setCellStyle(unmatchedStyle);
+                }
+            }
+
+            // 写入文件
+            try (FileOutputStream fos = new FileOutputStream(outputPath)) {
+                wb.write(fos);
+            }
+            System.out.println("对照表已输出到: " + outputPath);
+        }
+    }
+
+    // ==================== OSS 上传 ====================
+
+    /**
+     * 将匹配成功的文件上传到 OSS
+     *
+     * @param results 匹配结果
+     * @param ossDir  OSS 目标目录(如 "app-data/knowledgeBase/")
+     * @return 上传结果 Map:key=文件完整路径, value=OSS URL
+     */
+    public static Map<String, String> uploadMatchedFilesToOSS(Map<ExcelEntry, MatchResult> results, String ossDir) {
+        Map<String, String> uploadResults = new LinkedHashMap<>();
+        OSSClientUtil ossUtil = new OSSClientUtil();
+        String baseUrl = Global.getAliDownloadUrl();
+
+        for (Map.Entry<ExcelEntry, MatchResult> e : results.entrySet()) {
+            MatchResult result = e.getValue();
+            if (result == null) continue;
+
+            String localPath = result.file.fullPath;
+            String fileName = result.file.filename;
+
+            try {
+                // 使用 OSSClientUtil 上传本地文件
+                ossUtil.uploadFileSignatureOSS(localPath, ossDir, fileName);
+                String ossUrl = baseUrl + "/" + ossDir + fileName;
+                uploadResults.put(localPath, ossUrl);
+                System.out.printf("[OSS上传成功] %s -> %s%n", fileName, ossUrl);
+            } catch (Exception ex) {
+                System.err.printf("[OSS上传失败] %s : %s%n", fileName, ex.getMessage());
+                uploadResults.put(localPath, "上传失败: " + ex.getMessage());
+            }
+        }
+
+        return uploadResults;
+    }
+
+    // ==================== Web 接口适配 ====================
+
+    /**
+     * 从 InputStream 读取 Excel 条目(适配 Web 上传场景)
+     * 自动识别表头列名:标准名称、文号、所属节点名称
+     *
+     * @param inputStream Excel 文件输入流
+     * @return Excel 条目列表
+     */
+    public static List<ExcelEntry> readExcelFromStream(InputStream inputStream) throws Exception {
+        List<ExcelEntry> entries = new ArrayList<>();
+
+        try (Workbook wb = WorkbookFactory.create(inputStream)) {
+            Sheet sheet = wb.getSheetAt(0);
+
+            // 第一行是表头,自动识别列索引
+            Row headerRow = sheet.getRow(0);
+            if (headerRow == null) {
+                throw new Exception("Excel 表头为空");
+            }
+
+            int nameCol = -1, docNumCol = -1, nodeCol = -1;
+            for (int i = 0; i <= headerRow.getLastCellNum(); i++) {
+                Cell cell = headerRow.getCell(i);
+                if (cell == null) continue;
+                cell.setCellType(CellType.STRING);
+                String header = cell.getStringCellValue().trim();
+                if ("标准名称".equals(header)) {
+                    nameCol = i;
+                } else if ("文号".equals(header)) {
+                    docNumCol = i;
+                } else if ("所属节点名称".equals(header)) {
+                    nodeCol = i;
+                }
+            }
+
+            if (nameCol == -1) {
+                throw new Exception("表头中未找到【标准名称】列,请检查Excel格式");
+            }
+
+            // 从第二行开始读取数据(跳过表头)
+            for (int i = 1; i <= sheet.getLastRowNum(); i++) {
+                Row row = sheet.getRow(i);
+                if (row == null) continue;
+
+                String name = getCellString(row, nameCol);
+                if (name.isEmpty()) continue;
+
+                String docNum = docNumCol >= 0 ? getCellString(row, docNumCol) : "";
+                String node = nodeCol >= 0 ? getCellString(row, nodeCol) : "";
+
+                entries.add(new ExcelEntry(i + 1, node, name, docNum));
+            }
+        }
+        return entries;
+    }
+
+    /**
+     * 将匹配结果转换为 JSON 友好的 List<Map> 格式
+     *
+     * @param results 匹配结果
+     * @return JSON 友好的结果列表
+     */
+    public static List<Map<String, Object>> resultsToJsonList(Map<ExcelEntry, MatchResult> results) {
+        List<Map<String, Object>> list = new ArrayList<>();
+        for (Map.Entry<ExcelEntry, MatchResult> e : results.entrySet()) {
+            ExcelEntry entry = e.getKey();
+            MatchResult result = e.getValue();
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("name", entry.name);
+            item.put("docNum", entry.docNum != null ? entry.docNum : "");
+
+            if (result != null) {
+                item.put("matched", true);
+                item.put("matchedFilePath", result.file.fullPath);
+                item.put("score", result.score);
+            } else {
+                item.put("matched", false);
+                item.put("matchedFilePath", "");
+                item.put("score", 0.0);
+            }
+            list.add(item);
+        }
+        return list;
+    }
+
+    // ==================== 使用示例 ====================
+
+    public static void main(String[] args) throws Exception {
+        // ===== 配置参数(根据实际路径修改)=====
+        String excelPath = "D:\\attachment-file\\知识库导入模板.xlsx";  // Excel 文件路径
+        String dirPath   = "D:\\a_project_file";                        // 本地文件目录路径
+        String outputPath = "D:\\attachment-file\\文件匹配对照表.xlsx";   // 对照表输出路径
+        String ossDir    = "app-data/knowledgeBase/";                   // OSS 目标目录
+
+        // Excel 列配置(3列模板:A=序号, B=标准名称, C=文号)
+        int nameCol    = 1;   // B列: 标准名称
+        int docNumCol  = 2;   // C列: 文号
+        int nodeCol    = -1;  // 无所属节点列
+        int startRow   = 1;   // 第2行开始(跳过表头行,0-indexed)
+
+        // ===== 执行 =====
+        System.out.println("========== 知识库文件匹配工具 ==========\n");
+
+        System.out.println("[1/5] 正在读取 Excel...");
+        List<ExcelEntry> entries = readExcel(excelPath, nameCol, docNumCol, nodeCol, startRow);
+        System.out.println("  Excel 条目数: " + entries.size());
+
+        System.out.println("[2/5] 正在扫描目录: " + dirPath);
+        List<FileInfo> files = scanDirectory(dirPath);
+        System.out.println("  目录文件数: " + files.size());
+
+        System.out.println("[3/5] 正在匹配...");
+        Map<ExcelEntry, MatchResult> results = match(entries, files);
+
+        // ===== 输出对照表 =====
+        System.out.println("[4/5] 正在生成对照表...");
+        writeComparisonExcel(results, outputPath);
+
+        // ===== 上传 OSS =====
+        System.out.println("[5/5] 正在上传匹配文件到 OSS...");
+        Map<String, String> uploadResults = uploadMatchedFilesToOSS(results, ossDir);
+
+        // ===== 统计 =====
+        int matched = 0, unmatched = 0;
+        for (MatchResult r : results.values()) {
+            if (r != null) matched++; else unmatched++;
+        }
+
+        System.out.println("\n========== 匹配统计 ==========");
+        System.out.println("匹配成功: " + matched);
+        System.out.println("未匹配:   " + unmatched);
+        System.out.println("总计:     " + (matched + unmatched));
+        System.out.println("OSS上传成功: " + uploadResults.size());
+        System.out.println("================================");
+    }
+}

+ 1 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/service/WorkKnowledgeBaseShareService.java

@@ -1984,6 +1984,7 @@ public class WorkKnowledgeBaseShareService extends CrudService<WorkKnowledgeBase
                 shareInfo.setId(IdGen.uuid());
                 shareInfo.setId(IdGen.uuid());
                 shareInfo.setTreeNodeId(treeNodeId);
                 shareInfo.setTreeNodeId(treeNodeId);
                 shareInfo.setName(name);
                 shareInfo.setName(name);
+                shareInfo.setAuditStatus(AUDIT_STATUS_DRAFT);
                 shareInfo.preInsert();
                 shareInfo.preInsert();
                 dao.insert(shareInfo);
                 dao.insert(shareInfo);
 
 

+ 1 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/web/WorkKnowledgeBaseReadLikeController.java

@@ -227,6 +227,7 @@ public class WorkKnowledgeBaseReadLikeController extends BaseController {
             String userId = UserUtils.getUser().getId();
             String userId = UserUtils.getUser().getId();
             shareService.incrementClickCount(fileId, userId);
             shareService.incrementClickCount(fileId, userId);
             result.put("success", true);
             result.put("success", true);
+            result.put("clickCount", shareService.getClickCount(fileId));
         } catch (Exception e) {
         } catch (Exception e) {
             result.put("success", false);
             result.put("success", false);
             result.put("message", "记录点击量失败:" + e.getMessage());
             result.put("message", "记录点击量失败:" + e.getMessage());

+ 166 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/web/WorkKnowledgeBaseShareController.java

@@ -8,6 +8,7 @@ import com.jeeplus.common.utils.StringUtils;
 import com.jeeplus.common.utils.excel.ExportExcel;
 import com.jeeplus.common.utils.excel.ExportExcel;
 import com.jeeplus.common.utils.excel.ExportKnowledgeBaseExcel;
 import com.jeeplus.common.utils.excel.ExportKnowledgeBaseExcel;
 import com.jeeplus.common.utils.excel.ImportExcel;
 import com.jeeplus.common.utils.excel.ImportExcel;
+import com.jeeplus.common.utils.excel.utils.FileMatcher;
 import com.jeeplus.common.web.BaseController;
 import com.jeeplus.common.web.BaseController;
 import com.jeeplus.modules.WorkKnowledgeBase.entity.*;
 import com.jeeplus.modules.WorkKnowledgeBase.entity.*;
 import com.jeeplus.modules.WorkKnowledgeBase.service.WorkKnowledgeBasePointRuleService;
 import com.jeeplus.modules.WorkKnowledgeBase.service.WorkKnowledgeBasePointRuleService;
@@ -26,6 +27,8 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
 
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.HashSet;
@@ -33,6 +36,8 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.Set;
 import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
 
 
 /**
 /**
  * 知识库-文件信息管理 Controller
  * 知识库-文件信息管理 Controller
@@ -43,6 +48,9 @@ import java.util.Set;
 @RequestMapping(value = "${adminPath}/workKnowledgeBase/share")
 @RequestMapping(value = "${adminPath}/workKnowledgeBase/share")
 public class WorkKnowledgeBaseShareController extends BaseController {
 public class WorkKnowledgeBaseShareController extends BaseController {
 
 
+    /** 文件匹配结果缓存(替代 session,避免跨请求 session 丢失问题) */
+    private static final ConcurrentHashMap<String, Map<FileMatcher.ExcelEntry, FileMatcher.MatchResult>> FILE_MATCH_CACHE = new ConcurrentHashMap<>();
+
     @Autowired
     @Autowired
     private WorkKnowledgeBaseShareService shareService;
     private WorkKnowledgeBaseShareService shareService;
     
     
@@ -731,6 +739,131 @@ public class WorkKnowledgeBaseShareController extends BaseController {
     }
     }
 
 
     /**
     /**
+     * 文件匹配对照页面(上传Excel + 输入目录路径,自动对比文件名称)
+     */
+    @RequiresPermissions("workKnowledgeBase:share:import")
+    @RequestMapping(value = "fileMatchForm")
+    public String fileMatchForm(Model model) {
+        // 检测当前服务器操作系统
+        String osName = System.getProperty("os.name", "").toLowerCase();
+        String osType = osName.contains("win") ? "windows" : "linux";
+        model.addAttribute("osName", osType);
+        return "modules/WorkKnowledgeBase/workKnowledgeBaseShareFileMatch";
+    }
+
+    /**
+     * 执行文件匹配(上传Excel + 目录路径,返回对比结果JSON)
+     */
+    @RequiresPermissions("workKnowledgeBase:share:import")
+    @RequestMapping(value = "fileMatch", method = RequestMethod.POST)
+    @ResponseBody
+    public Map<String, Object> fileMatch(@RequestParam MultipartFile file,
+                                         @RequestParam String dirPath) {
+        Map<String, Object> result = new HashMap<>();
+        try {
+            // 1. 读取上传的 Excel
+            List<FileMatcher.ExcelEntry> entries = FileMatcher.readExcelFromStream(file.getInputStream());
+            if (entries.isEmpty()) {
+                result.put("success", false);
+                result.put("message", "Excel 中没有有效数据,请检查文件内容");
+                return result;
+            }
+
+            // 2. 扫描目录
+            List<FileMatcher.FileInfo> files = FileMatcher.scanDirectory(dirPath);
+            if (files.isEmpty()) {
+                result.put("success", false);
+                result.put("message", "目录下未找到任何文件,请检查路径:" + dirPath);
+                return result;
+            }
+
+            // 3. 执行匹配
+            Map<FileMatcher.ExcelEntry, FileMatcher.MatchResult> matchResults = FileMatcher.match(entries, files);
+
+            // 4. 转换为 JSON 格式
+            List<Map<String, Object>> jsonList = FileMatcher.resultsToJsonList(matchResults);
+
+            // 5. 统计
+            int matched = 0, unmatched = 0;
+            for (FileMatcher.MatchResult r : matchResults.values()) {
+                if (r != null) matched++; else unmatched++;
+            }
+
+            result.put("success", true);
+            result.put("results", jsonList);
+            result.put("matchedCount", matched);
+            result.put("unmatchedCount", unmatched);
+            result.put("totalCount", entries.size());
+
+            // 生成唯一ID,将匹配结果存入内存缓存,供导出使用
+            String exportId = UUID.randomUUID().toString().replace("-", "");
+            FILE_MATCH_CACHE.put(exportId, matchResults);
+            result.put("exportId", exportId);
+        } catch (Exception e) {
+            result.put("success", false);
+            result.put("message", "匹配失败:" + e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 导出文件匹配对照表(通过 exportId 从内存缓存中读取匹配结果,生成 Excel 下载)
+     */
+    @RequiresPermissions("workKnowledgeBase:share:import")
+    @RequestMapping(value = "exportFileMatchResult")
+    public void exportFileMatchResult(@RequestParam String exportId, HttpServletResponse response) {
+        try {
+            Map<FileMatcher.ExcelEntry, FileMatcher.MatchResult> matchResults = FILE_MATCH_CACHE.get(exportId);
+
+            if (matchResults == null || matchResults.isEmpty()) {
+                throw new RuntimeException("没有可导出的匹配结果,请先执行文件匹配");
+            }
+
+            // 使用项目标准 ExportExcel 工具类
+            List<String> headers = new ArrayList<>();
+            headers.add("序号");
+            headers.add("标准名称");
+            headers.add("文号");
+            headers.add("匹配文件路径");
+            headers.add("匹配百分比");
+            headers.add("匹配状态");
+
+            ExportExcel exportExcel = new ExportExcel("文件匹配对照表", headers);
+
+            int rowNum = 1;
+            for (Map.Entry<FileMatcher.ExcelEntry, FileMatcher.MatchResult> e : matchResults.entrySet()) {
+                FileMatcher.ExcelEntry entry = e.getKey();
+                FileMatcher.MatchResult result = e.getValue();
+
+                org.apache.poi.ss.usermodel.Row row = exportExcel.addRow();
+                exportExcel.addCell(row, 0, entry.row - 1);
+                exportExcel.addCell(row, 1, entry.name);
+                exportExcel.addCell(row, 2, entry.docNum != null ? entry.docNum : "");
+
+                if (result != null) {
+                    exportExcel.addCell(row, 3, result.file.fullPath);
+                    exportExcel.addCell(row, 4, String.format("%.2f%%", result.score));
+                    exportExcel.addCell(row, 5, "匹配成功");
+                } else {
+                    exportExcel.addCell(row, 3, "");
+                    exportExcel.addCell(row, 4, "0.00%");
+                    exportExcel.addCell(row, 5, "未匹配");
+                }
+                rowNum++;
+            }
+
+            String fileName = "文件匹配对照表_" + DateUtils.getDate("yyyyMMddHHmmss") + ".xlsx";
+            exportExcel.write(response, fileName).dispose();
+
+            // 导出成功后清除缓存
+            FILE_MATCH_CACHE.remove(exportId);
+        } catch (Exception e) {
+            logger.error("导出文件匹配对照表失败", e);
+            throw new RuntimeException("导出失败:" + e.getMessage(), e);
+        }
+    }
+
+    /**
      * 获取文件的所有附件列表(JSON接口,用于多文件弹窗展示)
      * 获取文件的所有附件列表(JSON接口,用于多文件弹窗展示)
      */
      */
     @RequiresPermissions(value = {"workKnowledgeBase:share:view", "workKnowledgeBase:share:edit"}, logical = org.apache.shiro.authz.annotation.Logical.OR)
     @RequiresPermissions(value = {"workKnowledgeBase:share:view", "workKnowledgeBase:share:edit"}, logical = org.apache.shiro.authz.annotation.Logical.OR)
@@ -812,6 +945,39 @@ public class WorkKnowledgeBaseShareController extends BaseController {
     }
     }
 
 
     /**
     /**
+     * 导出文件批量导入模板(固定三列:序号、标准名称、文件完整路径)
+     */
+    @RequiresPermissions("workKnowledgeBase:share:import")
+    @RequestMapping(value = "exportFileBatchTemplate")
+    public void exportFileBatchTemplate(HttpServletResponse response) {
+        try {
+            List<String> headers = new ArrayList<>();
+            headers.add("序号");
+            headers.add("标准名称");
+            headers.add("文件完整路径");
+
+            ExportExcel exportExcel = new ExportExcel("文件批量导入模板", headers);
+
+            // 添加示例数据行
+            Row row = exportExcel.addRow();
+            exportExcel.addCell(row, 0, "1");
+            exportExcel.addCell(row, 1, "示例标准名称");
+            // 根据系统环境给出路径示例
+            String osName = System.getProperty("os.name", "").toLowerCase();
+            if (osName.contains("win")) {
+                exportExcel.addCell(row, 2, "D:\\a_project_file\\基建管理\\示例文件.doc");
+            } else {
+                exportExcel.addCell(row, 2, "/attachment-file/a_project_file/基建管理/示例文件.doc");
+            }
+
+            String fileName = "文件批量导入模板_" + DateUtils.getDate("yyyyMMddHHmmss") + ".xlsx";
+            exportExcel.write(response, fileName).dispose();
+        } catch (Exception e) {
+            throw new RuntimeException("导出模板失败:" + e.getMessage(), e);
+        }
+    }
+
+    /**
      * 多页签导出:每个根节点(parent_id=0)作为一个Sheet页签
      * 多页签导出:每个根节点(parent_id=0)作为一个Sheet页签
      * 该根节点下所有子孙节点的数据全部放在同一个Sheet中,
      * 该根节点下所有子孙节点的数据全部放在同一个Sheet中,
      * 最后一列标注数据来自哪个子节点
      * 最后一列标注数据来自哪个子节点

+ 236 - 0
src/main/webapp/webpage/modules/WorkKnowledgeBase/workKnowledgeBaseShareFileMatch.jsp

@@ -0,0 +1,236 @@
+<%@ page contentType="text/html;charset=UTF-8" %>
+<%@ include file="/webpage/include/taglib.jsp"%>
+<html>
+<head>
+    <title>文件匹配对照</title>
+    <meta name="decorator" content="default"/>
+    <script type="text/javascript" src="${ctxStatic}/layui/layui.js"></script>
+    <link rel='stylesheet' type="text/css" href="${ctxStatic}/layui/css/layui.css"/>
+    <style>
+        .match-result-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        .match-result-table th { background: #f5f5f5; padding: 8px 6px; border: 1px solid #ddd; text-align: center; font-weight: bold; }
+        .match-result-table td { padding: 6px; border: 1px solid #ddd; word-break: break-all; }
+        .match-success { color: #4caf50; font-weight: bold; }
+        .match-fail { color: #f44336; font-weight: bold; }
+        .match-score-high { color: #4caf50; }
+        .match-score-mid { color: #ff9800; }
+        .match-score-low { color: #f44336; }
+        .result-container { max-height: 400px; overflow-y: auto; margin-top: 10px; }
+        .os-hint { color: #999; font-size: 12px; margin-top: 4px; }
+        .stat-bar { display: flex; gap: 20px; margin: 10px 0; font-size: 14px; }
+        .stat-bar span { padding: 4px 12px; border-radius: 3px; }
+        .stat-success { background: #e8f5e9; color: #2e7d32; }
+        .stat-fail { background: #ffebee; color: #c62828; }
+        .stat-total { background: #e3f2fd; color: #1565c0; }
+    </style>
+    <script type="text/javascript">
+        var currentOs = '${osName}';
+        var pathPlaceholder = '';
+        var currentExportId = ''; // 存储匹配结果的唯一ID
+
+        $(document).ready(function() {
+            // 根据操作系统设置路径提示和占位符
+            if (currentOs === 'windows') {
+                pathPlaceholder = '例如:D:\\a_project_file 或 D:/a_project_file';
+                $('#osHint').text('当前服务器为 Windows 系统,请输入本地绝对路径(如 D:\\xxx 或 C:/xxx)');
+            } else {
+                pathPlaceholder = '例如:/home/user/a_project_file';
+                $('#osHint').text('当前服务器为 Linux 系统,请输入本地绝对路径(如 /home/xxx)');
+            }
+            $('#dirPath').attr('placeholder', pathPlaceholder);
+
+            layui.use(['form'], function() {
+                var form = layui.form;
+                form.render();
+            });
+        });
+
+        /** 执行文件匹配 */
+        function doMatch() {
+            var fileInput = document.getElementById('matchExcelFile');
+            if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
+                parent.layer.msg('请选择Excel文件!', {icon: 5});
+                return false;
+            }
+            var dirPath = $('#dirPath').val().trim();
+            if (!dirPath) {
+                parent.layer.msg('请输入文件目录路径!', {icon: 5});
+                return false;
+            }
+
+            var formData = new FormData();
+            formData.append('file', fileInput.files[0]);
+            formData.append('dirPath', dirPath);
+
+            // 显示loading
+            $('#matchBtn').prop('disabled', true).text('匹配中...');
+            $('#resultArea').hide();
+            $('#loadingArea').show();
+
+            $.ajax({
+                url: '${ctx}/workKnowledgeBase/share/fileMatch',
+                type: 'POST',
+                data: formData,
+                processData: false,
+                contentType: false,
+                success: function(data) {
+                    $('#loadingArea').hide();
+                    $('#matchBtn').prop('disabled', false).text('开始匹配');
+                    if (data.success) {
+                        currentExportId = data.exportId || '';
+                        renderResults(data);
+                    } else {
+                        parent.layer.msg(data.message || '匹配失败', {icon: 2, time: 5000});
+                    }
+                },
+                error: function() {
+                    $('#loadingArea').hide();
+                    $('#matchBtn').prop('disabled', false).text('开始匹配');
+                    parent.layer.msg('请求异常,请重试', {icon: 2});
+                }
+            });
+            return true;
+        }
+
+        /** 渲染匹配结果 */
+        function renderResults(data) {
+            var results = data.results || [];
+            var totalMatched = data.matchedCount || 0;
+            var totalUnmatched = data.unmatchedCount || 0;
+            var totalCount = results.length;
+
+            var html = '';
+
+            // 统计栏
+            html += '<div class="stat-bar">';
+            html += '<span class="stat-total">总计: ' + totalCount + '</span>';
+            html += '<span class="stat-success">匹配成功: ' + totalMatched + '</span>';
+            html += '<span class="stat-fail">未匹配: ' + totalUnmatched + '</span>';
+            html += '</div>';
+
+            // 表格
+            html += '<div class="result-container">';
+            html += '<table class="match-result-table">';
+            html += '<thead><tr>';
+            html += '<th style="width:50px;">序号</th>';
+            html += '<th>标准名称</th>';
+            html += '<th>文号</th>';
+            html += '<th>匹配文件路径</th>';
+            html += '<th style="width:80px;">匹配度</th>';
+            html += '<th style="width:80px;">状态</th>';
+            html += '</tr></thead><tbody>';
+
+            if (results.length === 0) {
+                html += '<tr><td colspan="6" style="text-align:center;color:#999;padding:20px;">暂无数据</td></tr>';
+            } else {
+                for (var i = 0; i < results.length; i++) {
+                    var r = results[i];
+                    var scoreClass = 'match-score-low';
+                    if (r.score >= 80) scoreClass = 'match-score-high';
+                    else if (r.score >= 60) scoreClass = 'match-score-mid';
+
+                    html += '<tr>';
+                    html += '<td style="text-align:center;">' + (i + 1) + '</td>';
+                    html += '<td>' + escapeHtml(r.name) + '</td>';
+                    html += '<td>' + escapeHtml(r.docNum || '') + '</td>';
+                    if (r.matched) {
+                        html += '<td>' + escapeHtml(r.matchedFilePath || '') + '</td>';
+                        html += '<td class="' + scoreClass + '" style="text-align:center;">' + r.score.toFixed(2) + '%</td>';
+                        html += '<td class="match-success" style="text-align:center;">匹配</td>';
+                    } else {
+                        html += '<td style="color:#999;">-</td>';
+                        html += '<td style="text-align:center;color:#999;">0.00%</td>';
+                        html += '<td class="match-fail" style="text-align:center;">未匹配</td>';
+                    }
+                    html += '</tr>';
+                }
+            }
+            html += '</tbody></table>';
+            html += '</div>';
+
+            $('#resultArea').html(html).show();
+            $('#exportBtn').show();
+        }
+
+        /** 导出匹配结果到 Excel */
+        function exportResults() {
+            if (!currentExportId) {
+                parent.layer.msg('没有可导出的匹配结果,请先执行匹配', {icon: 5});
+                return;
+            }
+            var btn = $('#exportBtn');
+            if (btn.prop('disabled')) return;
+            btn.prop('disabled', true).text('导出中...');
+            // 通过 exportId 参数触发下载
+            var exportUrl = '${ctx}/workKnowledgeBase/share/exportFileMatchResult?exportId=' + encodeURIComponent(currentExportId);
+            var iframe = document.createElement('iframe');
+            iframe.style.display = 'none';
+            iframe.src = exportUrl;
+            top.document.body.appendChild(iframe);
+            // 下载启动后恢复按钮状态
+            setTimeout(function() {
+                btn.prop('disabled', false).html('<i class="glyphicon glyphicon-download-alt"></i> 导出对照表');
+                try { top.document.body.removeChild(iframe); } catch(e) {}
+            }, 8000);
+        }
+
+        function escapeHtml(text) {
+            if (!text) return '';
+            return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+        }
+    </script>
+</head>
+<body>
+<div class="single-form">
+    <div class="container">
+        <form id="matchForm" enctype="multipart/form-data" class="form-horizontal layui-form">
+            <sys:message content="${message}"/>
+            <div class="form-group layui-row first">
+                <div class="form-group-label"><h2>文件匹配对照</h2></div>
+
+                <div class="layui-item layui-col-sm12 lw6">
+                    <label class="layui-form-label"><span class="require-item">*</span>Excel文件:</label>
+                    <div class="layui-input-block">
+                        <input id="matchExcelFile" name="file" type="file" accept=".xls,.xlsx"
+                               class="form-control layui-input" style="padding: 5px;"/>
+                        <p class="help-block" style="color: #999; margin-top: 5px;">
+                            上传包含"序号、标准名称、文号"列的Excel文件
+                        </p>
+                    </div>
+                </div>
+
+                <div class="layui-item layui-col-sm12 lw6">
+                    <label class="layui-form-label"><span class="require-item">*</span>文件目录:</label>
+                    <div class="layui-input-block">
+                        <input id="dirPath" name="dirPath" type="text"
+                               class="form-control layui-input" placeholder="请输入文件目录路径"/>
+                        <p id="osHint" class="os-hint"></p>
+                    </div>
+                </div>
+            </div>
+
+            <div class="form-group layui-row" style="text-align:center; margin-top:10px;">
+                <button type="button" id="matchBtn" class="layui-btn layui-btn-sm layui-bg-blue" onclick="doMatch()">
+                    <i class="glyphicon glyphicon-search"></i> 开始匹配
+                </button>
+                <button type="button" id="exportBtn" class="layui-btn layui-btn-sm layui-bg-green" onclick="exportResults()" style="display:none;">
+                    <i class="glyphicon glyphicon-download-alt"></i> 导出对照表
+                </button>
+            </div>
+
+            <%-- 加载提示 --%>
+            <div id="loadingArea" style="display:none; text-align:center; padding:20px; color:#999;">
+                <i class="glyphicon glyphicon-refresh" style="font-size:20px; animation: spin 1s linear infinite;"></i>
+                正在匹配,请稍候...
+                <style>@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }</style>
+            </div>
+
+            <%-- 结果区域 --%>
+            <div id="resultArea" style="display:none; margin-top:10px;"></div>
+
+            <div class="form-group layui-row page-end"></div>
+        </form>
+    </div>
+</div>
+</body>
+</html>

+ 99 - 0
src/main/webapp/webpage/modules/WorkKnowledgeBase/workKnowledgeBaseShareImportBatch.jsp

@@ -0,0 +1,99 @@
+<%@ page contentType="text/html;charset=UTF-8" %>
+<%@ include file="/webpage/include/taglib.jsp"%>
+<html>
+<head>
+    <title>批量导入文件附件</title>
+    <meta name="decorator" content="default"/>
+    <script type="text/javascript" src="${ctxStatic}/layui/layui.js"></script>
+    <link rel='stylesheet' type="text/css" href="${ctxStatic}/layui/css/layui.css"/>
+    <script type="text/javascript">
+        /** 下载导入模板 */
+        function downloadTemplate() {
+            var rootId = $('#rootId').val();
+            window.location.href = '${ctx}/workKnowledgeBase/share/exportFileBatchTemplate';
+        }
+
+        function doSubmit(i) {
+            var fileInput = document.getElementById('importFile');
+            if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
+                parent.layer.msg('请选择要导入的文件!', {icon: 5});
+                return false;
+            }
+            var rootId = $('#rootId').val();
+            if (!rootId) {
+                parent.layer.msg('请选择根节点!', {icon: 5});
+                return false;
+            }
+
+            var formData = new FormData();
+            formData.append('file', fileInput.files[0]);
+            formData.append('rootId', rootId);
+
+            loading('正在导入文件,请稍等...');
+            $.ajax({
+                url: '${ctx}/workKnowledgeBase/share/importFileBatch',
+                type: 'POST',
+                data: formData,
+                processData: false,
+                contentType: false,
+                success: function(data) {
+                    closeLoading();
+                    if (data.success) {
+                        parent.layer.msg(data.message, {icon: 1, time: 5000});
+                    } else {
+                        parent.layer.msg(data.message, {icon: 2, time: 5000});
+                    }
+                },
+                error: function() {
+                    closeLoading();
+                    parent.layer.msg('导入请求异常!', {icon: 2});
+                }
+            });
+            return true;
+        }
+
+        $(document).ready(function() {
+            layui.use(['form'], function() {
+                var form = layui.form;
+                form.render();
+            });
+        });
+    </script>
+</head>
+<body>
+<div class="single-form">
+    <div class="container">
+        <form id="inputForm" enctype="multipart/form-data" class="form-horizontal layui-form">
+            <sys:message content="${message}"/>
+            <div class="form-group layui-row first">
+                <div class="form-group-label"><h2>批量导入文件附件</h2></div>
+
+                <div class="layui-item layui-col-sm12 lw6">
+                    <label class="layui-form-label"><span class="require-item">*</span>根节点:</label>
+                    <div class="layui-input-block">
+                        <select id="rootId" name="rootId" lay-verify="required" class="form-control judgment">
+                            <option value="">请选择根节点</option>
+                            <c:forEach items="${rootNodes}" var="node">
+                                <option value="${node.id}" <c:if test="${node.id == rootId}">selected</c:if>>${node.treeName}</option>
+                            </c:forEach>
+                        </select>
+                    </div>
+                </div>
+
+                <div class="layui-item layui-col-sm12 lw6">
+                    <label class="layui-form-label"><span class="require-item">*</span>导入文件:</label>
+                    <div class="layui-input-block">
+                        <input id="importFile" name="file" type="file" accept=".xls,.xlsx"
+                               class="form-control layui-input" style="padding: 5px;"/>
+                        <p class="help-block" style="color: #999; margin-top: 5px;">
+                            <span style="color:#e53935;">注意:本地路径需确保服务器上有对应文件。</span>
+                        </p>
+                    </div>
+                </div>
+            </div>
+            <div class="form-group layui-row page-end"></div>
+        </form>
+    </div>
+</div>
+</body>
+</html>

+ 117 - 49
src/main/webapp/webpage/modules/WorkKnowledgeBase/workKnowledgeBaseShareList.jsp

@@ -169,26 +169,48 @@
             });
             });
         }
         }
         
         
+        /** 更新列表中指定文件的计数值(点击量/阅读量/点赞量),不刷新页面 */
+        function updateCountInList(fileId, field, value) {
+            // 更新 DOM(通过 data 属性精准定位)
+            $('[data-' + field + '="' + fileId + '"]').each(function() {
+                $(this).text(value);
+            });
+            // 同步更新 layui 表格缓存
+            var tableData = layui.table.cache['checkboxTable'];
+            if (tableData) {
+                for (var i = 0; i < tableData.length; i++) {
+                    if (tableData[i].id === fileId) {
+                        tableData[i][field] = value;
+                        break;
+                    }
+                }
+            }
+        }
+
         /** 查看详情(带阅读记录 + 点击量递增) */
         /** 查看详情(带阅读记录 + 点击量递增) */
         function openDetailDialogWithRead(id) {
         function openDetailDialogWithRead(id) {
-            // 记录阅读行为 + 递增点击量
+            // 记录阅读行为
             $.ajax({
             $.ajax({
                 type: 'POST',
                 type: 'POST',
                 url: '${ctx}/workKnowledgeBase/readLike/recordRead',
                 url: '${ctx}/workKnowledgeBase/readLike/recordRead',
                 data: { fileId: id },
                 data: { fileId: id },
                 success: function(result) {
                 success: function(result) {
+                    if (result.success && result.readCount != null) {
+                        updateCountInList(id, 'read-count', result.readCount);
+                    }
                     // 递增点击量(与阅读记录并行,不影响主流程)
                     // 递增点击量(与阅读记录并行,不影响主流程)
                     $.ajax({
                     $.ajax({
                         type: 'POST',
                         type: 'POST',
                         url: '${ctx}/workKnowledgeBase/readLike/incrementClick',
                         url: '${ctx}/workKnowledgeBase/readLike/incrementClick',
-                        data: { fileId: id }
+                        data: { fileId: id },
+                        success: function(clickResult) {
+                            if (clickResult.success && clickResult.clickCount != null) {
+                                updateCountInList(id, 'click-count', clickResult.clickCount);
+                            }
+                        }
                     });
                     });
-                    // 无论是否首次阅读,都打开详情弹窗
+                    // 打开详情弹窗
                     openDetailDialog(id);
                     openDetailDialog(id);
-                    // 刷新列表,更新阅读量和点击量
-                    setTimeout(function() {
-                        search();
-                    }, 500);
                 },
                 },
                 error: function() {
                 error: function() {
                     // 即使记录失败也打开详情
                     // 即使记录失败也打开详情
@@ -205,12 +227,11 @@
                 url: '${ctx}/workKnowledgeBase/readLike/recordRead',
                 url: '${ctx}/workKnowledgeBase/readLike/recordRead',
                 data: { fileId: id },
                 data: { fileId: id },
                 success: function(result) {
                 success: function(result) {
+                    if (result.success && result.readCount != null) {
+                        updateCountInList(id, 'read-count', result.readCount);
+                    }
                     // 打开只读模式的详情弹窗
                     // 打开只读模式的详情弹窗
                     openQaDetailDialog(id);
                     openQaDetailDialog(id);
-                    // 刷新列表,更新阅读量
-                    setTimeout(function() {
-                        search();
-                    }, 500);
                 },
                 },
                 error: function() {
                 error: function() {
                     // 即使记录失败也打开详情
                     // 即使记录失败也打开详情
@@ -257,24 +278,28 @@
         
         
         /** 预览文件(带阅读记录 + 点击量递增) */
         /** 预览文件(带阅读记录 + 点击量递增) */
         function openPreviewWithRead(url, type, fileId) {
         function openPreviewWithRead(url, type, fileId) {
-            // 记录阅读行为 + 递增点击量
+            // 记录阅读行为
             $.ajax({
             $.ajax({
                 type: 'POST',
                 type: 'POST',
                 url: '${ctx}/workKnowledgeBase/readLike/recordRead',
                 url: '${ctx}/workKnowledgeBase/readLike/recordRead',
                 data: { fileId: fileId },
                 data: { fileId: fileId },
                 success: function(result) {
                 success: function(result) {
+                    if (result.success && result.readCount != null) {
+                        updateCountInList(fileId, 'read-count', result.readCount);
+                    }
                     // 递增点击量(与阅读记录并行,不影响主流程)
                     // 递增点击量(与阅读记录并行,不影响主流程)
                     $.ajax({
                     $.ajax({
                         type: 'POST',
                         type: 'POST',
                         url: '${ctx}/workKnowledgeBase/readLike/incrementClick',
                         url: '${ctx}/workKnowledgeBase/readLike/incrementClick',
-                        data: { fileId: fileId }
+                        data: { fileId: fileId },
+                        success: function(clickResult) {
+                            if (clickResult.success && clickResult.clickCount != null) {
+                                updateCountInList(fileId, 'click-count', clickResult.clickCount);
+                            }
+                        }
                     });
                     });
                     // 调用原有的预览方法
                     // 调用原有的预览方法
                     openPreview(url, type);
                     openPreview(url, type);
-                    // 刷新列表,更新阅读量和点击量
-                    setTimeout(function() {
-                        search();
-                    }, 500);
                 },
                 },
                 error: function() {
                 error: function() {
                     // 即使记录失败也打开预览
                     // 即使记录失败也打开预览
@@ -634,44 +659,61 @@
             });
             });
         }
         }
 
 
-        /** 点赞操作 */
-        function likeFile(fileId) {
+        /** 点赞/取消点赞通用处理(不刷新页面,直接更新按钮状态和点赞量) */
+        function toggleLike(btn, fileId, action) {
+            var $btn = $(btn);
+            $btn.css('pointer-events', 'none').css('opacity', '0.6');
+            var url = action === 'like'
+                ? '${ctx}/workKnowledgeBase/readLike/like'
+                : '${ctx}/workKnowledgeBase/readLike/cancelLike';
             $.ajax({
             $.ajax({
                 type: 'POST',
                 type: 'POST',
-                url: '${ctx}/workKnowledgeBase/readLike/like',
+                url: url,
                 data: { fileId: fileId },
                 data: { fileId: fileId },
                 success: function(data) {
                 success: function(data) {
+                    $btn.css('pointer-events', '').css('opacity', '');
                     if (data.success) {
                     if (data.success) {
-                        layer.msg('点赞成功', {icon: 1});
-                        search(); // 刷新列表
+                        if (action === 'like') {
+                            $btn.removeClass('layui-bg-red').addClass('layui-bg-orange')
+                                .html('<i class="fa fa-heart"></i> 已赞')
+                                .attr('onclick', "cancelLikeFile(this,'" + fileId + "')");
+                        } else {
+                            $btn.removeClass('layui-bg-orange').addClass('layui-bg-red')
+                                .html('<i class="fa fa-heart-o"></i> 点赞')
+                                .attr('onclick', "likeFile(this,'" + fileId + "')");
+                        }
+                        var newCount = data.likeCount || 0;
+                        updateCountInList(fileId, 'like-count', newCount);
+                        // 同步更新 liked 状态到表格缓存
+                        var tableData = layui.table.cache['checkboxTable'];
+                        if (tableData) {
+                            for (var i = 0; i < tableData.length; i++) {
+                                if (tableData[i].id === fileId) {
+                                    tableData[i].liked = (action === 'like');
+                                    break;
+                                }
+                            }
+                        }
+                        layer.msg(action === 'like' ? '点赞成功' : '已取消点赞', {icon: 1});
                     } else {
                     } else {
-                        layer.msg(data.message || '点赞失败', {icon: 2});
+                        layer.msg(data.message || '操作失败', {icon: 2});
                     }
                     }
                 },
                 },
                 error: function() {
                 error: function() {
+                    $btn.css('pointer-events', '').css('opacity', '');
                     layer.msg('请求失败,请重试', {icon: 2});
                     layer.msg('请求失败,请重试', {icon: 2});
                 }
                 }
             });
             });
         }
         }
 
 
-        /** 取消点赞操作 */
-        function cancelLikeFile(fileId) {
-            $.ajax({
-                type: 'POST',
-                url: '${ctx}/workKnowledgeBase/readLike/cancelLike',
-                data: { fileId: fileId },
-                success: function(data) {
-                    if (data.success) {
-                        layer.msg('已取消点赞', {icon: 1});
-                        search(); // 刷新列表
-                    } else {
-                        layer.msg(data.message || '取消点赞失败', {icon: 2});
-                    }
-                },
-                error: function() {
-                    layer.msg('请求失败,请重试', {icon: 2});
-                }
-            });
+        /** 点赞 */
+        function likeFile(btn, fileId) {
+            toggleLike(btn, fileId, 'like');
+        }
+
+        /** 取消点赞 */
+        function cancelLikeFile(btn, fileId) {
+            toggleLike(btn, fileId, 'cancel');
         }
         }
 
 
         /** 查询 */
         /** 查询 */
@@ -776,13 +818,17 @@
         function openImportFileBatchDialog() {
         function openImportFileBatchDialog() {
             top.layer.open({
             top.layer.open({
                 type: 2,
                 type: 2,
-                area: ['800px', '450px'],
+                area: ['1000px', '500px'],
                 title: '批量导入文件附件',
                 title: '批量导入文件附件',
                 maxmin: false,
                 maxmin: false,
                 content: '${ctx}/workKnowledgeBase/share/importFileBatchForm?rootId=${rootId}',
                 content: '${ctx}/workKnowledgeBase/share/importFileBatchForm?rootId=${rootId}',
                 skin: 'three-btns with-demo',
                 skin: 'three-btns with-demo',
-                btn: ['开始导入', '关闭'],
+                btn: ['下载模板', '开始导入', '关闭'],
                 btn1: function(index, layero) {
                 btn1: function(index, layero) {
+                    // 下载模板按钮
+                    window.location.href = '${ctx}/workKnowledgeBase/share/exportFileBatchTemplate';
+                },
+                btn2: function(index, layero) {
                     var iframeWin = layero.find('iframe')[0];
                     var iframeWin = layero.find('iframe')[0];
                     if (iframeWin.contentWindow.doSubmit(1)) {
                     if (iframeWin.contentWindow.doSubmit(1)) {
                         setTimeout(function() {
                         setTimeout(function() {
@@ -791,7 +837,20 @@
                         }, 3000);
                         }, 3000);
                     }
                     }
                 },
                 },
-                btn2: function(index) { top.layer.close(index); }
+                btn3: function(index) { top.layer.close(index); }
+            });
+        }
+
+        /** 文件匹配对照弹窗 */
+        function openFileMatchDialog() {
+            top.layer.open({
+                type: 2,
+                area: ['1100px', '700px'],
+                title: '文件匹配对照',
+                maxmin: true,
+                content: '${ctx}/workKnowledgeBase/share/fileMatchForm',
+                btn: ['关闭'],
+                btn1: function(index) { top.layer.close(index); }
             });
             });
         }
         }
 
 
@@ -972,6 +1031,15 @@
                                     文件导入
                                     文件导入
                                 </button>
                                 </button>
                             </shiro:hasPermission>
                             </shiro:hasPermission>
+                            <%--<shiro:hasPermission name="workKnowledgeBase:share:import">
+                                <button class="layui-btn layui-btn-sm layui-bg-purple"
+                                        onclick="openFileMatchDialog()">
+                                    <i class="glyphicon glyphicon-search"></i> 文件匹配
+                                </button>
+                            </shiro:hasPermission>--%>
+
+
+
                                                     
                                                     
                                                     
                                                     
                         </c:if>
                         </c:if>
@@ -1075,13 +1143,13 @@
             {field: 'createTime', align: 'center', title: '创建时间', width: 160},
             {field: 'createTime', align: 'center', title: '创建时间', width: 160},
             // 点击量(仅展示,通过点击文件名称/文件地址触发递增)
             // 点击量(仅展示,通过点击文件名称/文件地址触发递增)
             {field: 'clickCount', align: 'center', title: '点击量', width: 100, templet: function(d){
             {field: 'clickCount', align: 'center', title: '点击量', width: 100, templet: function(d){
-                return '<a href="javascript:void(0)" onclick="showClickDetail(\'' + d.id + '\')" style="color:#009688;cursor:pointer;">' + (d.clickCount || 0) + '</a>';
+                return '<a href="javascript:void(0)" data-click-count="' + d.id + '" onclick="showClickDetail(\'' + d.id + '\')" style="color:#009688;cursor:pointer;">' + (d.clickCount || 0) + '</a>';
             }},
             }},
             {field: 'readCount', align: 'center', title: '阅读量', width: 100, templet: function(d){
             {field: 'readCount', align: 'center', title: '阅读量', width: 100, templet: function(d){
-                return '<a href="javascript:void(0)" onclick="showReadDetail(\'' + d.id + '\')" style="color:#1e9fff;cursor:pointer;">' + (d.readCount || 0) + '</a>';
+                return '<a href="javascript:void(0)" data-read-count="' + d.id + '" onclick="showReadDetail(\'' + d.id + '\')" style="color:#1e9fff;cursor:pointer;">' + (d.readCount || 0) + '</a>';
             }},
             }},
             {field: 'likeCount', align: 'center', title: '点赞量', width: 100, templet: function(d){
             {field: 'likeCount', align: 'center', title: '点赞量', width: 100, templet: function(d){
-                return '<a href="javascript:void(0)" onclick="showLikeDetail(\'' + d.id + '\')" style="color:#ff5722;cursor:pointer;">' + (d.likeCount || 0) + '</a>';
+                return '<a href="javascript:void(0)" data-like-count="' + d.id + '" onclick="showLikeDetail(\'' + d.id + '\')" style="color:#ff5722;cursor:pointer;">' + (d.likeCount || 0) + '</a>';
             }},
             }},
 
 
             {align:'center', title: '状态', fixed: 'right', width:70,templet:function(d){
             {align:'center', title: '状态', fixed: 'right', width:70,templet:function(d){
@@ -1177,9 +1245,9 @@
                 // 点赞/取消点赞按钮(仅审核通过的文件显示,问答分类不需要,创建人不能给自己点赞)
                 // 点赞/取消点赞按钮(仅审核通过的文件显示,问答分类不需要,创建人不能给自己点赞)
                 if (!d.isQaCategory && status === 5) {
                 if (!d.isQaCategory && status === 5) {
                     if (d.liked) {
                     if (d.liked) {
-                        xml += '<a href="javascript:void(0)" onclick="cancelLikeFile(\'' + d.id + '\')" class="layui-btn layui-btn-xs layui-bg-orange"><i class="fa fa-heart"></i> 已赞</a>';
+                        xml += '<a href="javascript:void(0)" onclick="cancelLikeFile(this,\'' + d.id + '\')" class="layui-btn layui-btn-xs layui-bg-orange"><i class="fa fa-heart"></i> 已赞</a>';
                     } else {
                     } else {
-                        xml += '<a href="javascript:void(0)" onclick="likeFile(\'' + d.id + '\')" class="layui-btn layui-btn-xs layui-bg-red"><i class="fa fa-heart-o"></i> 点赞</a>';
+                        xml += '<a href="javascript:void(0)" onclick="likeFile(this,\'' + d.id + '\')" class="layui-btn layui-btn-xs layui-bg-red"><i class="fa fa-heart-o"></i> 点赞</a>';
                     }
                     }
                 }
                 }