Selaa lähdekoodia

知识库添加阅读倒计时限制

徐滕 3 viikkoa sitten
vanhempi
commit
175b6e9355

+ 7 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/dao/WorkKnowledgeBaseReadRecordDao.java

@@ -27,6 +27,13 @@ public interface WorkKnowledgeBaseReadRecordDao extends CrudDao<WorkKnowledgeBas
     void updateReadRecord(WorkKnowledgeBaseReadRecord record);
 
     /**
+     * 累加阅读时长(秒)
+     */
+    void addReadDuration(@Param("fileId") String fileId,
+                         @Param("userId") String userId,
+                         @Param("duration") int duration);
+
+    /**
      * 统计文件的阅读人数
      */
     Integer countReadersByFileId(@Param("fileId") String fileId);

+ 11 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/entity/WorkKnowledgeBaseReadRecord.java

@@ -35,6 +35,9 @@ public class WorkKnowledgeBaseReadRecord extends DataEntity<WorkKnowledgeBaseRea
     /** 访问次数 */
     private Integer readCount;
 
+    /** 累计阅读时长(秒) */
+    private Integer totalReadDuration;
+
     public WorkKnowledgeBaseReadRecord() {
         super();
     }
@@ -90,4 +93,12 @@ public class WorkKnowledgeBaseReadRecord extends DataEntity<WorkKnowledgeBaseRea
     public void setReadCount(Integer readCount) {
         this.readCount = readCount;
     }
+
+    public Integer getTotalReadDuration() {
+        return totalReadDuration;
+    }
+
+    public void setTotalReadDuration(Integer totalReadDuration) {
+        this.totalReadDuration = totalReadDuration;
+    }
 }

+ 70 - 12
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/service/WorkKnowledgeBaseReadLikeService.java

@@ -15,6 +15,7 @@ import com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgeBaseTreeInfo;
 import com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgePointCategory;
 import com.jeeplus.modules.sys.entity.User;
 import com.jeeplus.modules.sys.service.SystemService;
+import com.jeeplus.modules.sys.utils.DictUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -58,7 +59,7 @@ public class WorkKnowledgeBaseReadLikeService extends CrudService<WorkKnowledgeB
     private SystemService systemService;
 
     /**
-     * 记录阅读行为(首次阅读给创建人加分)
+     * 记录阅读行为(仅记录,不给分;给分由 updateReadDuration 在累计达标后触发)
      * @param fileId 文件ID
      * @param userId 访问人ID
      * @return 是否首次阅读
@@ -69,7 +70,7 @@ public class WorkKnowledgeBaseReadLikeService extends CrudService<WorkKnowledgeB
             return false;
         }
 
-        // 只有审核通过(5)的文件才记录阅读量,并进行阅读赋分
+        // 只有审核通过(5)的文件才记录阅读量
         WorkKnowledgeBaseShareInfo fileStatus = shareInfoDao.get(fileId);
         if (fileStatus == null || !"5".equals(fileStatus.getAuditStatus())) {
             return false;
@@ -84,13 +85,6 @@ public class WorkKnowledgeBaseReadLikeService extends CrudService<WorkKnowledgeB
             existingRecord.setReadCount(existingRecord.getReadCount() + 1);
             existingRecord.preUpdate();
             readRecordDao.updateReadRecord(existingRecord);
-
-            // 非首次阅读,检查是否已发放阅读积分(change_type=3),未发放则补发
-            int pointCount = pointDetailDao.countByUserIdAndShareIdAndChangeType(userId, fileId, 3);
-            if (pointCount == 0) {
-                grantPointsForFirstRead(fileId, userId);
-            }
-
             return false; // 非首次阅读
         } else {
             // 首次阅读,创建新记录
@@ -101,6 +95,7 @@ public class WorkKnowledgeBaseReadLikeService extends CrudService<WorkKnowledgeB
             newRecord.setFirstReadTime(now);
             newRecord.setLastReadTime(now);
             newRecord.setReadCount(1);
+            newRecord.setTotalReadDuration(0);
             newRecord.preInsert();
             // preInsert 后如果 createBy 为空(无登录用户 session),手动补上
             if (newRecord.getCreateBy() == null) {
@@ -109,14 +104,59 @@ public class WorkKnowledgeBaseReadLikeService extends CrudService<WorkKnowledgeB
                 newRecord.setUpdateBy(user);
             }
             readRecordDao.insert(newRecord);
-
-            // 首次阅读:给创建人发放积分
-            grantPointsForFirstRead(fileId, userId);
             return true; // 首次阅读
         }
     }
 
     /**
+     * 累加阅读时长,达标后自动发放阅读积分
+     * @param fileId 文件ID
+     * @param userId 用户ID
+     * @param duration 本次阅读时长(秒)
+     * @return 是否新发放了积分
+     */
+    @Transactional(readOnly = false)
+    public boolean updateReadDuration(String fileId, String userId, int duration) {
+        if (fileId == null || userId == null || duration <= 0) {
+            return false;
+        }
+
+        // 累加时长
+        readRecordDao.addReadDuration(fileId, userId, duration);
+
+        // 查询累计时长
+        WorkKnowledgeBaseReadRecord record = readRecordDao.findByFileIdAndUserId(fileId, userId);
+        if (record == null) {
+            return false;
+        }
+        int totalDuration = record.getTotalReadDuration() != null ? record.getTotalReadDuration() : 0;
+
+        // 读取阈值(字典配置,默认180秒)
+        String thresholdStr = DictUtils.getDictLabel("1", "read_min_seconds", "180");
+        int threshold = 180;
+        try {
+            threshold = Integer.parseInt(thresholdStr);
+        } catch (NumberFormatException e) {
+            threshold = 180;
+        }
+
+        // 未达标,返回
+        if (totalDuration < threshold) {
+            return false;
+        }
+
+        // 检查是否已发放过阅读积分(change_type=3)
+        int pointCount = pointDetailDao.countByUserIdAndShareIdAndChangeType(userId, fileId, 3);
+        if (pointCount > 0) {
+            return false; // 已发过,不重复发放
+        }
+
+        // 达标且未发放 → 给分
+        grantPointsForFirstRead(fileId, userId);
+        return true;
+    }
+
+    /**
      * 点赞操作
      * @param fileId 文件ID
      * @param userId 点赞人ID
@@ -279,6 +319,24 @@ public class WorkKnowledgeBaseReadLikeService extends CrudService<WorkKnowledgeB
     }
 
     /**
+     * 检查用户是否已获得某文件的阅读积分
+     */
+    public boolean hasReadPoints(String fileId, String userId) {
+        if (fileId == null || userId == null) {
+            return false;
+        }
+        int count = pointDetailDao.countByUserIdAndShareIdAndChangeType(userId, fileId, 3);
+        return count > 0;
+    }
+
+    /**
+     * 根据文件ID和用户ID查询阅读记录
+     */
+    public WorkKnowledgeBaseReadRecord findByFileIdAndUserId(String fileId, String userId) {
+        return readRecordDao.findByFileIdAndUserId(fileId, userId);
+    }
+
+    /**
      * 首次阅读时给阅读人发放积分
      */
     private void grantPointsForFirstRead(String fileId, String readerUserId) {

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

@@ -47,6 +47,15 @@ public class WorkKnowledgeBaseReadLikeController extends BaseController {
             result.put("success", true);
             result.put("isFirstRead", isFirstRead);
             result.put("readCount", readLikeService.getReadCount(fileId));
+            
+            // 返回累计阅读时长和是否已获积分
+            WorkKnowledgeBaseReadRecord record = readLikeService.findByFileIdAndUserId(fileId, userId);
+            if (record != null) {
+                result.put("totalReadDuration", record.getTotalReadDuration() != null ? record.getTotalReadDuration() : 0);
+            } else {
+                result.put("totalReadDuration", 0);
+            }
+            result.put("alreadyGranted", readLikeService.hasReadPoints(fileId, userId));
         } catch (Exception e) {
             result.put("success", false);
             result.put("message", "记录阅读失败:" + e.getMessage());
@@ -55,6 +64,39 @@ public class WorkKnowledgeBaseReadLikeController extends BaseController {
     }
 
     /**
+     * 累加阅读时长,达标后自动给分
+     */
+    @RequestMapping(value = "updateReadDuration")
+    @ResponseBody
+    public Map<String, Object> updateReadDuration(
+            @RequestParam String fileId,
+            @RequestParam Integer duration) {
+        Map<String, Object> result = new HashMap<>();
+        try {
+            String userId = UserUtils.getUser().getId();
+            boolean granted = readLikeService.updateReadDuration(fileId, userId, duration != null ? duration : 0);
+            
+            // 查询累计总时长,返回给前端用于计算剩余时间
+            WorkKnowledgeBaseReadRecord record = readLikeService.findByFileIdAndUserId(fileId, userId);
+            int totalDuration = (record != null && record.getTotalReadDuration() != null) ? record.getTotalReadDuration() : 0;
+            
+            result.put("success", true);
+            result.put("granted", granted);
+            result.put("totalDuration", totalDuration);
+            // 查询是否已发放过阅读积分(本次新发放 or 之前已发放)
+            boolean alreadyGranted = readLikeService.hasReadPoints(fileId, userId);
+            result.put("alreadyGranted", alreadyGranted);
+            if (granted) {
+                result.put("message", "阅读达标,已获得积分");
+            }
+        } catch (Exception e) {
+            result.put("success", false);
+            result.put("message", "更新阅读时长失败:" + e.getMessage());
+        }
+        return result;
+    }
+
+    /**
      * 点赞操作
      */
     @RequestMapping(value = "like")

+ 14 - 0
src/main/resources/mappings/modules/WorkKnowledgeBase/WorkKnowledgeBaseReadRecordDao.xml

@@ -9,6 +9,7 @@
         a.first_read_time AS "firstReadTime",
         a.last_read_time AS "lastReadTime",
         a.read_count AS "readCount",
+        a.total_read_duration AS "totalReadDuration",
         a.create_by AS "createBy.id",
         a.create_date AS "createDate",
         a.update_by AS "updateBy.id",
@@ -62,6 +63,7 @@
             first_read_time,
             last_read_time,
             read_count,
+            total_read_duration,
             create_by,
             create_date,
             update_by,
@@ -75,6 +77,7 @@
             #{firstReadTime},
             #{lastReadTime},
             #{readCount},
+            #{totalReadDuration},
             #{createBy.id},
             #{createDate},
             #{updateBy.id},
@@ -116,11 +119,22 @@
         UPDATE work_knowledge_base_read_record SET
             last_read_time = #{lastReadTime},
             read_count = #{readCount},
+            total_read_duration = #{totalReadDuration},
             update_by = #{updateBy.id},
             update_date = #{updateDate}
         WHERE id = #{id}
     </update>
 
+    <!-- 累加阅读时长(秒) -->
+    <update id="addReadDuration">
+        UPDATE work_knowledge_base_read_record SET
+            total_read_duration = total_read_duration + #{duration},
+            update_date = NOW()
+        WHERE file_id = #{fileId}
+          AND user_id = #{userId}
+          AND del_flag = 0
+    </update>
+
     <!-- 统计文件的阅读人数 -->
     <select id="countReadersByFileId" resultType="java.lang.Integer">
         SELECT COUNT(DISTINCT a.user_id)

+ 26 - 0
src/main/resources/mysqlDateBase/upgrade_read_like_half_point.sql

@@ -1,9 +1,9 @@
 -- =====================================================================
+-- 阅读/点赞积分调整为 0.5 分 + 阅读时长限制 —— 生产库升级脚本
 -- 日期:2026-10-21
 -- 特性:幂等,可重复执行,执行顺序无依赖
 -- 说明:
+--   1. 本脚本覆盖存量库的全部升级内容(规则值 + 表精度 + 阅读时长);
 --   2. wk_point_snapshot_tables.sql / init_work_knowledge_base_point_rule_data.sql
 --      为全新环境安装脚本,已同步为 0.5 和 DECIMAL,存量库无需执行;
 --   3. 违规处理记录表(work_knowledge_base_violation_record)不需要升级:
@@ -53,14 +53,36 @@ ALTER TABLE wk_point_snapshot_summary
     MODIFY COLUMN `contrib_remaining` DECIMAL(10,2) DEFAULT 0 COMMENT '贡献剩余积分';
 
 -- ---------------------------------------------------------------------
+-- 三、阅读时长限制:阅读必须累计满 3 分钟才给积分
+-- ---------------------------------------------------------------------
+
+-- 1. 阅读记录表加累计时长列(如已存在,重复执行无副作用)
+ALTER TABLE work_knowledge_base_read_record
+    ADD COLUMN IF NOT EXISTS `total_read_duration` INT DEFAULT 0 COMMENT '累计阅读时长(秒)';
+
+-- 2. 字典配置阅读最低时长(默认 180 秒 = 3 分钟)
+--    如果已存在则不插入(通过 id 去重)
+INSERT INTO sys_dict (id, type, label, value, description, sort, parent_id, create_by, create_date, update_by, update_date, remarks, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 'read_min_seconds', '1', '180', '阅读最低时长(秒)', 0, '0', '1', NOW(), '1', NOW(), '阅读积分最低时长配置', '0'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_dict WHERE type = 'read_min_seconds' AND label = '1'
+);
+
+-- ---------------------------------------------------------------------
+-- 四、执行后验证(可选)
 -- ---------------------------------------------------------------------
 -- 规则值应为 0.5(列类型应为 varchar):
 -- SELECT point_name, point_value FROM work_knowledge_base_point_rule
+--  WHERE point_name IN ('阅读', '点赞');
 -- 列类型应为 decimal(10,2):
 -- SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS
 --  WHERE TABLE_SCHEMA = DATABASE()
 --    AND ((TABLE_NAME = 'work_knowledge_base_point_detail' AND COLUMN_NAME = 'point')
 --      OR (TABLE_NAME = 'wk_point_snapshot_detail' AND COLUMN_NAME = 'point')
 --      OR (TABLE_NAME = 'wk_point_snapshot_summary' AND COLUMN_NAME LIKE '%subtotal%'));
+-- 阅读时长列和字典:
+-- SELECT COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS
+--  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'work_knowledge_base_read_record'
+--    AND COLUMN_NAME = 'total_read_duration';
+-- SELECT type, value FROM sys_dict WHERE type = 'read_min_seconds';

+ 1 - 1
src/main/webapp/static/common/js/common.js

@@ -232,7 +232,7 @@ function openPreview(url,flag){
             url += "&" + a + "=" + encodeURIComponent(ops[a]);
         }
     }
-    window.open(url)
+    return window.open(url)
 }
 
 

+ 1 - 1
src/main/webapp/webpage/include/head.jsp

@@ -70,7 +70,7 @@
 <script src="${ctxStatic}/common/mustache.min.js" type="text/javascript"></script>
 <script src="${ctxStatic}/common/content.js" type="text/javascript"></script>
 <link href="${ctxStatic}/common/css/login.css" type="text/css" rel="stylesheet" />
-<script src="${ctxStatic}/common/js/common.js?3" type="text/javascript"></script>
+<script src="${ctxStatic}/common/js/common.js?4" type="text/javascript"></script>
 <script src="${ctxStatic}/utilJs/custom.js?1" type="text/javascript"></script>
 <script type="text/javascript">var ctx = '${ctx}', ctxStatic='${ctxStatic}',ctxp='${ctxp}';</script>
 

+ 370 - 9
src/main/webapp/webpage/modules/WorkKnowledgeBase/workKnowledgeBaseShareDetail.jsp

@@ -27,10 +27,14 @@
         
         // 拦截 openPreview 函数,记录预览行为
         var originalOpenPreview = window.openPreview;
+        console.log('[DetailTrack] 初始化: originalOpenPreview =', typeof originalOpenPreview, originalOpenPreview);
+        console.log('[DetailTrack] 初始化: auditStatus=${entity.auditStatus}, entityId=${entity.id}');
         window.openPreview = function(url, type) {
-            // 首次查看附件时开始计时
+            console.log('[DetailTrack] 拦截器被调用, url=', url, 'type=', type);
+            // 首次查看附件时开始计时(仅首次,后续点击不重置)
             if (entryTime === null) {
                 entryTime = Date.now();
+                startAuditTimer();
             }
             // 记录预览行为
             var attachName = '';
@@ -49,7 +53,11 @@
             });
             // 调用原始函数
             if (originalOpenPreview) {
-                originalOpenPreview(url, type);
+                var result = originalOpenPreview(url, type);
+                console.log('[DetailTrack] 原始openPreview返回值:', result ? 'Window对象' : 'null/undefined');
+                return result;
+            } else {
+                console.log('[DetailTrack] 警告: originalOpenPreview 不存在!');
             }
         };
         
@@ -57,9 +65,10 @@
         $(document).on('click', '.op-btn-download', function(e) {
             e.preventDefault(); // 阻止默认的链接行为
                     
-            // 首次查看附件时开始计时
+            // 首次查看附件时开始计时(仅首次,后续点击不重置)
             if (entryTime === null) {
                 entryTime = Date.now();
+                startAuditTimer();
             }
                     
             var $btn = $(this);
@@ -102,6 +111,99 @@
             }
         });
         
+        // ========== 审核倒计时(纯前端展示) ==========
+        var auditTimerFloat = null; // 审核计时浮层引用
+        var auditTimerInterval = null; // 审核计时定时器
+        var auditOpenWins = []; // 当前打开的预览窗口列表
+        var auditPollTimer = null; // 窗口关闭轮询定时器
+        var auditAccumulated = 0; // 累计已看秒数(暂停时保留)
+        var auditTimerRunning = false; // 计时器是否正在运行
+
+        // 注册新打开的预览窗口
+        function registerAuditWin(win) {
+            if (!win) return;
+            auditOpenWins.push(win);
+            if (!auditTimerRunning && entryTime) {
+                resumeAuditTimer();
+            }
+            startAuditWinPolling();
+        }
+
+        // 轮询检测所有窗口是否关闭
+        function startAuditWinPolling() {
+            if (auditPollTimer) return;
+            auditPollTimer = setInterval(function() {
+                // 过滤掉已关闭的窗口
+                auditOpenWins = auditOpenWins.filter(function(w) {
+                    try { return !w.closed; } catch(e) { return false; }
+                });
+                if (auditOpenWins.length === 0 && auditTimerRunning) {
+                    // 所有窗口都关闭了,暂停计时
+                    pauseAuditTimer();
+                }
+            }, 1000);
+        }
+
+        function pauseAuditTimer() {
+            if (auditTimerInterval) {
+                clearInterval(auditTimerInterval);
+                auditTimerInterval = null;
+            }
+            auditTimerRunning = false;
+            if (auditTimerFloat) {
+                var minStay = ${minStaySeconds != null ? minStaySeconds : 180};
+                var elapsedStr = fmtAuditTime(auditAccumulated);
+                var remaining = Math.max(0, minStay - auditAccumulated);
+                if (remaining > 0) {
+                    auditTimerFloat.innerHTML = '<div style="font-weight:bold;margin-bottom:4px;">\u23f8 \u5ba1\u6838\u8ba1\u65f6\uff08\u5df2\u6682\u505c\uff09</div>'
+                        + '<div>\u5df2\u770b\uff1a<span style="color:#4caf50;">' + elapsedStr + '</span></div>'
+                        + '<div>\u8fd8\u9700\uff1a<span style="color:#ff9800;">' + fmtAuditTime(remaining) + '</span></div>'
+                        + '<div style="color:#999;font-size:12px;margin-top:2px;">\u6253\u5f00\u6587\u4ef6\u7ee7\u7eed\u8ba1\u65f6</div>';
+                } else {
+                    auditTimerFloat.innerHTML = '<div style="font-weight:bold;margin-bottom:4px;">\u23f8 \u5ba1\u6838\u8ba1\u65f6\uff08\u5df2\u6682\u505c\uff09</div>'
+                        + '<div>\u5df2\u770b\uff1a<span style="color:#4caf50;">' + elapsedStr + '</span></div>'
+                        + '<div style="color:#4caf50;font-weight:bold;">\u2714 \u5df2\u8fbe\u6807\uff0c\u53ef\u4ee5\u5ba1\u6838</div>';
+                }
+            }
+        }
+
+        function resumeAuditTimer() {
+            if (auditTimerInterval) return;
+            auditTimerRunning = true;
+            var minStay = ${minStaySeconds != null ? minStaySeconds : 180};
+            auditTimerInterval = setInterval(function() {
+                if (!entryTime) return;
+                auditAccumulated++;
+                var remaining = Math.max(0, minStay - auditAccumulated);
+                var elapsedStr = fmtAuditTime(auditAccumulated);
+                if (remaining > 0) {
+                    auditTimerFloat.innerHTML = '<div style="font-weight:bold;margin-bottom:4px;">\ud83d\udd52 \u5ba1\u6838\u8ba1\u65f6</div>'
+                        + '<div>\u5df2\u770b\uff1a<span style="color:#4caf50;">' + elapsedStr + '</span></div>'
+                        + '<div>\u8fd8\u9700\uff1a<span style="color:#ff9800;">' + fmtAuditTime(remaining) + '</span></div>';
+                } else {
+                    auditTimerFloat.innerHTML = '<div style="font-weight:bold;margin-bottom:4px;">\u2705 \u5ba1\u6838\u8ba1\u65f6</div>'
+                        + '<div>\u5df2\u770b\uff1a<span style="color:#4caf50;">' + elapsedStr + '</span></div>'
+                        + '<div style="color:#4caf50;font-weight:bold;">\u2714 \u5df2\u8fbe\u6807\uff0c\u53ef\u4ee5\u5ba1\u6838</div>';
+                }
+            }, 1000);
+        }
+
+        function startAuditTimer() {
+            if (auditTimerRunning) return; // 已在运行
+            auditAccumulated = 0; // 首次启动,重置累计
+            resumeAuditTimer();
+        }
+
+        function fmtAuditTime(seconds) {
+            if (seconds < 60) return seconds + '\u79d2';
+            var m = Math.floor(seconds / 60);
+            var s = seconds % 60;
+            if (m < 60) return (s === 0 ? m + '\u5206' : m + '\u5206' + s + '\u79d2');
+            var h = Math.floor(m / 60);
+            m = m % 60;
+            return h + '\u65f6' + (m > 0 ? m + '\u5206' : '') + (s > 0 ? s + '\u79d2' : '');
+        }
+
         // 时间格式化:秒 -> "X秒" / "X分Y秒" / "X时Y分Z秒"
         function formatDuration(seconds) {
             if (seconds < 60) {
@@ -140,8 +242,8 @@
                 $('#inputForm input[name="attachmentViewDetail"]').remove();
                 $('#inputForm input[name="isViewedAttachment"]').remove();
                 
-                // 计算附件查看时长(秒):从首次查看附件开始计时,未查看过则为0
-                var viewDurationSeconds = entryTime !== null ? Math.floor((Date.now() - entryTime) / 1000) : 0;
+                // 计算附件查看时长(秒):使用累计计时(暂停时不计入)
+                var viewDurationSeconds = auditAccumulated || 0;
                 // 是否查看过附件
                 var isViewedAttachment = attachmentViewLog.length > 0 ? 1 : 0;
                 // 附件查看行为 JSON
@@ -547,16 +649,16 @@
                                                        or fn:containsIgnoreCase(attach.attachmentName,'gif')
                                                        or fn:containsIgnoreCase(attach.attachmentName,'jpeg')}">
                                             <img src="${attach.temporaryUrl}" width="50" height="50"
-                                                 onclick="openPreview('${attach.temporaryUrl}',5)"
+                                                 onclick="openPreviewWithRead('${attach.temporaryUrl}',5)"
                                                  alt="${attach.attachmentName}"/>
                                         </c:when>
                                         <c:when test="${fn:containsIgnoreCase(attach.attachmentName,'pdf')}">
                                             <a class="attention-info" href="javascript:void(0)"
-                                               onclick="openPreview('${attach.temporaryUrl}',1)">${attach.attachmentName}</a>
+                                               onclick="openPreviewWithRead('${attach.temporaryUrl}',1)">${attach.attachmentName}</a>
                                         </c:when>
                                         <c:otherwise>
                                             <a class="attention-info" href="javascript:void(0)"
-                                               onclick="openPreview('${attach.temporaryUrl}',2)">${attach.attachmentName}</a>
+                                               onclick="openPreviewWithRead('${attach.temporaryUrl}',2)">${attach.attachmentName}</a>
                                         </c:otherwise>
                                     </c:choose>
                                 </td>
@@ -593,7 +695,7 @@
                             <tr>
                                 <td>${link.linkDesc}</td>
                                 <td>
-                                    <a href="${link.linkUrl}" target="_blank" class="op-btn" style="color: #1E9FFF;">
+                                    <a href="javascript:void(0)" onclick="openLinkWithRead('${link.linkUrl}')" class="op-btn" style="color: #1E9FFF;">
                                         <i class="fa fa-external-link"></i>&nbsp;${link.linkUrl}
                                     </a>
                                 </td>
@@ -1019,6 +1121,265 @@
             loadAnswers();
         }
     });
+
+    // ========== 阅读计时追踪(详情页独立) ==========
+    var readTracker = {
+        currentFileId: '${entity.id}',
+        startTime: null,
+        previewWin: null,
+        totalDuration: 0,
+        timer: null,
+        pollTimer: null,
+        threshold: 180,
+        originalTitle: document.title
+    };
+
+    function fmtReadDur(seconds) {
+        var m = Math.floor(seconds / 60);
+        var s = seconds % 60;
+        return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
+    }
+
+    function showReadBar(pct) {
+        var bar = document.getElementById('readProgressBar');
+        if (bar) { bar.style.display = 'block'; bar.style.width = Math.min(pct, 100) + '%'; }
+    }
+    function hideReadBar() {
+        var bar = document.getElementById('readProgressBar');
+        if (bar) { bar.style.display = 'none'; bar.style.width = '0%'; }
+    }
+
+    function showReadFloat(elapsed, threshold) {
+        var remaining = Math.max(0, threshold - elapsed);
+        var pct = Math.min(elapsed / threshold * 100, 100);
+        var el = document.getElementById('readFloatBox');
+        if (!el) return;
+        el.style.display = 'block';
+        el.innerHTML = '<div style="margin-bottom:4px;">&#128214; 阅读计时</div>'
+            + '<div>已读: <b>' + fmtReadDur(elapsed) + '</b> / ' + fmtReadDur(threshold) + '</div>'
+            + '<div>剩余: <b style="color:' + (remaining > 0 ? '#ffeb3b' : '#4caf50') + '">'
+            + (remaining > 0 ? fmtReadDur(remaining) : '达标!') + '</b></div>'
+            + '<div style="margin-top:6px;height:4px;background:#555;border-radius:2px;overflow:hidden;">'
+            + '<div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,#ff9800,#4caf50);transition:width 1s;"></div></div>';
+    }
+    function hideReadFloat() {
+        var el = document.getElementById('readFloatBox');
+        if (el) el.style.display = 'none';
+    }
+
+    function doSettle(fid, duration, origTitle, granted, totalDuration, elapsed, alreadyGranted) {
+        var threshold = readTracker.threshold;
+        var accumulated = (totalDuration && totalDuration > 0) ? totalDuration : (elapsed || duration);
+        // 以服务端判定为准:本次新发放 or 之前已发放过
+        if (granted || alreadyGranted) {
+            document.title = '[\u2705 已获积分] ' + origTitle;
+            layer.msg('\u9605\u8bfb\u65f6\u95f4\u5df2\u8fbe\u6807\uff0c\u79ef\u5206\u5df2\u53d1\u653e\uff01', {icon: 1, time: 3000});
+            setTimeout(function() { document.title = origTitle; }, 5000);
+        } else {
+            document.title = origTitle;
+            var remaining = Math.max(0, threshold - accumulated);
+            var remainText = fmtReadDur(remaining);
+            layer.msg('\u672c\u6b21\u9605\u8bfb ' + fmtReadDur(duration) + '\uff0c\u7d2f\u8ba1 ' + fmtReadDur(accumulated) + '\uff0c\u672a\u8fbe\u6807\u3002\u8fd8\u9700\u9605\u8bfb ' + remainText + ' \u624d\u80fd\u83b7\u5f97\u79ef\u5206\u3002', {icon: 0, time: 5000});
+        }
+    }
+
+    function settleCurrentRead() {
+        console.log('[DetailTrack] settleCurrentRead 被调用', { fileId: readTracker.currentFileId, startTime: readTracker.startTime });
+        if (!readTracker.currentFileId || !readTracker.startTime) return;
+        var duration = Math.max(1, Math.floor((Date.now() - readTracker.startTime) / 1000));
+        var fid = readTracker.currentFileId;
+        var origTitle = readTracker.originalTitle;
+        var elapsed = readTracker.totalDuration + duration;
+        console.log('[DetailTrack] 结算时长:', duration, '秒, 累计:', elapsed, '秒');
+        if (readTracker.timer) { clearInterval(readTracker.timer); readTracker.timer = null; }
+        if (readTracker.pollTimer) { clearInterval(readTracker.pollTimer); readTracker.pollTimer = null; }
+        hideReadBar();
+        hideReadFloat();
+        readTracker.currentFileId = null;
+        readTracker.startTime = null;
+        readTracker.previewWin = null;
+        readTracker.totalDuration = 0;
+        document.title = origTitle;
+        $.ajax({
+            type: 'POST',
+            url: '${ctx}/workKnowledgeBase/readLike/updateReadDuration',
+            data: { fileId: fid, duration: duration },
+            success: function(res) { console.log('[DetailTrack] 结算API返回:', res); doSettle(fid, duration, origTitle, res && res.granted, res && res.totalDuration, elapsed, res && res.alreadyGranted); },
+            error: function(xhr, status, err) { console.log('[DetailTrack] 结算API失败:', status, err); document.title = origTitle; }
+        });
+    }
+
+    function startReadTimer() {
+        console.log('[DetailTrack] startReadTimer 启动, totalDuration=', readTracker.totalDuration);
+        if (readTracker.timer) clearInterval(readTracker.timer);
+        readTracker.timer = setInterval(function() {
+            if (!readTracker.currentFileId || !readTracker.startTime) {
+                console.log('[DetailTrack] timer tick: 已停止(currentFileId或startTime为空)');
+                clearInterval(readTracker.timer); readTracker.timer = null; return;
+            }
+            var elapsed = readTracker.totalDuration + Math.floor((Date.now() - readTracker.startTime) / 1000);
+            var pct = Math.min(elapsed / readTracker.threshold * 100, 100);
+            document.title = '[ ' + fmtReadDur(elapsed) + '/' + fmtReadDur(readTracker.threshold) + '] ' + readTracker.originalTitle;
+            showReadBar(pct);
+            showReadFloat(elapsed, readTracker.threshold);
+            // 不再自动结算,等用户关闭窗口时统一处理
+        }, 1000);
+    }
+
+    function startClosePolling() {
+        console.log('[DetailTrack] startClosePolling 启动, previewWin=', readTracker.previewWin ? '有' : '无');
+        if (readTracker.pollTimer) clearInterval(readTracker.pollTimer);
+        readTracker.pollTimer = setInterval(function() {
+            if (!readTracker.currentFileId) {
+                clearInterval(readTracker.pollTimer); readTracker.pollTimer = null; return;
+            }
+            if (readTracker.previewWin) {
+                try {
+                    if (readTracker.previewWin.closed) {
+                        console.log('[DetailTrack] 轮询检测到窗口已关闭');
+                        clearInterval(readTracker.pollTimer); readTracker.pollTimer = null;
+                        settleCurrentRead();
+                    }
+                } catch(e) {
+                    console.log('[DetailTrack] 轮询异常:', e.message);
+                    clearInterval(readTracker.pollTimer); readTracker.pollTimer = null;
+                    settleCurrentRead();
+                }
+            }
+        }, 1000);
+    }
+
+    function trackRead(fileId, openFn) {
+        console.log('[DetailTrack] trackRead 被调用, fileId=', fileId);
+        if (readTracker.currentFileId && readTracker.startTime) {
+            settleCurrentRead();
+        }
+        var openResult = openFn();
+        console.log('[DetailTrack] trackRead: openFn() 返回=', openResult ? 'Window对象' : 'null/undefined');
+        $.ajax({
+            type: 'POST',
+            url: '${ctx}/workKnowledgeBase/readLike/recordRead',
+            data: { fileId: fileId },
+            success: function(result) {
+                console.log('[DetailTrack] recordRead API返回:', result);
+                var accumulated = (result && result.totalReadDuration) || 0;
+                readTracker.totalDuration = accumulated;
+                readTracker.startTime = Date.now();
+                startReadTimer();
+            },
+            error: function(xhr, status, err) {
+                console.log('[DetailTrack] recordRead API失败:', status, err);
+                readTracker.startTime = Date.now();
+                startReadTimer();
+            }
+        });
+        $.ajax({ type: 'POST', url: '${ctx}/workKnowledgeBase/readLike/incrementClick', data: { fileId: fileId } });
+        readTracker.currentFileId = fileId;
+        readTracker.previewWin = openResult;
+        console.log('[DetailTrack] trackRead: previewWin已保存, currentFileId=', fileId);
+        startClosePolling();
+    }
+
+    /** 详情页附件预览(仅审核通过才追踪阅读时长) */
+    function openPreviewWithRead(url, type) {
+        console.log('[DetailTrack] ===== openPreviewWithRead 被调用 =====', { url: url.substring(0, 80), type: type, auditStatus: '${entity.auditStatus}', entityId: '${entity.id}' });
+        
+        // 1. 先处理URL(复用 common.js 的逻辑)
+        var processedUrl = url;
+        if (type === 1 || type === 3 || type === 5) {
+            processedUrl = 'https://view.xdocin.com/view?src=' + encodeURIComponent(url);
+        } else if (type === 2) {
+            if (url.indexOf('.xml') !== -1) {
+                processedUrl = 'https://view.xdocin.com/view?src=' + encodeURIComponent(url);
+            } else {
+                try {
+                    var uObj = new URL(url);
+                    var pathname = uObj.pathname.toLowerCase();
+                    var officeExts = ['.doc','.docx','.xls','.xlsx','.ppt','.pptx','.mdb','.accdb','.msg','.ost','.pst'];
+                    var isOffice = officeExts.some(function(ext) { return pathname.endsWith(ext); });
+                    if (isOffice) {
+                        processedUrl = 'https://view.officeapps.live.com/op/view.aspx?src=' + encodeURIComponent(url);
+                    } else {
+                        processedUrl = 'https://view.xdocin.com/view?src=' + encodeURIComponent(url);
+                    }
+                } catch(e) {
+                    processedUrl = 'https://view.xdocin.com/view?src=' + encodeURIComponent(url);
+                }
+            }
+        }
+        
+        // 2. 立即打开窗口(同步,避免弹窗拦截)
+        var win = window.open(processedUrl, '_blank');
+        console.log('[DetailTrack] window.open 结果:', win ? 'Window对象' : 'null(被拦截)');
+        
+        // 3. 记录审核行为追踪(仅首次设置 entryTime,不重置)
+        if (entryTime === null) {
+            entryTime = Date.now();
+            startAuditTimer();
+        }
+        // 注册窗口到跟踪列表(关闭时暂停计时)
+        registerAuditWin(win);
+        var attachName = '';
+        try { var urlObj2 = new URL(url); attachName = decodeURIComponent(urlObj2.pathname.split('/').pop() || ''); } catch(e) { attachName = url.substring(0, 50); }
+        attachmentViewLog.push({ name: attachName, action: 'preview', time: new Date().toLocaleString('zh-CN', {hour12: false}) });
+        
+        // 4. 审核通过启动阅读积分计时,否则仅审核计时
+        if ('${entity.auditStatus}' == '5') {
+            console.log('[DetailTrack] 审核通过,启动阅读积分追踪');
+            trackRead('${entity.id}', function() { return win; });
+        } else {
+            console.log('[DetailTrack] 非审核通过(auditStatus=${entity.auditStatus}),仅审核计时');
+        }
+    }
+
+    /** 外部链接点击(带阅读追踪) */
+    function openLinkWithRead(linkUrl) {
+        console.log('[DetailTrack] openLinkWithRead:', linkUrl);
+        // 1. 同步打开窗口(避免弹窗拦截)
+        var win = window.open(linkUrl, '_blank');
+        console.log('[DetailTrack] window.open 结果:', win ? 'Window对象' : 'null(被拦截)');
+
+        // 2. 记录审核行为追踪(仅首次设置 entryTime,不重置)
+        if (entryTime === null) {
+            entryTime = Date.now();
+            startAuditTimer();
+        }
+        // 注册窗口到跟踪列表(关闭时暂停计时)
+        registerAuditWin(win);
+        var linkName = '';
+        try { var uObj = new URL(linkUrl); linkName = uObj.hostname + uObj.pathname.substring(0, 30); } catch(e) { linkName = linkUrl.substring(0, 50); }
+        attachmentViewLog.push({ name: linkName, action: 'link_click', time: new Date().toLocaleString('zh-CN', {hour12: false}) });
+
+        // 3. 只有审核通过才启动阅读计时
+        if ('${entity.auditStatus}' == '5') {
+            console.log('[DetailTrack] 链接-审核通过,启动追踪');
+            trackRead('${entity.id}', function() { return win; });
+        } else {
+            console.log('[DetailTrack] 链接-未审核通过,不追踪');
+        }
+    }
+</script>
+
+<!-- 阅读计时进度条 -->
+<div id="readProgressBar" style="position:fixed;top:0;left:0;height:3px;background:linear-gradient(90deg,#ff9800,#4caf50);z-index:999999;width:0%;transition:width 1s;display:none;"></div>
+
+<!-- 阅读计时浮层(右下角) -->
+<div id="readFloatBox" style="position:fixed;right:20px;bottom:20px;z-index:999999;background:rgba(0,0,0,0.75);color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;line-height:1.6;min-width:180px;box-shadow:0 2px 8px rgba(0,0,0,0.3);display:none;"></div>
+
+<!-- 审核计时浮层(右下角) -->
+<div id="auditTimerFloat" style="position:fixed;right:20px;bottom:20px;z-index:999999;background:rgba(0,0,0,0.75);color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;line-height:1.6;min-width:180px;box-shadow:0 2px 8px rgba(0,0,0,0.3);display:none;"></div>
+<script>
+    // 页面加载时检查是否需要显示审核计时浮层
+    (function() {
+        var canAudit = ${canAudit != null ? canAudit : false};
+        var auditStatus = '${entity.auditStatus}';
+        if (canAudit && (auditStatus === '1' || auditStatus === '2')) {
+            auditTimerFloat = document.getElementById('auditTimerFloat');
+            auditTimerFloat.style.display = 'block';
+            auditTimerFloat.innerHTML = '<div style="font-weight:bold;margin-bottom:4px;">\ud83d\udd52 \u5ba1\u6838\u8ba1\u65f6</div>'
+                + '<div style="color:#999;">\u70b9\u51fb\u6587\u4ef6\u9884\u89c8\u6216\u94fe\u63a5\u5f00\u59cb\u8ba1\u65f6...</div>';
+        }
+    })();
 </script>
 
 </body>

+ 273 - 115
src/main/webapp/webpage/modules/WorkKnowledgeBase/workKnowledgeBaseShareList.jsp

@@ -83,16 +83,18 @@
         }
 
         /** 查看详情 */
-        function openDetailDialog(id) {
-            top.layer.open({
+        function openDetailDialog(id, onClose) {
+            var idx = top.layer.open({
                 type: 2,
                 area: ['80%', '80%'],
                 title: '文件详情',
                 maxmin: true,
                 content: '${ctx}/workKnowledgeBase/share/detail?readOnly=true&id=' + id + '&treeNodeId=${treeNodeId}&rootId=${rootId}',
                 btn: ['关闭'],
-                btn1: function(index) { top.layer.close(index); }
+                btn1: function(index) { top.layer.close(index); },
+                end: function() { if (onClose) onClose(); }
             });
+            return idx;
         }
 
         /** 点击状态列:作废时展示作废原因,其他状态打开详情 */
@@ -157,16 +159,18 @@
         }
         
         /** 查看问答答疑详情(只读模式) */
-        function openQaDetailDialog(id) {
-            top.layer.open({
+        function openQaDetailDialog(id, onClose) {
+            var idx = top.layer.open({
                 type: 2,
                 area: ['80%', '80%'],
                 title: '问答详情',
                 maxmin: true,
                 content: '${ctx}/workKnowledgeBase/share/detail?readOnly=true&id=' + id + '&treeNodeId=${treeNodeId}&rootId=${rootId}',
                 btn: ['关闭'],
-                btn1: function(index) { top.layer.close(index); }
+                btn1: function(index) { top.layer.close(index); },
+                end: function() { if (onClose) onClose(); }
             });
+            return idx;
         }
         
         /** 更新列表中指定文件的计数值(点击量/阅读量/点赞量),不刷新页面 */
@@ -187,58 +191,7 @@
             }
         }
 
-        /** 查看详情(带阅读记录 + 点击量递增) */
-        function openDetailDialogWithRead(id) {
-            // 记录阅读行为
-            $.ajax({
-                type: 'POST',
-                url: '${ctx}/workKnowledgeBase/readLike/recordRead',
-                data: { fileId: id },
-                success: function(result) {
-                    if (result.success && result.readCount != null) {
-                        updateCountInList(id, 'read-count', result.readCount);
-                    }
-                    // 递增点击量(与阅读记录并行,不影响主流程)
-                    $.ajax({
-                        type: 'POST',
-                        url: '${ctx}/workKnowledgeBase/readLike/incrementClick',
-                        data: { fileId: id },
-                        success: function(clickResult) {
-                            if (clickResult.success && clickResult.clickCount != null) {
-                                updateCountInList(id, 'click-count', clickResult.clickCount);
-                            }
-                        }
-                    });
-                    // 打开详情弹窗
-                    openDetailDialog(id);
-                },
-                error: function() {
-                    // 即使记录失败也打开详情
-                    openDetailDialog(id);
-                }
-            });
-        }
         
-        /** 查看问答答疑详情(只读模式,带阅读记录) */
-        function openQaDetailDialogWithRead(id) {
-            // 先记录阅读行为
-            $.ajax({
-                type: 'POST',
-                url: '${ctx}/workKnowledgeBase/readLike/recordRead',
-                data: { fileId: id },
-                success: function(result) {
-                    if (result.success && result.readCount != null) {
-                        updateCountInList(id, 'read-count', result.readCount);
-                    }
-                    // 打开只读模式的详情弹窗
-                    openQaDetailDialog(id);
-                },
-                error: function() {
-                    // 即使记录失败也打开详情
-                    openQaDetailDialog(id);
-                }
-            });
-        }
         
         /** 打开确认最佳答案页面(带提交/关闭按钮) */
         function openConfirmAnswerDialog(id) {
@@ -276,36 +229,42 @@
             });
         }
         
-        /** 预览文件(带阅读记录 + 点击量递增) */
+        /** 预览文件(带阅读计时追踪) */
         function openPreviewWithRead(url, type, fileId) {
-            // 记录阅读行为
-            $.ajax({
-                type: 'POST',
-                url: '${ctx}/workKnowledgeBase/readLike/recordRead',
-                data: { fileId: fileId },
-                success: function(result) {
-                    if (result.success && result.readCount != null) {
-                        updateCountInList(fileId, 'read-count', result.readCount);
-                    }
-                    // 递增点击量(与阅读记录并行,不影响主流程)
-                    $.ajax({
-                        type: 'POST',
-                        url: '${ctx}/workKnowledgeBase/readLike/incrementClick',
-                        data: { fileId: fileId },
-                        success: function(clickResult) {
-                            if (clickResult.success && clickResult.clickCount != null) {
-                                updateCountInList(fileId, 'click-count', clickResult.clickCount);
-                            }
+            // 先处理URL(复用 openPreview 的逻辑)
+            var processedUrl = url;
+            var imgFlag = false;
+            if (type === 1 || type === 3 || type === 5) {
+                processedUrl = 'https://view.xdocin.com/view?src=' + encodeURIComponent(url);
+                if (type === 5) imgFlag = true;
+            } else if (type === 2) {
+                if (url.indexOf('.xml') !== -1) {
+                    processedUrl = 'https://view.xdocin.com/view?src=' + encodeURIComponent(url);
+                } else {
+                    try {
+                        var uObj = new URL(url);
+                        var pName = uObj.pathname.toLowerCase();
+                        var oExts = ['.doc','.docx','.xls','.xlsx','.ppt','.pptx','.mdb','.accdb','.msg','.ost','.pst'];
+                        var isOffice = false;
+                        for (var k = 0; k < oExts.length; k++) { if (pName.endsWith(oExts[k])) { isOffice = true; break; } }
+                        if (isOffice) {
+                            processedUrl = 'https://view.officeapps.live.com/op/view.aspx?src=' + encodeURIComponent(url);
+                        } else {
+                            processedUrl = 'https://view.xdocin.com/view?src=' + encodeURIComponent(url);
                         }
-                    });
-                    // 调用原有的预览方法
-                    openPreview(url, type);
-                },
-                error: function() {
-                    // 即使记录失败也打开预览
-                    openPreview(url, type);
+                    } catch (e) {
+                        processedUrl = 'https://view.xdocin.com/view?src=' + encodeURIComponent(url);
+                    }
                 }
-            });
+            }
+            if (processedUrl.indexOf('https://view.xdocin.com/view?src=') !== -1) {
+                processedUrl += '&img=' + (imgFlag ? 'true' : 'false') + '&expire=1';
+            }
+            // 直接用处理后的URL打开窗口(在onclick同步上下文中)
+            var win = window.open(processedUrl, '_blank');
+            trackRead(fileId, function() {
+                return win;
+            }, true);
         }
 
         /** 附件/链接列表弹窗:展示所有文件和链接 */
@@ -412,36 +371,12 @@
             }
         }
 
-        /** 点击链接时记录阅读量和点击量,然后跳转 */
+        /** 点击链接时先打开窗口,再记录阅读计时 */
         function clickLinkWithRecord(element, linkUrl, fileId) {
-            // 记录阅读行为(审核通过后首次点击才触发)
-            $.ajax({
-                type: 'POST',
-                url: '${ctx}/workKnowledgeBase/readLike/recordRead',
-                data: { fileId: fileId },
-                success: function(result) {
-                    if (result.success && result.readCount != null) {
-                        updateCountInList(fileId, 'read-count', result.readCount);
-                    }
-                    // 递增点击量
-                    $.ajax({
-                        type: 'POST',
-                        url: '${ctx}/workKnowledgeBase/readLike/incrementClick',
-                        data: { fileId: fileId },
-                        success: function(clickResult) {
-                            if (clickResult.success && clickResult.clickCount != null) {
-                                updateCountInList(fileId, 'click-count', clickResult.clickCount);
-                            }
-                        }
-                    });
-                    // 打开链接
-                    window.open(linkUrl, '_blank');
-                },
-                error: function() {
-                    // 即使记录失败也打开链接
-                    window.open(linkUrl, '_blank');
-                }
-            });
+            var win = window.open(linkUrl, '_blank');
+            trackRead(fileId, function() {
+                return win;
+            }, true);
         }
 
 
@@ -1271,9 +1206,9 @@
             {field: 'name', align: 'center', title: '文件名称', minWidth: 200, templet: function(d) {
                 // 问答答疑分类使用只读模式,其他分类使用普通模式
                 if (d.isQaCategory) {
-                    return '<a href="javascript:void(0)" class="attention-info" onclick="openQaDetailDialogWithRead(\'' + d.id + '\')">' + (d.name || '') + '</a>';
+                    return '<a href="javascript:void(0)" class="attention-info" onclick="openQaDetailDialog(\'' + d.id + '\')">' + (d.name || '') + '</a>';
                 } else {
-                    return '<a href="javascript:void(0)" class="attention-info" onclick="openDetailDialogWithRead(\'' + d.id + '\')">' + (d.name || '') + '</a>';
+                    return '<a href="javascript:void(0)" class="attention-info" onclick="openDetailDialog(\'' + d.id + '\')">' + (d.name || '') + '</a>';
                 }
             }},
             {field: 'fileUrl', align: 'center', title: '文件地址', minWidth: 200, templet: function(d) {
@@ -1562,12 +1497,235 @@
         });
     });
 
+    console.log('[ReadTrack] ✅ 第二个<script>块已加载, layui.use回调已结束');
+
     resizeListTable();
     resizeListWindow1();
     $(window).resize(function() {
         resizeListWindow1();
     });
+
+    // ========== 阅读计时追踪 ==========
+    var readTracker = {
+        currentFileId: null,
+        startTime: null,
+        previewWin: null,
+        totalDuration: 0,
+        timer: null,
+        pollTimer: null,
+        threshold: 180,
+        originalTitle: document.title
+    };
+    
+    function fmtReadDur(seconds) {
+        var m = Math.floor(seconds / 60);
+        var s = seconds % 60;
+        return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
+    }
+    
+    function showReadBar(pct) {
+        var bar = document.getElementById('readProgressBar');
+        if (bar) { bar.style.display = 'block'; bar.style.width = Math.min(pct, 100) + '%'; }
+    }
+    function hideReadBar() {
+        var bar = document.getElementById('readProgressBar');
+        if (bar) { bar.style.display = 'none'; bar.style.width = '0%'; }
+    }
+    
+    // 可见计时浮层
+    function showReadFloat(elapsed, threshold) {
+        var remaining = Math.max(0, threshold - elapsed);
+        var pct = Math.min(elapsed / threshold * 100, 100);
+        var div = document.getElementById('readFloatTimer');
+        if (!div) {
+            div = document.createElement('div');
+            div.id = 'readFloatTimer';
+            div.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:999999;'
+                + 'background:rgba(0,0,0,0.75);color:#fff;padding:10px 16px;border-radius:8px;'
+                + 'font-size:13px;line-height:1.6;min-width:180px;box-shadow:0 2px 8px rgba(0,0,0,0.3);';
+            document.body.appendChild(div);
+        }
+        div.innerHTML = '<div style="margin-bottom:4px;">&#128214; 阅读计时</div>'
+            + '<div>已读: <b>' + fmtReadDur(elapsed) + '</b> / ' + fmtReadDur(threshold) + '</div>'
+            + '<div>剩余: <b style="color:' + (remaining > 0 ? '#ffeb3b' : '#4caf50') + '">'
+            + (remaining > 0 ? fmtReadDur(remaining) : '达标!') + '</b></div>'
+            + '<div style="margin-top:6px;height:4px;background:#555;border-radius:2px;overflow:hidden;">'
+            + '<div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,#ff9800,#4caf50);transition:width 1s;"></div></div>';
+        div.style.display = 'block';
+    }
+    function hideReadFloat() {
+        var div = document.getElementById('readFloatTimer');
+        if (div) div.style.display = 'none';
+    }
+    
+    function doSettle(fid, duration, origTitle, granted, totalDuration, elapsed, alreadyGranted) {
+        var threshold = readTracker.threshold;
+        var accumulated = (totalDuration && totalDuration > 0) ? totalDuration : (elapsed || duration);
+        // 以服务端判定为准:本次新发放 or 之前已发放过
+        if (granted || alreadyGranted) {
+            document.title = '[\u2705 已获积分] ' + origTitle;
+            layer.msg('\u9605\u8bfb\u65f6\u95f4\u5df2\u8fbe\u6807\uff0c\u79ef\u5206\u5df2\u53d1\u653e\uff01', {icon: 1, time: 3000});
+            setTimeout(function() { document.title = origTitle; }, 5000);
+        } else {
+            document.title = origTitle;
+            var remaining = Math.max(0, threshold - accumulated);
+            var remainText = fmtReadDur(remaining);
+            layer.msg('\u672c\u6b21\u9605\u8bfb ' + fmtReadDur(duration) + '\uff0c\u7d2f\u8ba1 ' + fmtReadDur(accumulated) + '\uff0c\u672a\u8fbe\u6807\u3002\u8fd8\u9700\u9605\u8bfb ' + remainText + ' \u624d\u80fd\u83b7\u5f97\u79ef\u5206\u3002', {icon: 0, time: 5000});
+        }
+    }
+    
+    function settleCurrentRead() {
+        console.log('[ReadTrack] settleCurrentRead 被调用', {
+            fileId: readTracker.currentFileId,
+            startTime: readTracker.startTime,
+            layerIndex: readTracker.layerIndex,
+            previewWin: readTracker.previewWin
+        });
+        if (!readTracker.currentFileId || !readTracker.startTime) {
+            console.log('[ReadTrack] settleCurrentRead 跳过:currentFileId 或 startTime 为空');
+            return;
+        }
+        var duration = Math.max(1, Math.floor((Date.now() - readTracker.startTime) / 1000));
+        var fid = readTracker.currentFileId;
+        var origTitle = readTracker.originalTitle;
+        var elapsed = readTracker.totalDuration + duration; // 保存累计时长(清除前)
+        console.log('[ReadTrack] 结算时长:', duration, '秒, 累计:', elapsed, '秒, fileId:', fid);
+        // 立即停止所有定时器
+        if (readTracker.timer) { clearInterval(readTracker.timer); readTracker.timer = null; }
+        if (readTracker.pollTimer) { clearInterval(readTracker.pollTimer); readTracker.pollTimer = null; }
+        hideReadBar();
+        hideReadFloat();
+        readTracker.currentFileId = null;
+        readTracker.startTime = null;
+        readTracker.previewWin = null;
+        readTracker.layerIndex = null;
+        readTracker.totalDuration = 0;
+        document.title = origTitle;
+        $.ajax({
+            type: 'POST',
+            url: '${ctx}/workKnowledgeBase/readLike/updateReadDuration',
+            data: { fileId: fid, duration: duration },
+            success: function(res) {
+                console.log('[ReadTrack] AJAX 成功:', res);
+                doSettle(fid, duration, origTitle, res && res.granted, res && res.totalDuration, elapsed, res && res.alreadyGranted);
+            },
+            error: function() {
+                console.log('[ReadTrack] AJAX 失败');
+                document.title = origTitle;
+            }
+        });
+    }
+    
+    function startReadTimer() {
+        if (readTracker.timer) clearInterval(readTracker.timer);
+        readTracker.timer = setInterval(function() {
+            // 防御性检查
+            if (!readTracker.currentFileId || !readTracker.startTime) {
+                clearInterval(readTracker.timer);
+                readTracker.timer = null;
+                return;
+            }
+            var elapsed = readTracker.totalDuration + Math.floor((Date.now() - readTracker.startTime) / 1000);
+            var pct = Math.min(elapsed / readTracker.threshold * 100, 100);
+            document.title = '[ ' + fmtReadDur(elapsed) + '/' + fmtReadDur(readTracker.threshold) + '] ' + readTracker.originalTitle;
+            showReadBar(pct);
+            showReadFloat(elapsed, readTracker.threshold);
+            // 不再自动结算,等用户关闭窗口时统一处理
+        }, 1000);
+    }
+    
+    // 统一轮询:检测 layer 弹窗关闭 + 新窗口关闭
+    function startClosePolling() {
+        if (readTracker.pollTimer) clearInterval(readTracker.pollTimer);
+        readTracker.pollTimer = setInterval(function() {
+            if (!readTracker.currentFileId) {
+                clearInterval(readTracker.pollTimer);
+                readTracker.pollTimer = null;
+                return;
+            }
+            var shouldSettle = false;
+            var reason = '';
+            // 检测新窗口关闭
+            if (readTracker.previewWin) {
+                try {
+                    if (readTracker.previewWin.closed) { shouldSettle = true; reason = 'previewWin.closed'; }
+                } catch(e) { shouldSettle = true; reason = 'previewWin异常'; }
+            }
+            // 检测 layer 弹窗是否还活着(通过 DOM 元素是否存在判断)
+            if (!shouldSettle && readTracker.layerIndex != null) {
+                try {
+                    var layerEl = top.document.getElementById('layui-layer' + readTracker.layerIndex);
+                    console.log('[ReadTrack] 轮询检查 layer:', readTracker.layerIndex, '元素存在:', !!layerEl);
+                    if (!layerEl) { shouldSettle = true; reason = 'layer DOM不存在'; }
+                } catch(e) {
+                    shouldSettle = true; reason = 'layer检查异常:' + e.message;
+                }
+            }
+            if (shouldSettle) {
+                console.log('[ReadTrack] 检测到关闭, 原因:', reason);
+                clearInterval(readTracker.pollTimer);
+                readTracker.pollTimer = null;
+                settleCurrentRead();
+            }
+        }, 1000);
+    }
+
+    /** 统一阅读追踪入口:先打开窗口(避免弹窗拦截),再异步记录 */
+    function trackRead(fileId, openFn, isPreview) {
+        // 1. 结算上一个文件
+        if (readTracker.currentFileId) {
+            settleCurrentRead();
+        }
+        // 2. 先打开窗口/弹窗(在用户点击的同步上下文中,避免浏览器拦截)
+        var openResult = openFn();
+        // 3. 异步记录阅读行为,获取累计时长后启动计时
+        $.ajax({
+            type: 'POST',
+            url: '${ctx}/workKnowledgeBase/readLike/recordRead',
+            data: { fileId: fileId },
+            success: function(result) {
+                var accumulated = (result && result.totalReadDuration) || 0;
+                readTracker.totalDuration = accumulated;
+                readTracker.startTime = Date.now();
+                if (accumulated > 0) {
+                    console.log('[ReadTrack] 恢复累计时长:', accumulated, '秒');
+                }
+                startReadTimer();
+            },
+            error: function() {
+                readTracker.startTime = Date.now();
+                startReadTimer();
+            }
+        });
+        $.ajax({
+            type: 'POST',
+            url: '${ctx}/workKnowledgeBase/readLike/incrementClick',
+            data: { fileId: fileId }
+        });
+        // 4. 初始化追踪状态(startReadTimer 在 AJAX 回调中启动)
+        readTracker.currentFileId = fileId;
+        readTracker.startTime = Date.now();
+        readTracker.totalDuration = 0;
+        readTracker.layerIndex = null;
+        readTracker.previewWin = null;
+        if (isPreview) {
+            readTracker.previewWin = openResult;
+            console.log('[ReadTrack] 新窗口 previewWin:', openResult ? 'OK' : 'null(被拦截)');
+        } else {
+            if (typeof openResult === 'number') {
+                readTracker.layerIndex = openResult;
+                console.log('[ReadTrack] layer弹窗 layerIndex:', openResult);
+            } else {
+                console.log('[ReadTrack] 警告: layer弹窗未返回index, ret=', openResult);
+            }
+        }
+        startClosePolling();
+    }
 </script>
+
+<!-- 阅读计时进度条 -->
+<div id="readProgressBar" style="position:fixed;top:0;left:0;height:3px;background:linear-gradient(90deg,#ff9800,#4caf50);z-index:999999;width:0%;transition:width 1s;display:none;"></div>
+
 </c:if>
 </body>
 </html>