feat: 增强插件可靠性与可用性

- 修复 PromptBuilder ClassLoader 冲突,改为 ConfigMap 直读
- 实现失败重试机制,指数退避策略
- 评论者黑名单支持邮箱匹配,可从评论列表选择
- 日志页面支持按状态/情感筛选和关键词搜索
- AI角色邮箱配置增加 Gravatar 头像实时预览
- 插件依赖声明修正:ai-foundation 改为必须依赖
- 新增定时清理旧记录功能,可配置保留天数
- 更新 README 和 VitePress 文档
This commit is contained in:
sunny-335
2026-06-14 23:07:22 +08:00
parent e03ba7a20d
commit ef3cdf4bdc
20 changed files with 946 additions and 81 deletions
+58 -1
View File
@@ -38,6 +38,41 @@
</button>
</div>
<!-- Filter Bar -->
<div class="mx-4 mt-2 flex items-center gap-3">
<select
v-model="filterStatus"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">全部状态</option>
<option value="PASS">通过</option>
<option value="FAIL">失败</option>
<option value="PENDING">待审核</option>
<option value="REJECTED">已拒绝</option>
</select>
<select
v-model="filterSentiment"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">全部情感</option>
<option value="POSITIVE">正面</option>
<option value="NEUTRAL">中性</option>
<option value="NEGATIVE">负面</option>
</select>
<input
v-model="filterKeyword"
type="text"
placeholder="搜索回复内容..."
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
class="text-xs text-gray-500 hover:text-gray-700"
@click="resetFilters"
>
重置
</button>
</div>
<div class="m-4">
<VLoading v-if="loading" />
@@ -312,6 +347,11 @@ const totalPages = ref(0)
const selectedNames = ref<Set<string>>(new Set())
const selectAll = ref(false)
// Filter state
const filterStatus = ref("")
const filterSentiment = ref("")
const filterKeyword = ref("")
const toggleSelect = (name: string) => {
if (selectedNames.value.has(name)) {
selectedNames.value.delete(name)
@@ -340,9 +380,13 @@ const conversationMessages = ref<ConversationMessage[]>([])
const fetchReplies = async () => {
loading.value = true
try {
const params: Record<string, string | number> = { page: page.value, size: size.value }
if (filterStatus.value) params.status = filterStatus.value
if (filterSentiment.value) params.sentiment = filterSentiment.value
if (filterKeyword.value) params.keyword = filterKeyword.value
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
{ params: { page: page.value, size: size.value } },
{ params },
)
replies.value = data.items || []
total.value = data.total || 0
@@ -561,6 +605,19 @@ const renderContent = (content: string) => {
.replace(/<a /gi, "<a target='_blank' rel='noopener noreferrer' ")
}
const resetFilters = () => {
filterStatus.value = ""
filterSentiment.value = ""
filterKeyword.value = ""
page.value = 1
fetchReplies()
}
watch([filterStatus, filterSentiment, filterKeyword], () => {
page.value = 1
fetchReplies()
})
watch(page, () => {
selectedNames.value.clear()
selectAll.value = false
+242 -11
View File
@@ -62,8 +62,13 @@
/>
</div>
<div>
<label class="font-medium">评论者黑名单</label>
<div class="mt-1 text-sm text-gray-500">输入评论者显示名称多个用逗号分隔这些评论者的评论不会触发AI回复</div>
<div class="flex items-center justify-between">
<label class="font-medium">评论者黑名单</label>
<VButton size="sm" @click="openCommenterDialog">
添加评论者
</VButton>
</div>
<div class="mt-1 text-sm text-gray-500">输入评论者显示名称或邮箱多个用逗号分隔这些评论者的评论不会触发AI回复</div>
<textarea
v-model="settings.basic.blockedCommenters"
rows="3"
@@ -87,12 +92,30 @@
<div>
<label class="font-medium">AI角色邮箱</label>
<div class="mt-1 text-sm text-gray-500">用于Gravatar头像服务展示头像留空则使用默认头像</div>
<input
type="email"
v-model="settings.persona.personaEmail"
class="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="ai@example.com"
/>
<div class="mt-1 flex items-start gap-4">
<input
type="email"
v-model="settings.persona.personaEmail"
class="block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="ai@example.com"
/>
<div class="flex-shrink-0">
<div
v-if="avatarUrl"
class="h-12 w-12 overflow-hidden rounded-full border border-gray-200"
>
<img :src="avatarUrl" alt="头像预览" class="h-full w-full object-cover" />
</div>
<div
v-else
class="flex h-12 w-12 items-center justify-center rounded-full border border-gray-200 bg-gray-100"
>
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
</div>
</div>
<div>
<label class="font-medium">AI角色人格提示词</label>
@@ -138,6 +161,39 @@
</div>
</div>
<!-- Cleanup Settings -->
<div v-if="activeTab === 'cleanup' && !loading" class="space-y-6">
<div class="flex items-center justify-between">
<div>
<div class="font-medium">启用自动清理</div>
<div class="text-sm text-gray-500">启用后将自动清理过期的AI回复记录</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input type="checkbox" v-model="settings.cleanup.cleanupEnabled" class="peer sr-only" />
<div class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
</label>
</div>
<div>
<label class="font-medium">保留天数</label>
<div class="mt-1 text-sm text-gray-500">超过保留天数的AI回复记录将被自动清理</div>
<input
type="number"
v-model.number="settings.cleanup.retentionDays"
min="1"
max="365"
class="mt-1 block w-full max-w-xs rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<VButton @click="performCleanup" :disabled="cleanupLoading">
{{ cleanupLoading ? '清理中...' : '立即清理' }}
</VButton>
<div v-if="cleanupResult !== null" class="mt-2 text-sm text-green-600">
清理完成共删除 {{ cleanupResult }} 条记录
</div>
</div>
</div>
<!-- Save Button -->
<div v-if="!loading" class="mt-6 flex justify-end">
<VButton type="primary" @click="saveSettings" :disabled="saving">
@@ -147,11 +203,61 @@
</div>
</VCard>
</div>
<!-- Commenter Selection Dialog -->
<div
v-if="showCommenterDialog"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="showCommenterDialog = false"
>
<div class="w-full max-w-lg rounded-lg bg-white shadow-xl">
<div class="border-b px-6 py-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">选择评论者</h3>
<button
class="text-gray-400 hover:text-gray-600"
@click="showCommenterDialog = false"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<input
v-model="commenterSearch"
type="text"
class="mt-3 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="搜索评论者名称或邮箱..."
/>
</div>
<div class="max-h-80 overflow-y-auto px-6 py-3">
<VLoading v-if="commenterLoading" />
<div v-else-if="filteredCommenters.length === 0" class="py-8 text-center text-sm text-gray-500">
暂无评论者数据
</div>
<div v-else class="space-y-2">
<div
v-for="commenter in filteredCommenters"
:key="commenter.name + commenter.email"
class="flex items-center justify-between rounded-md border border-gray-100 px-4 py-3 hover:bg-gray-50"
>
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-sm">{{ commenter.name }}</div>
<div v-if="commenter.email" class="truncate text-xs text-gray-500">{{ commenter.email }}</div>
</div>
<VButton size="sm" @click="addCommenter(commenter)">
添加
</VButton>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue"
import { ref, reactive, computed, onMounted, watch } from "vue"
import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VCard, VLoading, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components"
@@ -161,12 +267,27 @@ const tabs = [
{ key: "persona", label: "AI角色设置" },
{ key: "model", label: "模型设置" },
{ key: "prompt", label: "Prompt设置" },
{ key: "cleanup", label: "数据清理" },
]
const activeTab = ref("basic")
const loading = ref(false)
const saving = ref(false)
// Commenter dialog state
const showCommenterDialog = ref(false)
const commenterList = ref<{ name: string; email: string }[]>([])
const commenterSearch = ref("")
const commenterLoading = ref(false)
// Avatar preview state
const avatarUrl = ref("")
let avatarDebounceTimer: ReturnType<typeof setTimeout> | null = null
// Cleanup state
const cleanupLoading = ref(false)
const cleanupResult = ref<number | null>(null)
const settings = reactive({
basic: {
autoReply: true,
@@ -185,10 +306,114 @@ const settings = reactive({
prompt: {
customPromptTemplate: "",
},
cleanup: {
cleanupEnabled: true,
retentionDays: 30,
},
})
const configMapName = "comment-ai-autopilot-configmap"
// --- Task 4: Commenter blacklist enhancement ---
const openCommenterDialog = async () => {
showCommenterDialog.value = true
commenterSearch.value = ""
commenterLoading.value = true
try {
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/commenters",
)
commenterList.value = Array.isArray(data) ? data : (data.items || [])
} catch (e) {
console.error("Failed to fetch commenters", e)
Toast.error("获取评论者列表失败")
commenterList.value = []
} finally {
commenterLoading.value = false
}
}
const filteredCommenters = computed(() => {
const keyword = commenterSearch.value.trim().toLowerCase()
if (!keyword) return commenterList.value
return commenterList.value.filter(
(c) =>
c.name.toLowerCase().includes(keyword) ||
(c.email && c.email.toLowerCase().includes(keyword)),
)
})
const addCommenter = (commenter: { name: string; email: string }) => {
const value = commenter.email || commenter.name
if (!value) return
const current = settings.basic.blockedCommenters
.split(",")
.map((s) => s.trim())
.filter(Boolean)
if (current.includes(value)) {
Toast.info("该评论者已在黑名单中")
return
}
current.push(value)
settings.basic.blockedCommenters = current.join(",")
Toast.success("已添加到黑名单")
}
// --- Task 7: Avatar preview ---
const computeGravatarHash = async (email: string): Promise<string> => {
const normalized = email.trim().toLowerCase()
const encoder = new TextEncoder()
const data = encoder.encode(normalized)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}
watch(
() => settings.persona.personaEmail,
(newEmail) => {
if (avatarDebounceTimer) {
clearTimeout(avatarDebounceTimer)
}
if (!newEmail || !newEmail.trim()) {
avatarUrl.value = ""
return
}
avatarDebounceTimer = setTimeout(async () => {
try {
const hash = await computeGravatarHash(newEmail)
avatarUrl.value = `https://cn.cravatar.com/avatar/${hash}`
} catch (e) {
console.error("Failed to compute Gravatar hash", e)
avatarUrl.value = ""
}
}, 500)
},
)
// --- Task 10: Cleanup ---
const performCleanup = async () => {
cleanupLoading.value = true
cleanupResult.value = null
try {
const { data } = await axiosInstance.post(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/cleanup",
)
cleanupResult.value = data.deletedCount ?? data ?? 0
Toast.success(`清理完成,共删除 ${cleanupResult.value} 条记录`)
} catch (e) {
console.error("Failed to perform cleanup", e)
Toast.error("清理失败")
} finally {
cleanupLoading.value = false
}
}
// --- Settings fetch & save ---
const fetchSettings = async () => {
loading.value = true
try {
@@ -214,7 +439,10 @@ const fetchSettings = async () => {
if (d.prompt) {
settings.prompt.customPromptTemplate = d.prompt.customPromptTemplate || ""
}
if (d.cleanup) {
settings.cleanup.cleanupEnabled = d.cleanup.cleanupEnabled !== false
settings.cleanup.retentionDays = d.cleanup.retentionDays || 30
}
}
} catch (e) {
console.error("Failed to fetch settings", e)
@@ -251,7 +479,10 @@ const saveSettings = async () => {
prompt: {
customPromptTemplate: settings.prompt.customPromptTemplate,
},
cleanup: {
cleanupEnabled: settings.cleanup.cleanupEnabled,
retentionDays: settings.cleanup.retentionDays,
},
}
await axiosInstance.put(