fix: 修复重试计数、误报反馈、响应式阻塞等问题,版本号更新为v1.2.1

修复 AiReplyOrchestrator.retryOrFail 使用 .then() 导致 retryCount 失效;修复误报反馈端点 .subscribe() 过早触发覆盖 AI 回复;修复 PersonaResolver 在响应式上下文使用 .block() 阻塞线程;修复 penalize/approveOriginalComment 缺少乐观锁重试;修复误报反馈端点无法重试 FAIL 状态;修复 tag-NEUTRAL 缺少样式、handleTriggerAiReply 缺少加载保护;修复 filterKeyword 未做防抖、performCleanup 逻辑错误;优化 extractChoice 优先匹配违规类别避免误判
This commit is contained in:
sunny-335
2026-07-01 10:52:28 +08:00
parent 9ac25c4081
commit 615935d947
10 changed files with 167 additions and 88 deletions
+22 -7
View File
@@ -76,7 +76,10 @@
<svg class="filter-icon fp-icon-ok" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
<span class="filter-category">误报</span>
<span class="filter-detail">{{ reply.spec.filterReason || '用户确认为误报' }}</span>
<button class="btn-trigger-ai" @click="handleTriggerAiReply(reply)">触发AI回复</button>
<button class="btn-trigger-ai" :disabled="triggerAiLoadingName === reply.metadata.name" @click="handleTriggerAiReply(reply)">
<span v-if="triggerAiLoadingName === reply.metadata.name" class="fp-spinner"></span>
触发AI回复
</button>
</div>
</div>
</div>
@@ -176,7 +179,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue"
import { ref, onMounted, onUnmounted, watch } from "vue"
import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components"
@@ -189,6 +192,7 @@ const selectedNames = ref<Set<string>>(new Set()); const selectAll = ref(false);
const filterStatus = ref(""); const filterSentiment = ref(""); const filterKeyword = ref("");
const showDialog = ref(false); const conversationLoading = ref(false); const conversationMessages = ref<ConversationMessage[]>([]);
const showFalsePositiveDialog = ref(false); const falsePositiveTarget = ref<AiCommentReplyItem | null>(null); const fpLoading = ref(false);
const triggerAiLoadingName = ref<string | null>(null);
const toggleSelect = (name: string) => { selectedNames.value.has(name) ? selectedNames.value.delete(name) : selectedNames.value.add(name); selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name)) }
const toggleSelectAll = () => { if (selectAll.value) { selectedNames.value.clear(); selectAll.value = false } else { selectedNames.value = new Set(replies.value.map(r => r.metadata.name)); selectAll.value = true } }
@@ -255,17 +259,26 @@ const handleFalsePositive = async (action: string) => {
} finally { fpLoading.value = false }
}
const handleTriggerAiReply = async (reply: AiCommentReplyItem) => {
if (triggerAiLoadingName.value) return
triggerAiLoadingName.value = reply.metadata.name
try {
await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${reply.metadata.name}/false-positive`, { action: "aiReply" })
Toast.success("AI回复正在后台生成")
fetchReplies()
} catch (e: any) {
Toast.error(e?.response?.data?.message || "触发失败")
}
} finally { triggerAiLoadingName.value = null }
}
watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() })
// 状态/情感筛选立即触发;关键词输入防抖 300ms 避免每次按键都请求
watch([filterStatus, filterSentiment], () => { page.value = 1; fetchReplies() })
let keywordDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(filterKeyword, () => {
if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer)
keywordDebounceTimer = setTimeout(() => { page.value = 1; fetchReplies() }, 300)
})
watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() })
onMounted(fetchReplies)
onUnmounted(() => { if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer) })
</script>
<style scoped>
@@ -309,8 +322,10 @@ onMounted(fetchReplies)
.btn-false-positive { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #b45309; border-radius: 4px; background: transparent; color: #b45309; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
.btn-false-positive:hover { background: #b45309; color: #fff; }
.fp-icon-ok { color: #16a34a; }
.btn-trigger-ai { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #2563eb; border-radius: 4px; background: transparent; color: #2563eb; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
.btn-trigger-ai:hover { background: #2563eb; color: #fff; }
.btn-trigger-ai { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #2563eb; border-radius: 4px; background: transparent; color: #2563eb; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; display: inline-flex; align-items: center; gap: 4px; }
.btn-trigger-ai:hover:not(:disabled) { background: #2563eb; color: #fff; }
.btn-trigger-ai:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-trigger-ai .fp-spinner { width: 11px; height: 11px; border-color: rgba(37,99,235,0.3); border-top-color: #2563eb; }
.card-footer { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; background: #f9fafb; border-top: 1px solid #f3f4f6; }
@media (min-width: 640px) { .card-footer { flex-direction: row; justify-content: space-between; align-items: center; } }
.footer-info { font-size: 12px; color: #6b7280; display: flex; flex-wrap: wrap; gap: 12px; }
@@ -329,7 +344,7 @@ onMounted(fetchReplies)
.tag-PASS { background: #dcfce7; color: #15803d; } .tag-FAIL { background: #fee2e2; color: #b91c1c; } .tag-PENDING { background: #fef9c3; color: #a16207; } .tag-REJECTED { background: #ffedd5; color: #c2410c; } .tag-FILTERED { background: #f1f5f9; color: #b45309; border: 1px solid #fde68a; } .tag-FALSE_POSITIVE { background: #dbeafe; color: #1d4ed8; border: 1px solid #93c5fd; }
.tag-published { background: #dbeafe; color: #1d4ed8; } .tag-draft { background: #f3f4f6; color: #4b5563; }
.tag-conv { background: #f3e8ff; color: #7e22ce; }
.tag-VERY_POSITIVE { background: #dcfce7; color: #14532d; } .tag-POSITIVE { background: #ecfdf5; color: #15803d; } .tag-NEGATIVE { background: #ffe4e6; color: #e11d48; } .tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; }
.tag-VERY_POSITIVE { background: #dcfce7; color: #14532d; } .tag-POSITIVE { background: #ecfdf5; color: #15803d; } .tag-NEUTRAL { background: #f3f4f6; color: #4b5563; } .tag-NEGATIVE { background: #ffe4e6; color: #e11d48; } .tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; }
/* 对话弹窗与响应式气泡 */
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; backdrop-filter: blur(2px); padding: 16px; box-sizing: border-box; }
+1 -1
View File
@@ -309,7 +309,7 @@ const filteredCommenters = computed(() => { const kw = commenterSearch.value.tri
const openCommenterDialog = async () => { showCommenterDialog.value = true; commenterLoading.value = true; try { const { data } = await axiosInstance.get(`${apiBase}/commenters`); commenterList.value = data.items || data } catch(e) { commenterList.value = [] } finally { commenterLoading.value = false } }
const addCommenter = (c: any) => { const v = c.email || c.displayName; const cur = settings.basic.blockedCommenters.split(",").map(s=>s.trim()).filter(Boolean); if(cur.includes(v)) return; cur.push(v); settings.basic.blockedCommenters = cur.join(","); Toast.success("已添加"); showCommenterDialog.value = false }
const cleanupLoading = ref(false); const cleanupResult = ref<number | null>(null)
const performCleanup = async () => { cleanupLoading.value=true; try { const { data } = await axiosInstance.post(`${apiBase}/cleanup`); cleanupResult.value = data.deletedCount ?? data ?? 0; Toast.success("清理完成") } catch(e){ Toast.error("清理失败") } finally { cleanupLoading.value=false } }
const performCleanup = async () => { cleanupLoading.value=true; try { const { data } = await axiosInstance.post(`${apiBase}/cleanup`); cleanupResult.value = typeof data === 'number' ? data : (data?.deletedCount ?? 0); Toast.success("清理完成") } catch(e){ Toast.error("清理失败") } finally { cleanupLoading.value=false } }
// Persona
const personasApiBase = `${apiBase}/personas`