feat: 草稿模式修复、回复编辑、预设扩展、多语言回复、文档全面更新
- 修复草稿模式下仍自动发布评论的Bug - 新增草稿模式下编辑AI回复内容功能 - 新增3个Prompt预设(技术解答型/鼓励型/知识科普型) - 新增多语言回复指令 - 新增日志页面时间范围筛选 - 新增AI角色排序功能 - 新增配置导入/导出功能 - 修复processingLocks内存泄漏 - 修复OptimisticLockingFailureException缺少重试 - 修复publishReply空Mono导致审核挂起 - 移除近7日回复趋势卡片 - 版本号更新至0.0.0-ygkszvd - 全面更新插件文档
This commit is contained in:
+10
-78
@@ -177,53 +177,6 @@
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- Daily Trend -->
|
||||
<VCard :body-class="['!p-5']">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-gray-500">近7日回复趋势</h3>
|
||||
<div class="inline-flex rounded-md border border-gray-200 overflow-hidden">
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs transition-colors"
|
||||
:class="range === '7' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
||||
@click="range = '7'"
|
||||
>
|
||||
7天
|
||||
</button>
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
|
||||
:class="range === '30' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
||||
@click="range = '30'"
|
||||
>
|
||||
30天
|
||||
</button>
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
|
||||
:class="range === 'all' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
||||
@click="range = 'all'"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="stats?.dailyTrend?.length" class="flex items-end gap-3" style="height: 160px">
|
||||
<div
|
||||
v-for="day in stats.dailyTrend"
|
||||
:key="day.date"
|
||||
class="flex-1 flex flex-col items-center justify-end h-full"
|
||||
>
|
||||
<div class="text-xs text-gray-500 mb-1 font-medium">{{ day.count }}</div>
|
||||
<div
|
||||
class="w-full rounded-t-md transition-all duration-500"
|
||||
:class="day.count > 0 ? 'bg-gradient-to-t from-blue-500 to-blue-400' : 'bg-gray-100'"
|
||||
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
|
||||
></div>
|
||||
<div class="text-[10px] text-gray-400 mt-2 whitespace-nowrap">{{ formatTrendDate(day.date) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center text-sm text-gray-400" style="height: 160px">
|
||||
暂无数据
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Score + Quick Actions -->
|
||||
@@ -299,16 +252,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue"
|
||||
import { ref, computed, onMounted } from "vue"
|
||||
import { axiosInstance } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
interface DailyCount {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface StatsResponse {
|
||||
total: number
|
||||
passCount: number
|
||||
@@ -316,7 +264,6 @@ interface StatsResponse {
|
||||
reviewingCount: number
|
||||
avgScore: number
|
||||
sentimentDistribution: Record<string, number>
|
||||
dailyTrend: DailyCount[]
|
||||
}
|
||||
|
||||
interface PersonaResponse {
|
||||
@@ -331,7 +278,6 @@ interface HealthResponse {
|
||||
|
||||
const stats = ref<StatsResponse | null>(null)
|
||||
const persona = ref<PersonaResponse | null>(null)
|
||||
const range = ref("7")
|
||||
const health = ref<HealthResponse | null>(null)
|
||||
const healthVisible = ref(true)
|
||||
|
||||
@@ -357,7 +303,7 @@ const scoreBarColor = computed(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=${range.value}`,
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=7`,
|
||||
)
|
||||
stats.value = data
|
||||
} catch (e) {
|
||||
@@ -367,9 +313,11 @@ const fetchStats = async () => {
|
||||
|
||||
const computeGravatarHash = async (email: string): Promise<string> => {
|
||||
const normalized = email.trim().toLowerCase()
|
||||
const data = new TextEncoder().encode(normalized)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join("")
|
||||
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('')
|
||||
}
|
||||
|
||||
const fetchPersona = async () => {
|
||||
@@ -378,14 +326,15 @@ const fetchPersona = async () => {
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas",
|
||||
)
|
||||
const personas = Array.isArray(data) ? data : (data.items || [])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const defaultPersona = personas.find((p: any) => p.spec?.isDefault) || personas[0]
|
||||
if (defaultPersona) {
|
||||
persona.value = {
|
||||
name: defaultPersona.spec?.displayName || '未命名',
|
||||
prompt: defaultPersona.spec?.prompt || '',
|
||||
avatar: '', // 需要单独计算
|
||||
avatar: '',
|
||||
}
|
||||
// 计算 Gravatar
|
||||
// 使用Gravatar邮箱头像
|
||||
const email = defaultPersona.spec?.email
|
||||
if (email) {
|
||||
const hash = await computeGravatarHash(email)
|
||||
@@ -426,23 +375,6 @@ const getSentimentPercent = (sentiment: string): number => {
|
||||
return Math.round(((dist[sentiment] || 0) / total) * 100)
|
||||
}
|
||||
|
||||
const getTrendBarHeight = (count: number): number => {
|
||||
const trend = stats.value?.dailyTrend
|
||||
if (!trend || trend.length === 0) return 0
|
||||
const max = Math.max(...trend.map(d => d.count), 1)
|
||||
return Math.max((count / max) * 100, count > 0 ? 8 : 4)
|
||||
}
|
||||
|
||||
const formatTrendDate = (dateStr: string): string => {
|
||||
if (!dateStr) return ''
|
||||
const parts = dateStr.split('-')
|
||||
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
|
||||
}
|
||||
|
||||
watch(range, () => {
|
||||
fetchStats()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
fetchPersona()
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
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"
|
||||
/>
|
||||
<input v-model="filterStartDate" type="date" 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" />
|
||||
<span class="text-xs text-gray-400">至</span>
|
||||
<input v-model="filterEndDate" type="date" 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"
|
||||
@@ -172,6 +175,12 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="reply.spec.status === 'PASS' && !reply.spec.published">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 transition-colors px-2 py-1 rounded hover:bg-blue-50"
|
||||
@click="openEditDialog(reply)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-xs text-green-600 hover:text-green-800 transition-colors px-2 py-1 rounded hover:bg-green-50"
|
||||
@click="handleApprove(reply.metadata.name)"
|
||||
@@ -299,6 +308,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<teleport to="body">
|
||||
<div v-if="showEditDialog" class="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="showEditDialog = false"></div>
|
||||
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<h3 class="text-base font-semibold text-gray-800">编辑AI回复</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600 p-1 rounded-lg hover:bg-gray-100" @click="showEditDialog = false">
|
||||
<svg class="w-5 h-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>
|
||||
<div class="flex-1 px-5 py-4">
|
||||
<textarea v-model="editContent" rows="8" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 resize-y" placeholder="输入回复内容..."></textarea>
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t border-gray-100 flex justify-end gap-2">
|
||||
<button class="px-4 py-1.5 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg" @click="showEditDialog = false">取消</button>
|
||||
<button class="px-4 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50" :disabled="editSaving" @click="saveEdit">{{ editSaving ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -351,6 +382,8 @@ const selectAll = ref(false)
|
||||
const filterStatus = ref("")
|
||||
const filterSentiment = ref("")
|
||||
const filterKeyword = ref("")
|
||||
const filterStartDate = ref("")
|
||||
const filterEndDate = ref("")
|
||||
|
||||
const toggleSelect = (name: string) => {
|
||||
if (selectedNames.value.has(name)) {
|
||||
@@ -377,6 +410,12 @@ const showDialog = ref(false)
|
||||
const conversationLoading = ref(false)
|
||||
const conversationMessages = ref<ConversationMessage[]>([])
|
||||
|
||||
// Edit dialog state
|
||||
const showEditDialog = ref(false)
|
||||
const editingReply = ref<AiCommentReplyItem | null>(null)
|
||||
const editContent = ref("")
|
||||
const editSaving = ref(false)
|
||||
|
||||
const fetchReplies = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -384,6 +423,8 @@ const fetchReplies = async () => {
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterSentiment.value) params.sentiment = filterSentiment.value
|
||||
if (filterKeyword.value) params.keyword = filterKeyword.value
|
||||
if (filterStartDate.value) params.startDate = filterStartDate.value
|
||||
if (filterEndDate.value) params.endDate = filterEndDate.value
|
||||
const { data } = await axiosInstance.get(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
|
||||
{ params },
|
||||
@@ -393,6 +434,7 @@ const fetchReplies = async () => {
|
||||
totalPages.value = Math.ceil(total.value / size.value)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch replies", e)
|
||||
Toast.error("获取回复列表失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -415,6 +457,30 @@ const openConversation = async (reply: AiCommentReplyItem) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (reply: AiCommentReplyItem) => {
|
||||
editingReply.value = reply
|
||||
editContent.value = stripHtml(reply.spec.reply || "")
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingReply.value || !editContent.value.trim()) return
|
||||
editSaving.value = true
|
||||
try {
|
||||
await axiosInstance.put(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${editingReply.value.metadata.name}/content`,
|
||||
{ reply: editContent.value }
|
||||
)
|
||||
Toast.success("保存成功")
|
||||
showEditDialog.value = false
|
||||
fetchReplies()
|
||||
} catch (e) {
|
||||
Toast.error("保存失败")
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
try {
|
||||
await axiosInstance.delete(
|
||||
@@ -609,11 +675,13 @@ const resetFilters = () => {
|
||||
filterStatus.value = ""
|
||||
filterSentiment.value = ""
|
||||
filterKeyword.value = ""
|
||||
filterStartDate.value = ""
|
||||
filterEndDate.value = ""
|
||||
page.value = 1
|
||||
fetchReplies()
|
||||
}
|
||||
|
||||
watch([filterStatus, filterSentiment, filterKeyword], () => {
|
||||
watch([filterStatus, filterSentiment, filterKeyword, filterStartDate, filterEndDate], () => {
|
||||
page.value = 1
|
||||
fetchReplies()
|
||||
})
|
||||
|
||||
+248
-40
@@ -5,6 +5,11 @@
|
||||
<IconPlug class="mr-2 self-center" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<VButton type="secondary" size="sm" @click="exportConfig">导出配置</VButton>
|
||||
<label class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
导入配置
|
||||
<input type="file" accept=".json" class="hidden" @change="handleImportFile" />
|
||||
</label>
|
||||
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
|
||||
</template>
|
||||
</VPageHeader>
|
||||
@@ -117,26 +122,38 @@
|
||||
<span>请添加至少一个AI角色</span>
|
||||
</div>
|
||||
<div v-else class="persona-list">
|
||||
<div v-for="p in personas" :key="p.metadata.name" class="persona-card">
|
||||
<div v-for="(persona, index) in personas" :key="persona.metadata.name" class="persona-card">
|
||||
<div class="persona-card__avatar">
|
||||
<img v-if="getPersonaAvatar(p)" :src="getPersonaAvatar(p)" alt="头像" />
|
||||
<span v-else class="persona-card__avatar-fallback">{{ (p.spec.displayName || '?').charAt(0) }}</span>
|
||||
<img v-if="getPersonaAvatar(persona)" :src="getPersonaAvatar(persona)" alt="头像" />
|
||||
<span v-else class="persona-card__avatar-fallback">{{ (persona.spec.displayName || '?').charAt(0) }}</span>
|
||||
</div>
|
||||
<div class="persona-card__info">
|
||||
<div class="persona-card__name">
|
||||
{{ p.spec.displayName || '未命名' }}
|
||||
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
|
||||
{{ persona.spec.displayName || '未命名' }}
|
||||
<span v-if="persona.spec.isDefault" class="persona-card__badge">默认</span>
|
||||
</div>
|
||||
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
|
||||
<div class="persona-card__prompt">{{ persona.spec.prompt || '暂无提示词' }}</div>
|
||||
</div>
|
||||
<div class="persona-card__actions">
|
||||
<button class="btn-icon" title="编辑" @click="openPersonaDialog(p)">
|
||||
<button
|
||||
class="text-xs text-gray-400 hover:text-gray-600 px-1 py-0.5"
|
||||
:disabled="index === 0"
|
||||
@click="movePersonaUp(index)"
|
||||
title="上移"
|
||||
>▲</button>
|
||||
<button
|
||||
class="text-xs text-gray-400 hover:text-gray-600 px-1 py-0.5"
|
||||
:disabled="index === personas.length - 1"
|
||||
@click="movePersonaDown(index)"
|
||||
title="下移"
|
||||
>▼</button>
|
||||
<button class="btn-icon" title="编辑" @click="openPersonaDialog(persona)">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
<button v-if="!p.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(p)">
|
||||
<button v-if="!persona.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(persona)">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
<button v-if="!p.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(p)">
|
||||
<button v-if="!persona.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(persona)">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -313,10 +330,13 @@
|
||||
<span>暂无评论者数据</span>
|
||||
</div>
|
||||
<div v-else class="dialog__list">
|
||||
<button v-for="c in filteredCommenters" :key="c.name + c.email" class="dialog__item" @click="addCommenter(c)">
|
||||
<div class="dialog__item-avatar">{{ c.name?.charAt(0) || '?' }}</div>
|
||||
<button v-for="c in filteredCommenters" :key="c.displayName + c.email" class="dialog__item" @click="addCommenter(c)">
|
||||
<div class="dialog__item-avatar">
|
||||
<img v-if="c.avatarUrl" :src="c.avatarUrl" alt="" class="w-full h-full object-cover rounded-full" />
|
||||
<span v-else>{{ c.displayName?.charAt(0) || '?' }}</span>
|
||||
</div>
|
||||
<div class="dialog__item-info">
|
||||
<div class="dialog__item-name">{{ c.name }}</div>
|
||||
<div class="dialog__item-name">{{ c.displayName }}</div>
|
||||
<div v-if="c.email" class="dialog__item-email">{{ c.email }}</div>
|
||||
</div>
|
||||
<svg class="dialog__item-add" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
@@ -339,10 +359,10 @@
|
||||
<!-- Avatar Preview -->
|
||||
<div class="persona-dialog-preview">
|
||||
<div class="persona-card__avatar">
|
||||
<img v-if="personaDialogAvatar" :src="personaDialogAvatar" alt="头像" />
|
||||
<img v-if="personaDialogAvatar" :src="personaDialogAvatar" alt="Gravatar头像" />
|
||||
<span v-else class="persona-card__avatar-fallback">{{ (personaForm.displayName || '?').charAt(0) }}</span>
|
||||
</div>
|
||||
<div style="font-size:13px;color:#6b7280">Gravatar头像预览</div>
|
||||
<div style="font-size:13px;color:#6b7280">{{ personaDialogAvatar ? 'Gravatar头像预览' : '未设置邮箱,将使用默认头像' }}</div>
|
||||
</div>
|
||||
<!-- Nickname -->
|
||||
<div class="form-field">
|
||||
@@ -382,13 +402,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入确认对话框 -->
|
||||
<VModal v-model:visible="showImportConfirm" title="确认导入配置">
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-gray-600">导入将覆盖当前配置,此操作不可撤销。确定要继续吗?</p>
|
||||
<div v-if="importFileData" class="text-xs text-gray-500">
|
||||
<p v-if="importFileData.configMap">- 包含插件设置</p>
|
||||
<p v-if="importFileData.personas">- 包含 {{ importFileData.personas.length }} 个AI角色</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton @click="showImportConfirm = false">取消</VButton>
|
||||
<VButton type="primary" :loading="importLoading" @click="confirmImport">确认导入</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from "vue"
|
||||
import { axiosInstance } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
|
||||
import { axiosInstance, coreApiClient } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
const promptVariables = [
|
||||
@@ -406,6 +444,9 @@ const promptPresets = [
|
||||
{ key: 'professional', label: '专业型', desc: '严谨正式,有逻辑性' },
|
||||
{ key: 'humorous', label: '幽默型', desc: '轻松诙谐,适当幽默' },
|
||||
{ key: 'concise', label: '简洁型', desc: '一两句话,简洁明了' },
|
||||
{ key: 'technical', label: '技术解答型', desc: '深入浅出,专业解答技术问题' },
|
||||
{ key: 'encouraging', label: '鼓励型', desc: '积极正面,给予鼓励和支持' },
|
||||
{ key: 'educational', label: '知识科普型', desc: '通俗易懂,普及相关知识' },
|
||||
]
|
||||
|
||||
const enabledPresetKeys = computed({
|
||||
@@ -434,7 +475,7 @@ const isPresetEnabled = (key: string) => enabledPresetKeys.value.includes(key)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showCommenterDialog = ref(false)
|
||||
const commenterList = ref<{ name: string; email: string }[]>([])
|
||||
const commenterList = ref<{ displayName: string; email: string; avatarUrl: string }[]>([])
|
||||
const commenterSearch = ref("")
|
||||
const commenterLoading = ref(false)
|
||||
const cleanupLoading = ref(false)
|
||||
@@ -455,7 +496,6 @@ const personaForm = reactive({
|
||||
isDefault: false,
|
||||
})
|
||||
const personaDialogAvatar = ref('')
|
||||
let personaAvatarDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const settings = reactive({
|
||||
basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", maxConversationRounds: 8, rateLimitPerMinute: 10 },
|
||||
@@ -465,6 +505,72 @@ const settings = reactive({
|
||||
})
|
||||
|
||||
const configMapName = "comment-ai-autopilot-configmap"
|
||||
const apiBase = "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1"
|
||||
|
||||
// ===== Export / Import =====
|
||||
const exportConfig = async () => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(`${apiBase}/export`)
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `comment-ai-autopilot-config-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
Toast.success('配置已导出')
|
||||
} catch (e) {
|
||||
console.error('Failed to export config', e)
|
||||
Toast.error('导出配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const importLoading = ref(false)
|
||||
const showImportConfirm = ref(false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const importFileData = ref<any>(null)
|
||||
|
||||
const handleImportFile = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.files || input.files.length === 0) return
|
||||
const file = input.files[0]
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target?.result as string)
|
||||
if (!data.configMap && !data.personas) {
|
||||
Toast.error('无效的配置文件格式')
|
||||
return
|
||||
}
|
||||
importFileData.value = data
|
||||
showImportConfirm.value = true
|
||||
} catch {
|
||||
Toast.error('无法解析配置文件')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
// 重置 input 以允许重复选择同一文件
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const confirmImport = async () => {
|
||||
if (!importFileData.value) return
|
||||
importLoading.value = true
|
||||
try {
|
||||
await axiosInstance.post(`${apiBase}/import`, importFileData.value)
|
||||
Toast.success('配置已导入')
|
||||
showImportConfirm.value = false
|
||||
importFileData.value = null
|
||||
// 刷新数据
|
||||
await fetchPersonas()
|
||||
await computePersonaAvatars()
|
||||
} catch (e) {
|
||||
console.error('Failed to import config', e)
|
||||
Toast.error('导入配置失败')
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCommenterDialog = async () => {
|
||||
showCommenterDialog.value = true
|
||||
@@ -485,11 +591,11 @@ const openCommenterDialog = async () => {
|
||||
const filteredCommenters = computed(() => {
|
||||
const kw = commenterSearch.value.trim().toLowerCase()
|
||||
if (!kw) return commenterList.value
|
||||
return commenterList.value.filter(c => c.name.toLowerCase().includes(kw) || (c.email && c.email.toLowerCase().includes(kw)))
|
||||
return commenterList.value.filter(c => c.displayName.toLowerCase().includes(kw) || (c.email && c.email.toLowerCase().includes(kw)))
|
||||
})
|
||||
|
||||
const addCommenter = (commenter: { name: string; email: string }) => {
|
||||
const value = commenter.email || commenter.name
|
||||
const addCommenter = (commenter: { displayName: string; email: string }) => {
|
||||
const value = commenter.email || commenter.displayName
|
||||
if (!value) return
|
||||
const current = settings.basic.blockedCommenters.split(",").map(s => s.trim()).filter(Boolean)
|
||||
if (current.includes(value)) { Toast.info("该评论者已在黑名单中"); return }
|
||||
@@ -501,9 +607,11 @@ const addCommenter = (commenter: { name: string; email: string }) => {
|
||||
|
||||
const computeGravatarHash = async (email: string): Promise<string> => {
|
||||
const normalized = email.trim().toLowerCase()
|
||||
const data = new TextEncoder().encode(normalized)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join("")
|
||||
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('')
|
||||
}
|
||||
|
||||
const performCleanup = async () => {
|
||||
@@ -526,6 +634,7 @@ const fetchPersonas = async () => {
|
||||
const { data } = await axiosInstance.get(personasApiBase)
|
||||
const list = Array.isArray(data) ? data : (data.items || [])
|
||||
// 默认角色排到最上方
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
personas.value = list.sort((a: any, b: any) => {
|
||||
if (a.spec?.isDefault && !b.spec?.isDefault) return -1
|
||||
if (!a.spec?.isDefault && b.spec?.isDefault) return 1
|
||||
@@ -533,6 +642,7 @@ const fetchPersonas = async () => {
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch personas", e)
|
||||
Toast.error("获取角色列表失败")
|
||||
personas.value = []
|
||||
} finally {
|
||||
personasLoading.value = false
|
||||
@@ -542,7 +652,6 @@ const fetchPersonas = async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getPersonaAvatar = (persona: any) => {
|
||||
if (persona.spec?.email) {
|
||||
// Compute synchronously from cached hash if available
|
||||
return persona._avatarUrl || ''
|
||||
}
|
||||
return ''
|
||||
@@ -563,7 +672,7 @@ const computePersonaAvatars = async () => {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const openPersonaDialog = (persona: any | null) => {
|
||||
const openPersonaDialog = async (persona: any | null) => {
|
||||
personaEditing.value = persona
|
||||
if (persona) {
|
||||
personaForm.displayName = persona.spec.displayName || ''
|
||||
@@ -578,9 +687,10 @@ const openPersonaDialog = (persona: any | null) => {
|
||||
}
|
||||
personaDialogAvatar.value = ''
|
||||
if (personaForm.email) {
|
||||
computeGravatarHash(personaForm.email).then(hash => {
|
||||
try {
|
||||
const hash = await computeGravatarHash(personaForm.email)
|
||||
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
|
||||
}).catch(() => { personaDialogAvatar.value = '' })
|
||||
} catch { personaDialogAvatar.value = '' }
|
||||
}
|
||||
showPersonaDialog.value = true
|
||||
}
|
||||
@@ -662,6 +772,7 @@ const deletePersona = async (persona: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const setDefaultPersona = async (persona: any) => {
|
||||
try {
|
||||
// 先取消当前默认角色:获取最新数据后更新
|
||||
@@ -684,11 +795,62 @@ const setDefaultPersona = async (persona: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const movePersonaUp = async (index: number) => {
|
||||
if (index <= 0) return
|
||||
const current = personas.value[index]
|
||||
const prev = personas.value[index - 1]
|
||||
// Swap priorities
|
||||
const currentPriority = current.spec?.priority ?? index
|
||||
const prevPriority = prev.spec?.priority ?? (index - 1)
|
||||
try {
|
||||
// Update both personas
|
||||
await Promise.all([
|
||||
axiosInstance.put(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${current.metadata.name}`,
|
||||
{ ...current, spec: { ...current.spec, priority: prevPriority } }
|
||||
),
|
||||
axiosInstance.put(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${prev.metadata.name}`,
|
||||
{ ...prev, spec: { ...prev.spec, priority: currentPriority } }
|
||||
)
|
||||
])
|
||||
Toast.success("排序已更新")
|
||||
fetchPersonas()
|
||||
} catch (e) {
|
||||
Toast.error("排序更新失败")
|
||||
}
|
||||
}
|
||||
|
||||
const movePersonaDown = async (index: number) => {
|
||||
if (index >= personas.value.length - 1) return
|
||||
const current = personas.value[index]
|
||||
const next = personas.value[index + 1]
|
||||
const currentPriority = current.spec?.priority ?? index
|
||||
const nextPriority = next.spec?.priority ?? (index + 1)
|
||||
try {
|
||||
await Promise.all([
|
||||
axiosInstance.put(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${current.metadata.name}`,
|
||||
{ ...current, spec: { ...current.spec, priority: nextPriority } }
|
||||
),
|
||||
axiosInstance.put(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${next.metadata.name}`,
|
||||
{ ...next, spec: { ...next.spec, priority: currentPriority } }
|
||||
)
|
||||
])
|
||||
Toast.success("排序已更新")
|
||||
fetchPersonas()
|
||||
} catch (e) {
|
||||
Toast.error("排序更新失败")
|
||||
}
|
||||
}
|
||||
|
||||
// Watch persona dialog email for Gravatar preview
|
||||
let emailDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => personaForm.email, (newEmail) => {
|
||||
if (personaAvatarDebounceTimer) clearTimeout(personaAvatarDebounceTimer)
|
||||
if (emailDebounceTimer) clearTimeout(emailDebounceTimer)
|
||||
if (!newEmail || !newEmail.trim()) { personaDialogAvatar.value = ''; return }
|
||||
personaAvatarDebounceTimer = setTimeout(async () => {
|
||||
emailDebounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const hash = await computeGravatarHash(newEmail)
|
||||
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
|
||||
@@ -696,28 +858,47 @@ watch(() => personaForm.email, (newEmail) => {
|
||||
}, 500)
|
||||
})
|
||||
|
||||
const parseConfigSection = (data: Record<string, unknown>, key: string): Record<string, unknown> => {
|
||||
const val = data[key]
|
||||
if (!val) return {}
|
||||
if (typeof val === 'string') {
|
||||
try { return JSON.parse(val) } catch { return {} }
|
||||
}
|
||||
return val as Record<string, unknown>
|
||||
}
|
||||
|
||||
const fetchSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await axiosInstance.get(`/api/v1alpha1/configmaps/${configMapName}`)
|
||||
const { data } = await coreApiClient.configMap.getConfigMap({ name: configMapName })
|
||||
if (data.data) {
|
||||
const d = data.data
|
||||
if (d.basic) { settings.basic.autoReply = d.basic.autoReply !== false; settings.basic.autoPublish = d.basic.autoPublish !== false; settings.basic.maxRetryCount = d.basic.maxRetryCount || 3; settings.basic.blockedCommenters = d.basic.blockedCommenters || ""; settings.basic.maxConversationRounds = d.basic.maxConversationRounds || 8; settings.basic.rateLimitPerMinute = d.basic.rateLimitPerMinute || 10 }
|
||||
if (d.model) { settings.model.modelName = d.model.modelName || "" }
|
||||
if (d.prompt) { settings.prompt.customPromptTemplate = d.prompt.customPromptTemplate || ""; settings.prompt.enabledPresets = Array.isArray(d.prompt.enabledPresets) ? d.prompt.enabledPresets : (d.prompt.enabledPresets || "").split(",").map((s: string) => s.trim()).filter(Boolean) }
|
||||
if (d.cleanup) { settings.cleanup.cleanupEnabled = d.cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = d.cleanup.retentionDays || 30 }
|
||||
const d = data.data as Record<string, unknown>
|
||||
const basic = parseConfigSection(d, 'basic') as Record<string, unknown>
|
||||
const model = parseConfigSection(d, 'model') as Record<string, unknown>
|
||||
const prompt = parseConfigSection(d, 'prompt') as Record<string, unknown>
|
||||
const cleanup = parseConfigSection(d, 'cleanup') as Record<string, unknown>
|
||||
if (Object.keys(basic).length) { settings.basic.autoReply = basic.autoReply !== false; settings.basic.autoPublish = basic.autoPublish !== false; settings.basic.maxRetryCount = (basic.maxRetryCount as number) || 3; settings.basic.blockedCommenters = (basic.blockedCommenters as string) || ""; settings.basic.maxConversationRounds = (basic.maxConversationRounds as number) || 8; settings.basic.rateLimitPerMinute = (basic.rateLimitPerMinute as number) || 10 }
|
||||
if (Object.keys(model).length) { settings.model.modelName = (model.modelName as string) || "" }
|
||||
if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) }
|
||||
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
|
||||
}
|
||||
} catch (e) { console.error("Failed to fetch settings", e) }
|
||||
} catch (e) { console.error("Failed to fetch settings", e); Toast.error("获取设置失败") }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const { data: latest } = await axiosInstance.get(`/api/v1alpha1/configmaps/${configMapName}`)
|
||||
const { data: latest } = await coreApiClient.configMap.getConfigMap({ name: configMapName })
|
||||
const updated = { ...latest }
|
||||
updated.data = { ...updated.data, basic: { ...settings.basic }, model: { ...settings.model }, prompt: { ...settings.prompt }, cleanup: { ...settings.cleanup } }
|
||||
await axiosInstance.put(`/api/v1alpha1/configmaps/${configMapName}`, updated)
|
||||
updated.data = {
|
||||
...updated.data,
|
||||
basic: JSON.stringify(settings.basic),
|
||||
model: JSON.stringify(settings.model),
|
||||
prompt: JSON.stringify(settings.prompt),
|
||||
cleanup: JSON.stringify(settings.cleanup),
|
||||
}
|
||||
await coreApiClient.configMap.updateConfigMap({ name: configMapName, configMap: updated })
|
||||
Toast.success("设置已保存")
|
||||
} catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") }
|
||||
finally { saving.value = false }
|
||||
@@ -741,6 +922,33 @@ onMounted(async () => {
|
||||
@media (max-width: 1024px) {
|
||||
.settings-container { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.settings-sidebar {
|
||||
order: -1;
|
||||
}
|
||||
.persona-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.settings-section .form-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.settings-section .form-row__label {
|
||||
min-width: auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.settings-section .form-input,
|
||||
.settings-section .form-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.preset-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Section ===== */
|
||||
.settings-section {
|
||||
|
||||
Reference in New Issue
Block a user