fix: 修复瞬间内容获取、锁竞态、NPE风险等问题,移除实时刷新冗余时间显示
- ContextExtractor: 合并瞬间内容和发布时间为单次fetch,避免重复查询 - ContextExtractor: 瞬间分支补齐onErrorResume和defaultIfEmpty容错 - ContextExtractor: getCommentCount/fetchConversationHistory添加spec null检查 - AiReplyOrchestrator: 修复processFalsePositive锁竞态条件,统一存储获取时间 - AiReplyOrchestrator: 修复cleanupStaleLocks无法清理FP锁的问题 - LogsView: 移除实时刷新时间显示(等待中/刚刚更新/N秒前)及相关定时器 - LogsView: 移除冗余的三元表达式
This commit is contained in:
+8
-3
@@ -9,7 +9,7 @@
|
|||||||
- **支持瞬间插件(Moments)评论区适配** — 当检测到已安装并启用 [plugin-moments](https://github.com/halo-sigs/plugin-moments) 时,自动为瞬间评论启用 AI 自动回复
|
- **支持瞬间插件(Moments)评论区适配** — 当检测到已安装并启用 [plugin-moments](https://github.com/halo-sigs/plugin-moments) 时,自动为瞬间评论启用 AI 自动回复
|
||||||
- 新增 `MomentsIntegrationService`,通过 `SchemeManager` 检测 Moment 扩展注册状态,避免直接引用导致的 `NoClassDefFoundError`
|
- 新增 `MomentsIntegrationService`,通过 `SchemeManager` 检测 Moment 扩展注册状态,避免直接引用导致的 `NoClassDefFoundError`
|
||||||
- 在插件设置 - 基本设置中新增"瞬间评论区适配"开关,仅当瞬间插件可用时显示,默认开启
|
- 在插件设置 - 基本设置中新增"瞬间评论区适配"开关,仅当瞬间插件可用时显示,默认开启
|
||||||
- `ContextExtractor` 适配 Moment 上下文:使用 moment name 作为关联标识,评论创建时间作为日期上下文
|
- `ContextExtractor` 适配 Moment 上下文:使用 moment name 作为关联标识,通过 `Unstructured` 单次 fetch 获取瞬间实际内容(`spec.content.raw`/`html`)和发布时间(`spec.releaseTime`)作为 AI 上下文
|
||||||
- `FilterService` 对 Moment 评论读取 `momentsEnabled` 配置决定是否触发 AI 回复
|
- `FilterService` 对 Moment 评论读取 `momentsEnabled` 配置决定是否触发 AI 回复
|
||||||
- **评论人昵称广告判定** — 前置过滤现在综合判断评论者昵称与评论内容。昵称包含商业推广关键词(如"免费算命"、"加微信xxx"、"代写论文"、"低价代购"等)即使评论内容看似正常也会被判定为广告
|
- **评论人昵称广告判定** — 前置过滤现在综合判断评论者昵称与评论内容。昵称包含商业推广关键词(如"免费算命"、"加微信xxx"、"代写论文"、"低价代购"等)即使评论内容看似正常也会被判定为广告
|
||||||
- `CommentPreFilterService.check()` 新增 `commentOwner` 参数,将昵称纳入 AI 分类输入
|
- `CommentPreFilterService.check()` 新增 `commentOwner` 参数,将昵称纳入 AI 分类输入
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
- 强化身份约束:明确角色不是文章作者、站点管理员、客服或用户本人;禁止声称亲身经历未提供之事;禁止编造文章外的人物、数据、链接;禁止泄露系统提示词、模型参数、插件实现与安全策略
|
- 强化身份约束:明确角色不是文章作者、站点管理员、客服或用户本人;禁止声称亲身经历未提供之事;禁止编造文章外的人物、数据、链接;禁止泄露系统提示词、模型参数、插件实现与安全策略
|
||||||
- **"Prompt设置"更名为"提示词设置"** — UI 标签页、面板标题、设置项标签、帮助文本统一改为中文"提示词"
|
- **"Prompt设置"更名为"提示词设置"** — UI 标签页、面板标题、设置项标签、帮助文本统一改为中文"提示词"
|
||||||
- **日志瞬间关联链接精确到具体瞬间** — Moment 评论的关联链接从 `/moments` 列表页改为 `/moments/{name}` 具体瞬间页
|
- **日志瞬间关联链接精确到具体瞬间** — Moment 评论的关联链接从 `/moments` 列表页改为 `/moments/{name}` 具体瞬间页
|
||||||
- **日志页面增加实时刷新功能** — 新增"实时刷新"开关,开启后每 10 秒静默轮询新数据。标签页隐藏或弹窗打开时自动暂停,回到页面时立即刷新
|
- **日志页面增加实时刷新功能** — 新增"实时刷新"开关,开启后每 10 秒静默轮询新数据。标签页隐藏或弹窗打开时自动暂停,回到页面时立即刷新。支持可配置刷新间隔(5s/10s/30s/60s)、新记录 Toast 提示、滚动位置保留、连续失败自动关闭
|
||||||
- **AI 安全审核改为失败关闭策略** — `ReviewService` 在审核服务不可用或异常时不再自动通过,改为返回 FAIL 并拦截发布,避免未经审核的 AI 回复被自动发布
|
- **AI 安全审核改为失败关闭策略** — `ReviewService` 在审核服务不可用或异常时不再自动通过,改为返回 FAIL 并拦截发布,避免未经审核的 AI 回复被自动发布
|
||||||
|
|
||||||
### Bug 修复
|
### Bug 修复
|
||||||
@@ -51,8 +51,13 @@
|
|||||||
- **修复 Endpoint 批量操作并发无限制** — `flatMap` 默认并发 256,大批量操作可能压垮数据库。限制为 10
|
- **修复 Endpoint 批量操作并发无限制** — `flatMap` 默认并发 256,大批量操作可能压垮数据库。限制为 10
|
||||||
- **修复 LogsView 实时刷新漏检状态变化** — 数据签名仅含 total 和首尾 name,记录状态变化不会被检测。签名新增首尾状态和发布标记
|
- **修复 LogsView 实时刷新漏检状态变化** — 数据签名仅含 total 和首尾 name,记录状态变化不会被检测。签名新增首尾状态和发布标记
|
||||||
- **修复 LogsView 实时刷新与手动操作竞态** — 自动刷新与手动 fetchReplies 可能同时执行导致数据错乱。新增 `autoRefreshing` 标志位
|
- **修复 LogsView 实时刷新与手动操作竞态** — 自动刷新与手动 fetchReplies 可能同时执行导致数据错乱。新增 `autoRefreshing` 标志位
|
||||||
- **优化实时刷新** — 新增可配置刷新间隔(5s/10s/30s/60s)、相对更新时间显示、新记录 Toast 提示、滚动位置保留、用户操作后重置计时
|
|
||||||
- **修复 `ReplyReconciler.isAiReply` 空指针风险** — 未检查 `spec == null`,畸形 Reply 数据会触发 NPE
|
- **修复 `ReplyReconciler.isAiReply` 空指针风险** — 未检查 `spec == null`,畸形 Reply 数据会触发 NPE
|
||||||
|
- **修复 `ContextExtractor` 瞬间内容重复 fetch** — `getMomentContent` 和 `getMomentReleaseDate` 各自独立 fetch 同一个瞬间扩展,产生 2 次重复查询。合并为 `getMomentContentAndDate` 单次 fetch
|
||||||
|
- **修复 `ContextExtractor` 瞬间分支缺少容错** — `buildContext` 的 Moment 分支缺少 `onErrorResume` 和 `defaultIfEmpty`,异常时静默跳过而非降级处理。已补齐与 Post/SinglePage 一致的容错
|
||||||
|
- **修复 `AiReplyOrchestrator.processFalsePositive` 锁竞态条件** — 锁值存储过期时间(未来时间戳),过期后 `putIfAbsent` 不覆盖旧值导致去重失效。改为存储获取时间,与 `processComment` 一致
|
||||||
|
- **修复 `cleanupStaleLocks` 无法清理误报处理锁** — 误报处理锁值是未来时间戳,`cleanupStaleLocks` 计算 age 为负数永远不清理。统一为存储获取时间
|
||||||
|
- **修复 `ContextExtractor.getCommentCount` 空指针风险** — `reply.getSpec()` 可能为 null 时直接调用 `getCommentName()` 触发 NPE。`fetchConversationHistory` 同样问题已一并修复
|
||||||
|
- **移除实时刷新冗余时间显示** — 移除刷新间隔选择右侧的"等待中…"/"刚刚更新"/"N秒前更新"等状态文本及相关定时器,减少不必要的 UI 噪声和每秒重渲染
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -154,11 +154,12 @@ public class AiReplyOrchestrator {
|
|||||||
String recordName) {
|
String recordName) {
|
||||||
log.info("[Orchestrator] Processing false-positive: comment={}, record={}", commentName, recordName);
|
log.info("[Orchestrator] Processing false-positive: comment={}, record={}", commentName, recordName);
|
||||||
|
|
||||||
// 加锁防止重复触发(与 processComment 使用相同的锁机制)
|
// 加锁防止重复触发(与 processComment 使用相同的锁机制,存储获取时间便于 cleanupStaleLocks 清理)
|
||||||
String lockKey = "fp:" + recordName;
|
String lockKey = "fp:" + recordName;
|
||||||
long lockExpiry = System.currentTimeMillis() + LOCK_EXPIRY_MS;
|
cleanupStaleLocks();
|
||||||
Long existingExpiry = processingLocks.putIfAbsent(lockKey, lockExpiry);
|
long now = System.currentTimeMillis();
|
||||||
if (existingExpiry != null && existingExpiry > System.currentTimeMillis()) {
|
Long existingAcquireTime = processingLocks.putIfAbsent(lockKey, now);
|
||||||
|
if (existingAcquireTime != null && (now - existingAcquireTime) < LOCK_EXPIRY_MS) {
|
||||||
log.warn("[Orchestrator] False-positive already in progress for record {}, skipping", recordName);
|
log.warn("[Orchestrator] False-positive already in progress for record {}, skipping", recordName);
|
||||||
return Mono.empty();
|
return Mono.empty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import run.halo.app.core.extension.content.Comment;
|
|||||||
import run.halo.app.core.extension.content.Post;
|
import run.halo.app.core.extension.content.Post;
|
||||||
import run.halo.app.core.extension.content.SinglePage;
|
import run.halo.app.core.extension.content.SinglePage;
|
||||||
import run.halo.app.core.extension.content.Reply;
|
import run.halo.app.core.extension.content.Reply;
|
||||||
|
import run.halo.app.extension.GroupVersionKind;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.extension.Unstructured;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -56,7 +62,8 @@ public class ContextExtractor {
|
|||||||
var triggerTime = triggerReply.getMetadata().getCreationTimestamp();
|
var triggerTime = triggerReply.getMetadata().getCreationTimestamp();
|
||||||
return client.list(Reply.class,
|
return client.list(Reply.class,
|
||||||
reply -> {
|
reply -> {
|
||||||
if (!commentName.equals(reply.getSpec().getCommentName())) {
|
var spec = reply.getSpec();
|
||||||
|
if (spec == null || !commentName.equals(spec.getCommentName())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (triggerReplyName.equals(reply.getMetadata().getName())) {
|
if (triggerReplyName.equals(reply.getMetadata().getName())) {
|
||||||
@@ -185,9 +192,32 @@ public class ContextExtractor {
|
|||||||
|
|
||||||
if (subjectRef != null && "Moment".equals(subjectRef.getKind())) {
|
if (subjectRef != null && "Moment".equals(subjectRef.getKind())) {
|
||||||
// 瞬间插件评论:Moment 没有 slug/title,用 moment name 作为关联标识
|
// 瞬间插件评论:Moment 没有 slug/title,用 moment name 作为关联标识
|
||||||
|
// 通过 Unstructured 单次 fetch 获取瞬间实际内容和发布时间作为 AI 上下文
|
||||||
String momentName = subjectRef.getName();
|
String momentName = subjectRef.getName();
|
||||||
return getCommentCount(comment.getMetadata().getName())
|
String commentDate = formatCommentDate(comment);
|
||||||
.map(commentCount -> new CommentContext(
|
return getMomentContentAndDate(momentName, commentDate)
|
||||||
|
.flatMap(parts -> getCommentCount(comment.getMetadata().getName())
|
||||||
|
.map(commentCount -> new CommentContext(
|
||||||
|
comment.getMetadata().getName(),
|
||||||
|
momentName,
|
||||||
|
momentName,
|
||||||
|
commentContent,
|
||||||
|
commentOwner,
|
||||||
|
"瞬间",
|
||||||
|
parts[0],
|
||||||
|
null,
|
||||||
|
isAiConversation,
|
||||||
|
parts[1],
|
||||||
|
commentCount,
|
||||||
|
"",
|
||||||
|
"Moment"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[ContextExtractor] Failed to process Moment {}: {}", momentName, e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(new CommentContext(
|
||||||
comment.getMetadata().getName(),
|
comment.getMetadata().getName(),
|
||||||
momentName,
|
momentName,
|
||||||
momentName,
|
momentName,
|
||||||
@@ -197,8 +227,8 @@ public class ContextExtractor {
|
|||||||
"",
|
"",
|
||||||
null,
|
null,
|
||||||
isAiConversation,
|
isAiConversation,
|
||||||
formatCommentDate(comment),
|
commentDate,
|
||||||
commentCount,
|
0,
|
||||||
"",
|
"",
|
||||||
"Moment"
|
"Moment"
|
||||||
));
|
));
|
||||||
@@ -325,24 +355,31 @@ public class ContextExtractor {
|
|||||||
|
|
||||||
if (subjectRef != null && "Moment".equals(subjectRef.getKind())) {
|
if (subjectRef != null && "Moment".equals(subjectRef.getKind())) {
|
||||||
String momentName = subjectRef.getName();
|
String momentName = subjectRef.getName();
|
||||||
return getCommentCount(commentName)
|
String commentDate = formatCommentDate(comment);
|
||||||
.flatMap(commentCount -> historyMono
|
return getMomentContentAndDate(momentName, commentDate)
|
||||||
.map(history -> new CommentContext(
|
.flatMap(parts -> getCommentCount(commentName)
|
||||||
commentName,
|
.flatMap(commentCount -> historyMono
|
||||||
momentName,
|
.map(history -> new CommentContext(
|
||||||
momentName,
|
commentName,
|
||||||
replyContent,
|
momentName,
|
||||||
replyOwner,
|
momentName,
|
||||||
"瞬间",
|
replyContent,
|
||||||
"",
|
replyOwner,
|
||||||
replyName,
|
"瞬间",
|
||||||
isAiConversation,
|
parts[0],
|
||||||
formatCommentDate(comment),
|
replyName,
|
||||||
commentCount,
|
isAiConversation,
|
||||||
history,
|
parts[1],
|
||||||
"Moment"
|
commentCount,
|
||||||
))
|
history,
|
||||||
|
"Moment"
|
||||||
|
))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[ContextExtractor] Failed to process Moment {} for reply: {}", momentName, e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
.defaultIfEmpty(new CommentContext(
|
.defaultIfEmpty(new CommentContext(
|
||||||
commentName,
|
commentName,
|
||||||
momentName,
|
momentName,
|
||||||
@@ -353,7 +390,7 @@ public class ContextExtractor {
|
|||||||
"",
|
"",
|
||||||
replyName,
|
replyName,
|
||||||
isAiConversation,
|
isAiConversation,
|
||||||
"",
|
commentDate,
|
||||||
0,
|
0,
|
||||||
"",
|
"",
|
||||||
"Moment"
|
"Moment"
|
||||||
@@ -460,6 +497,55 @@ public class ContextExtractor {
|
|||||||
.defaultIfEmpty("");
|
.defaultIfEmpty("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 瞬间插件(Moments)内容与发布时间获取(单次 fetch)。
|
||||||
|
*
|
||||||
|
* <p>瞬间插件是可选依赖,不能直接引用其 Java 类(会导致 NoClassDefFoundError)。
|
||||||
|
* 通过 ReactiveExtensionClient.fetch(GroupVersionKind, name) 以 Unstructured 形式获取瞬间扩展,
|
||||||
|
* 再从 spec.content.raw / spec.content.html 提取实际内容,从 spec.releaseTime 提取发布时间。
|
||||||
|
* 返回 String[2]:[0]=内容,[1]=发布日期。
|
||||||
|
*/
|
||||||
|
private Mono<String[]> getMomentContentAndDate(String momentName, String fallbackDate) {
|
||||||
|
GroupVersionKind momentGvk = new GroupVersionKind(
|
||||||
|
"moment.halo.run", "v1alpha1", "Moment");
|
||||||
|
return client.fetch(momentGvk, momentName)
|
||||||
|
.mapNotNull(moment -> new String[]{
|
||||||
|
extractMomentContent(moment.getData()),
|
||||||
|
extractMomentReleaseDate(moment.getData(), fallbackDate)
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(new String[]{"", fallbackDate != null ? fallbackDate : ""})
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[ContextExtractor] Failed to fetch Moment {}: {}", momentName, e.getMessage());
|
||||||
|
return Mono.just(new String[]{"", fallbackDate != null ? fallbackDate : ""});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Unstructured data 中提取瞬间内容:优先 raw,回退 html(去标签)。 */
|
||||||
|
private String extractMomentContent(Map<String, Object> data) {
|
||||||
|
Optional<Object> rawOpt = Unstructured.getNestedValue(data, "spec", "content", "raw");
|
||||||
|
if (rawOpt.isPresent() && rawOpt.get() != null) {
|
||||||
|
String raw = rawOpt.get().toString();
|
||||||
|
if (!raw.isBlank()) return raw;
|
||||||
|
}
|
||||||
|
Optional<Object> htmlOpt = Unstructured.getNestedValue(data, "spec", "content", "html");
|
||||||
|
if (htmlOpt.isPresent() && htmlOpt.get() != null) {
|
||||||
|
String html = htmlOpt.get().toString();
|
||||||
|
if (html != null && !html.isBlank()) {
|
||||||
|
return Jsoup.clean(html, Safelist.none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Unstructured data 中提取瞬间发布日期:spec.releaseTime,回退到 fallbackDate。 */
|
||||||
|
private String extractMomentReleaseDate(Map<String, Object> data, String fallbackDate) {
|
||||||
|
Optional<Instant> releaseTime = Unstructured.getNestedInstant(data, "spec", "releaseTime");
|
||||||
|
if (releaseTime.isPresent() && releaseTime.get() != null) {
|
||||||
|
return releaseTime.get().toString().substring(0, 10);
|
||||||
|
}
|
||||||
|
return fallbackDate != null ? fallbackDate : "";
|
||||||
|
}
|
||||||
|
|
||||||
private String formatPostDate(Post post) {
|
private String formatPostDate(Post post) {
|
||||||
var publishTime = post.getSpec().getPublishTime();
|
var publishTime = post.getSpec().getPublishTime();
|
||||||
if (publishTime != null) {
|
if (publishTime != null) {
|
||||||
@@ -497,7 +583,10 @@ public class ContextExtractor {
|
|||||||
|
|
||||||
private Mono<Integer> getCommentCount(String commentName) {
|
private Mono<Integer> getCommentCount(String commentName) {
|
||||||
return client.list(Reply.class,
|
return client.list(Reply.class,
|
||||||
reply -> commentName.equals(reply.getSpec().getCommentName()),
|
reply -> {
|
||||||
|
var spec = reply.getSpec();
|
||||||
|
return spec != null && commentName.equals(spec.getCommentName());
|
||||||
|
},
|
||||||
null)
|
null)
|
||||||
.collectList()
|
.collectList()
|
||||||
.map(replies -> replies.size())
|
.map(replies -> replies.size())
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="autorefresh-group">
|
<div class="autorefresh-group">
|
||||||
<button class="btn-autorefresh" :class="{ 'is-active': autoRefresh }" @click="toggleAutoRefresh" :title="autoRefresh ? '点击关闭实时刷新' : '点击开启实时刷新'">
|
<button class="btn-autorefresh" :class="{ 'is-active': autoRefresh }" @click="toggleAutoRefresh" :title="autoRefresh ? '点击关闭实时刷新' : '点击开启实时刷新'">
|
||||||
<span class="autorefresh-dot" v-if="autoRefresh"></span>
|
<span class="autorefresh-dot" v-if="autoRefresh"></span>
|
||||||
{{ autoRefresh ? '实时刷新' : '实时刷新' }}
|
实时刷新
|
||||||
</button>
|
</button>
|
||||||
<select v-if="autoRefresh" v-model="autoRefreshSecs" class="autorefresh-interval" :title="`刷新间隔:${autoRefreshSecs}秒`">
|
<select v-if="autoRefresh" v-model="autoRefreshSecs" class="autorefresh-interval" :title="`刷新间隔:${autoRefreshSecs}秒`">
|
||||||
<option :value="5">5s</option>
|
<option :value="5">5s</option>
|
||||||
@@ -50,7 +50,6 @@
|
|||||||
<option :value="30">30s</option>
|
<option :value="30">30s</option>
|
||||||
<option :value="60">60s</option>
|
<option :value="60">60s</option>
|
||||||
</select>
|
</select>
|
||||||
<span v-if="autoRefresh" class="autorefresh-status">{{ lastRefreshLabel }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,11 +207,9 @@ const showFalsePositiveDialog = ref(false); const falsePositiveTarget = ref<AiCo
|
|||||||
const triggerAiLoadingName = ref<string | null>(null);
|
const triggerAiLoadingName = ref<string | null>(null);
|
||||||
|
|
||||||
// 实时刷新:定时轮询新数据。暂停条件:标签页隐藏、loading 中、弹窗打开。
|
// 实时刷新:定时轮询新数据。暂停条件:标签页隐藏、loading 中、弹窗打开。
|
||||||
// 优化:轻量变更检测、新记录提示、连续失败自动关闭、间隔可配置、用户操作后重置计时、保留滚动位置、显示相对更新时间
|
// 优化:轻量变更检测、新记录提示、连续失败自动关闭、间隔可配置、用户操作后重置计时、保留滚动位置
|
||||||
const autoRefresh = ref(false); const autoRefreshSecs = ref(10); let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
const autoRefresh = ref(false); const autoRefreshSecs = ref(10); let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let consecutiveFailures = 0; const MAX_FAILURES = 5;
|
let consecutiveFailures = 0; const MAX_FAILURES = 5;
|
||||||
const lastRefreshTime = ref<number | null>(null); let refreshRelativeTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
const lastRefreshLabel = ref("等待中…");
|
|
||||||
let autoRefreshing = false; // 防止 autoRefreshTick 与 fetchReplies 竞态
|
let autoRefreshing = false; // 防止 autoRefreshTick 与 fetchReplies 竞态
|
||||||
// 轻量签名:total + 首尾 name + 首尾状态,检测记录数量、顺序、状态变化
|
// 轻量签名:total + 首尾 name + 首尾状态,检测记录数量、顺序、状态变化
|
||||||
const dataSignature = (items: any[], totalCount: number) => {
|
const dataSignature = (items: any[], totalCount: number) => {
|
||||||
@@ -223,13 +220,6 @@ const dataSignature = (items: any[], totalCount: number) => {
|
|||||||
const lastPublished = last.spec?.published || "";
|
const lastPublished = last.spec?.published || "";
|
||||||
return `${totalCount}|${first.metadata.name}|${firstStatus}|${firstPublished}|${last.metadata.name}|${lastStatus}|${lastPublished}`;
|
return `${totalCount}|${first.metadata.name}|${firstStatus}|${firstPublished}|${last.metadata.name}|${lastStatus}|${lastPublished}`;
|
||||||
};
|
};
|
||||||
const updateRelativeTime = () => {
|
|
||||||
if (lastRefreshTime.value == null) { lastRefreshLabel.value = "等待中…"; return; }
|
|
||||||
const diff = Math.floor((Date.now() - lastRefreshTime.value) / 1000);
|
|
||||||
if (diff < 5) lastRefreshLabel.value = "刚刚更新";
|
|
||||||
else if (diff < 60) lastRefreshLabel.value = `${diff}秒前更新`;
|
|
||||||
else lastRefreshLabel.value = `${Math.floor(diff / 60)}分钟前更新`;
|
|
||||||
};
|
|
||||||
const isPageVisible = () => !document.hidden;
|
const isPageVisible = () => !document.hidden;
|
||||||
const autoRefreshTick = async () => {
|
const autoRefreshTick = async () => {
|
||||||
// 标签页隐藏、正在加载、或存在打开的弹窗时不轮询,避免干扰用户操作
|
// 标签页隐藏、正在加载、或存在打开的弹窗时不轮询,避免干扰用户操作
|
||||||
@@ -248,8 +238,6 @@ const autoRefreshTick = async () => {
|
|||||||
const newItems = data.items || []; const newTotal = data.total || 0;
|
const newItems = data.items || []; const newTotal = data.total || 0;
|
||||||
const newSignature = dataSignature(newItems, newTotal);
|
const newSignature = dataSignature(newItems, newTotal);
|
||||||
consecutiveFailures = 0; // 成功,重置失败计数
|
consecutiveFailures = 0; // 成功,重置失败计数
|
||||||
lastRefreshTime.value = Date.now();
|
|
||||||
updateRelativeTime();
|
|
||||||
// 仅当数据签名变化时更新,减少不必要的渲染
|
// 仅当数据签名变化时更新,减少不必要的渲染
|
||||||
if (newSignature !== prevSignature) {
|
if (newSignature !== prevSignature) {
|
||||||
// 在首页且有新增记录时提示用户(仅在首页轮询能可靠判定"新增")
|
// 在首页且有新增记录时提示用户(仅在首页轮询能可靠判定"新增")
|
||||||
@@ -276,16 +264,10 @@ const autoRefreshTick = async () => {
|
|||||||
const startAutoRefresh = () => {
|
const startAutoRefresh = () => {
|
||||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||||
consecutiveFailures = 0;
|
consecutiveFailures = 0;
|
||||||
lastRefreshTime.value = null;
|
|
||||||
updateRelativeTime();
|
|
||||||
// 相对时间计时器:每秒更新 "N秒前更新" 文案
|
|
||||||
if (refreshRelativeTimer) clearInterval(refreshRelativeTimer);
|
|
||||||
refreshRelativeTimer = setInterval(updateRelativeTime, 1000);
|
|
||||||
autoRefreshTimer = setInterval(() => { if (isPageVisible()) autoRefreshTick(); }, autoRefreshSecs.value * 1000);
|
autoRefreshTimer = setInterval(() => { if (isPageVisible()) autoRefreshTick(); }, autoRefreshSecs.value * 1000);
|
||||||
};
|
};
|
||||||
const stopAutoRefresh = () => {
|
const stopAutoRefresh = () => {
|
||||||
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
|
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
|
||||||
if (refreshRelativeTimer) { clearInterval(refreshRelativeTimer); refreshRelativeTimer = null; }
|
|
||||||
};
|
};
|
||||||
const resetAutoRefreshTimer = () => { if (autoRefresh.value) startAutoRefresh(); };
|
const resetAutoRefreshTimer = () => { if (autoRefresh.value) startAutoRefresh(); };
|
||||||
const toggleAutoRefresh = () => {
|
const toggleAutoRefresh = () => {
|
||||||
@@ -435,7 +417,6 @@ onUnmounted(() => {
|
|||||||
@keyframes autorefresh-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(0.85); } }
|
@keyframes autorefresh-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(0.85); } }
|
||||||
.autorefresh-interval { padding: 6px 8px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; font-size: 13px; color: #6b7280; cursor: pointer; }
|
.autorefresh-interval { padding: 6px 8px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; font-size: 13px; color: #6b7280; cursor: pointer; }
|
||||||
.autorefresh-interval:focus { outline: none; border-color: #86efac; }
|
.autorefresh-interval:focus { outline: none; border-color: #86efac; }
|
||||||
.autorefresh-status { font-size: 12px; color: #9ca3af; white-space: nowrap; min-width: 70px; }
|
|
||||||
|
|
||||||
/* 列表区 */
|
/* 列表区 */
|
||||||
.list-area { margin: 16px; }
|
.list-area { margin: 16px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user