feat: 增强插件可靠性与可用性
- 修复 PromptBuilder ClassLoader 冲突,改为 ConfigMap 直读 - 实现失败重试机制,指数退避策略 - 评论者黑名单支持邮箱匹配,可从评论列表选择 - 日志页面支持按状态/情感筛选和关键词搜索 - AI角色邮箱配置增加 Gravatar 头像实时预览 - 插件依赖声明修正:ai-foundation 改为必须依赖 - 新增定时清理旧记录功能,可配置保留天数 - 更新 README 和 VitePress 文档
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user