feat: v1.0.0-beta.2 - ExtensionGetter integration, UI revamp, bug fixes

- Replace cross-ClassLoader reflection with ExtensionGetter.getEnabledExtension(AiModelService.class)
- Declare optional pluginDependencies (ai-foundation?: "*") and recommended-apps annotation
- Use OutputSpec.choice for structured classification (sentiment, review safety/quality)
- Use GenerateTextRequest with maxRetries=2 for reliable chat generation
- Add multi-turn conversation context (conversation_history placeholder)
- Fix RateLimitService thread leak (implement DisposableBean)
- Two-stage AI review: safety check + 1-5 quality score mapped to 0-100
- Remove redundant dashboard cards (sentiment distribution, 7-day trend, avg score)
- Redesign settings page with tabbed navigation (basic/persona/model/prompt/cleanup)
- Fix settings layout (move tab bar out of grid container)
- Fix button icon+text alignment via :deep(.btn-content) inline-flex
- Add review score grade labels (excellent/good/fair/poor) in logs
This commit is contained in:
sunny-335
2026-06-17 19:40:10 +08:00
parent bf989ca3f2
commit 767efdccbb
15 changed files with 811 additions and 817 deletions
+5 -194
View File
@@ -108,152 +108,11 @@
</VCard>
</div>
<!-- Middle: Sentiment + Trend -->
<div class="grid grid-cols-1 gap-4 mt-4 lg:grid-cols-2">
<!-- Sentiment Distribution -->
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-4">情感分布</h3>
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-green-500 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">正面</span>
<span class="font-medium text-green-600">{{ stats?.sentimentDistribution?.POSITIVE || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-green-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('POSITIVE') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-gray-400 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">中性</span>
<span class="font-medium text-gray-600">{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gray-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('NEUTRAL') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-red-500 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">负面</span>
<span class="font-medium text-red-500">{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-red-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('NEGATIVE') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-gray-300 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">未知</span>
<span class="font-medium text-gray-400">{{ stats?.sentimentDistribution?.UNKNOWN || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gray-300 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('UNKNOWN') + '%' }"
></div>
</div>
</div>
</div>
</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 -->
<div class="grid grid-cols-1 gap-4 mt-4 sm:grid-cols-2">
<!-- Avg Score -->
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-3">平均审核评分</h3>
<div class="flex items-center gap-4">
<div class="text-4xl font-bold" :class="scoreColor">{{ stats?.avgScore?.toFixed(1) || '0.0' }}</div>
<div class="flex-1">
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="scoreBarColor"
:style="{ width: (stats?.avgScore || 0) * 10 + '%' }"
></div>
</div>
<div class="flex justify-between text-[10px] text-gray-300 mt-1">
<span>0</span>
<span>5</span>
<span>10</span>
</div>
</div>
</div>
</VCard>
<!-- Quick Actions -->
<!-- Quick Actions -->
<div class="mt-4">
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-3">快捷操作</h3>
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="$router.push({ name: 'CommentAiAutopilotLogs' })"
@@ -299,24 +158,16 @@
</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
failCount: number
reviewingCount: number
avgScore: number
sentimentDistribution: Record<string, number>
dailyTrend: DailyCount[]
}
interface PersonaResponse {
@@ -331,7 +182,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)
@@ -340,24 +190,10 @@ const passRate = computed(() => {
return Math.round((stats.value.passCount / stats.value.total) * 100)
})
const scoreColor = computed(() => {
const score = stats.value?.avgScore || 0
if (score >= 7) return "text-green-600"
if (score >= 4) return "text-amber-500"
return "text-red-500"
})
const scoreBarColor = computed(() => {
const score = stats.value?.avgScore || 0
if (score >= 7) return "bg-gradient-to-r from-green-400 to-green-500"
if (score >= 4) return "bg-gradient-to-r from-amber-400 to-amber-500"
return "bg-gradient-to-r from-red-400 to-red-500"
})
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) {
@@ -421,31 +257,6 @@ const openSettings = () => {
window.location.href = "/console/comment-ai-autopilot/settings"
}
const getSentimentPercent = (sentiment: string): number => {
const dist = stats.value?.sentimentDistribution
if (!dist) return 0
const total = Object.values(dist).reduce((a, b) => a + b, 0)
if (total === 0) return 0
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()
+10 -1
View File
@@ -152,7 +152,8 @@
<div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
<div class="flex items-center gap-4 text-xs text-gray-400">
<span>
评分 <span :class="getScoreClass(reply.spec.score)" class="font-medium text-gray-600">{{ reply.spec.score }}</span>
评分 <span :class="getScoreClass(reply.spec.score)" class="font-medium">{{ reply.spec.score }}</span>
<span class="text-gray-300 ml-0.5">{{ getScoreLabel(reply.spec.score) }}</span>
</span>
<span v-if="reply.spec.postSlug" class="flex items-center gap-1">
文章
@@ -508,6 +509,14 @@ const getScoreClass = (score: number) => {
return "text-red-600"
}
const getScoreLabel = (score: number) => {
if (score >= 85) return "优秀"
if (score >= 70) return "良好"
if (score >= 50) return "一般"
if (score > 0) return "较差"
return ""
}
const getStatusClass = (status: string) => {
switch (status) {
case "PASS":
+385 -270
View File
@@ -5,291 +5,324 @@
<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>
<VSpace spacing="sm">
<VButton size="sm" @click="exportConfig">
<template #icon>
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</template>
导出
</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">
<svg style="width:14px;height:14px;margin-right:4px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
导入
<input type="file" accept=".json" class="hidden" @change="handleImportFile" />
</label>
<VButton size="sm" @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-4">
<VLoading v-if="loading" />
<div v-if="!loading" class="settings-container">
<!-- Left: Settings Sections -->
<div class="settings-sections space-y-5">
<!-- ========== 基本设置 ========== -->
<section class="settings-section">
<div class="section-header section-header--blue">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<div class="section-header__text">
<h3>基本设置</h3>
<p>控制AI回复的基本行为</p>
</div>
</div>
<div class="section-body">
<!-- Switch Row: 自动回复 -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">自动回复</span>
<span class="form-hint">启用后AI将自动回复新评论</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="settings.basic.autoReply" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
<!-- Switch Row: 自动发布 -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">自动发布</span>
<span class="form-hint">关闭后AI回复将存为草稿需手动审核发布</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="settings.basic.autoPublish" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
<!-- Slider: 最大对话轮次 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">最大对话轮次</span>
<span class="form-badge">{{ settings.basic.maxConversationRounds }}</span>
</div>
<span class="form-hint">同一评论线程中AI最多自动回复的轮次</span>
<div class="slider">
<input type="range" v-model.number="settings.basic.maxConversationRounds" min="1" max="100" class="slider__input" />
<div class="slider__marks">
<span>1</span><span>50</span><span>100</span>
</div>
</div>
</div>
<!-- Input: 速率限制 -->
<div class="form-field">
<label class="form-label">速率限制</label>
<span class="form-hint">每分钟最大AI回复数量</span>
<input type="number" v-model.number="settings.basic.rateLimitPerMinute" min="1" max="100" class="form-input" placeholder="10" />
</div>
<!-- Slider: 最大重试次数 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">最大重试次数</span>
<span class="form-badge">{{ settings.basic.maxRetryCount }}</span>
</div>
<span class="form-hint">AI生成失败时的最大重试次数采用指数退避策略</span>
<div class="slider">
<input type="range" v-model.number="settings.basic.maxRetryCount" min="1" max="10" class="slider__input" />
<div class="slider__marks">
<span>1</span><span>5</span><span>10</span>
</div>
</div>
</div>
<!-- Textarea: 评论者黑名单 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">评论者黑名单</span>
<button class="btn-link" @click="openCommenterDialog">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
添加评论者
</button>
</div>
<span class="form-hint">支持名称邮箱和正则表达式正则以 regex: 开头 regex:^spam.*</span>
<textarea v-model="settings.basic.blockedCommenters" rows="2" class="form-textarea" placeholder="例如:张三, spam@example.com, 李四"></textarea>
</div>
</div>
</section>
<div v-if="!loading">
<!-- Tab Navigation (full width, above the grid) -->
<div class="settings-tabs">
<button
v-for="tab in tabItems"
:key="tab.value"
class="settings-tab"
:class="{ 'settings-tab--active': activeTab === tab.value }"
@click="activeTab = tab.value"
>
{{ tab.label }}
</button>
</div>
<!-- ========== AI角色设置 ========== -->
<section class="settings-section">
<div class="section-header section-header--purple">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
</div>
<div class="section-header__text">
<h3>AI角色设置</h3>
<p>定义AI虚拟评论者的身份和风格</p>
</div>
</div>
<div class="section-body">
<!-- Persona List -->
<VLoading v-if="personasLoading" />
<div v-else-if="personas.length === 0" class="persona-empty">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>请添加至少一个AI角色</span>
</div>
<div v-else class="persona-list">
<div v-for="p in personas" :key="p.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>
</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>
</div>
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
</div>
<div class="persona-card__actions">
<button class="btn-icon" title="编辑" @click="openPersonaDialog(p)">
<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)">
<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)">
<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>
<!-- Settings Grid: sections (left) + sidebar (right) -->
<div class="settings-container">
<!-- Left: Settings Sections -->
<div class="settings-sections space-y-5">
<!-- ========== 基本设置 ========== -->
<section v-if="activeTab === 'basic'" class="settings-section">
<div class="section-header section-header--blue">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<div class="section-header__text">
<h3>基本设置</h3>
<p>控制AI回复的基本行为</p>
</div>
</div>
<VButton type="secondary" @click="openPersonaDialog(null)">
<svg style="width:16px;height:16px;margin-right:4px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
添加角色
</VButton>
</div>
</section>
<!-- ========== 模型设置 ========== -->
<section class="settings-section">
<div class="section-header section-header--green">
<div class="section-header__icon">
<svg 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 class="section-header__text">
<h3>模型设置</h3>
<p>配置AI Foundation提供的模型</p>
</div>
</div>
<div class="section-body">
<div class="form-field">
<label class="form-label">AI模型名称</label>
<span class="form-hint">留空使用AI Foundation默认模型填写AiModel资源名称可指定模型</span>
<input type="text" v-model="settings.model.modelName" class="form-input" placeholder="留空使用默认模型" />
</div>
</div>
</section>
<!-- ========== Prompt设置 ========== -->
<section class="settings-section">
<div class="section-header section-header--amber">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<div class="section-header__text">
<h3>Prompt设置</h3>
<p>自定义AI回复的提示词模板</p>
</div>
</div>
<div class="section-body">
<!-- Preset Selection -->
<div class="form-field">
<label class="form-label">Prompt预设</label>
<span class="form-hint">选择预设风格可多选</span>
<div class="preset-grid">
<label v-for="p in promptPresets" :key="p.key" class="preset-item" :class="{ 'preset-item--active': isPresetEnabled(p.key) }">
<input type="checkbox" :checked="isPresetEnabled(p.key)" @change="togglePreset(p.key)" class="preset-checkbox" />
<div class="preset-item__content">
<span class="preset-item__label">{{ p.label }}</span>
<span class="preset-item__desc">{{ p.desc }}</span>
</div>
<div class="section-body">
<!-- Switch Row: 自动回复 -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">自动回复</span>
<span class="form-hint">启用后AI将自动回复新评论</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="settings.basic.autoReply" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
</div>
<div class="form-field">
<div class="form-field__header">
<label class="form-label">定义Prompt模板</label>
<span class="form-hint--inline">留空使用默认模板</span>
<!-- Switch Row: 自动发布 -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">动发布</span>
<span class="form-hint">关闭后AI回复将存为草稿需手动审核发布</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="settings.basic.autoPublish" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
<div class="var-tags">
<span v-for="v in promptVariables" :key="v.name" class="var-tag">
<code>{{ v.name }}</code> {{ v.desc }}
</span>
</div>
<textarea v-model="settings.prompt.customPromptTemplate" rows="10" class="form-textarea form-textarea--mono" placeholder="自定义Prompt模板"></textarea>
</div>
</div>
</section>
<!-- ========== 数据清理 ========== -->
<section class="settings-section">
<div class="section-header section-header--red">
<div class="section-header__icon">
<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>
</div>
<div class="section-header__text">
<h3>数据清理</h3>
<p>自动清理过期的AI回复记录</p>
</div>
</div>
<div class="section-body">
<!-- Switch: 启用自动清理 -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">启用自动清理</span>
<span class="form-hint">每天自动清理超过保留天数的记录</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="settings.cleanup.cleanupEnabled" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
<!-- Slider: 保留天数 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">保留天数</span>
<span class="form-badge">{{ settings.cleanup.retentionDays }} </span>
</div>
<span class="form-hint">超过此天数的AI回复记录将被自动清理</span>
<div class="slider">
<input type="range" v-model.number="settings.cleanup.retentionDays" min="1" max="365" class="slider__input" />
<div class="slider__marks">
<span>1</span><span>180</span><span>365</span>
<!-- Slider: 最大对话轮次 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">最大对话轮次</span>
<span class="form-badge">{{ settings.basic.maxConversationRounds }}</span>
</div>
<span class="form-hint">同一评论线程中AI最多自动回复的轮次</span>
<div class="slider">
<input type="range" v-model.number="settings.basic.maxConversationRounds" min="1" max="100" class="slider__input" />
<div class="slider__marks">
<span>1</span><span>50</span><span>100</span>
</div>
</div>
</div>
</div>
<!-- Manual Cleanup -->
<div class="form-row form-row--bordered">
<div class="form-row__label">
<span class="form-label">手动清理</span>
<span class="form-hint">立即执行一次清理操作</span>
<!-- Input: 速率限制 -->
<div class="form-field">
<label class="form-label">速率限制</label>
<span class="form-hint">每分钟最大AI回复数量</span>
<input type="number" v-model.number="settings.basic.rateLimitPerMinute" min="1" max="100" class="form-input" placeholder="10" />
</div>
<div class="form-row__action">
<VButton size="sm" type="secondary" @click="performCleanup" :disabled="cleanupLoading">
{{ cleanupLoading ? '清理中...' : '立即清理' }}
<!-- Slider: 最大重试次数 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">最大重试次数</span>
<span class="form-badge">{{ settings.basic.maxRetryCount }}</span>
</div>
<span class="form-hint">AI生成失败时的最大重试次数采用指数退避策略</span>
<div class="slider">
<input type="range" v-model.number="settings.basic.maxRetryCount" min="1" max="10" class="slider__input" />
<div class="slider__marks">
<span>1</span><span>5</span><span>10</span>
</div>
</div>
</div>
<!-- Textarea: 评论者黑名单 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">评论者黑名单</span>
<button class="btn-link" @click="openCommenterDialog">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
添加评论者
</button>
</div>
<span class="form-hint">支持名称邮箱和正则表达式正则以 regex: 开头 regex:^spam.*</span>
<textarea v-model="settings.basic.blockedCommenters" rows="2" class="form-textarea" placeholder="例如:张三, spam@example.com, 李四"></textarea>
</div>
</div>
</section>
<!-- ========== AI角色设置 ========== -->
<section v-if="activeTab === 'persona'" class="settings-section">
<div class="section-header section-header--purple">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
</div>
<div class="section-header__text">
<h3>AI角色设置</h3>
<p>定义AI虚拟评论者的身份和风格</p>
</div>
</div>
<div class="section-body">
<!-- Persona List -->
<VLoading v-if="personasLoading" />
<div v-else-if="personas.length === 0" class="persona-empty">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>请添加至少一个AI角色</span>
</div>
<div v-else class="persona-list">
<div v-for="p in personas" :key="p.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>
</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>
</div>
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
</div>
<div class="persona-card__actions">
<button class="btn-icon" title="编辑" @click="openPersonaDialog(p)">
<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)">
<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)">
<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>
</div>
</div>
<VButton type="secondary" @click="openPersonaDialog(null)">
<svg style="width:16px;height:16px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
添加角色
</VButton>
</div>
</section>
<!-- ========== 模型设置 ========== -->
<section v-if="activeTab === 'model'" class="settings-section">
<div class="section-header section-header--green">
<div class="section-header__icon">
<svg 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 class="section-header__text">
<h3>模型设置</h3>
<p>配置AI Foundation提供的模型</p>
</div>
</div>
<div class="section-body">
<div class="form-field">
<label class="form-label">AI模型名称</label>
<span class="form-hint">留空使用AI Foundation默认模型填写AiModel资源名称可指定模型</span>
<input type="text" v-model="settings.model.modelName" class="form-input" placeholder="留空使用默认模型" />
</div>
</div>
</section>
<!-- ========== Prompt设置 ========== -->
<section v-if="activeTab === 'prompt'" class="settings-section">
<div class="section-header section-header--amber">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<div class="section-header__text">
<h3>Prompt设置</h3>
<p>自定义AI回复的提示词模板</p>
</div>
</div>
<div class="section-body">
<!-- Preset Selection -->
<div class="form-field">
<label class="form-label">Prompt预设</label>
<span class="form-hint">选择预设风格可多选</span>
<div class="preset-grid">
<label v-for="p in promptPresets" :key="p.key" class="preset-item" :class="{ 'preset-item--active': isPresetEnabled(p.key) }">
<input type="checkbox" :checked="isPresetEnabled(p.key)" @change="togglePreset(p.key)" class="preset-checkbox" />
<div class="preset-item__content">
<span class="preset-item__label">{{ p.label }}</span>
<span class="preset-item__desc">{{ p.desc }}</span>
</div>
</label>
</div>
</div>
<div class="form-field">
<div class="form-field__header">
<label class="form-label">自定义Prompt模板</label>
<span class="form-hint--inline">留空使用默认模板</span>
</div>
<div class="var-tags">
<span v-for="v in promptVariables" :key="v.name" class="var-tag">
<code>{{ v.name }}</code> {{ v.desc }}
</span>
</div>
<textarea v-model="settings.prompt.customPromptTemplate" rows="10" class="form-textarea form-textarea--mono" placeholder="自定义Prompt模板"></textarea>
</div>
</div>
</section>
<!-- ========== 数据清理 ========== -->
<section v-if="activeTab === 'cleanup'" class="settings-section">
<div class="section-header section-header--red">
<div class="section-header__icon">
<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>
</div>
<div class="section-header__text">
<h3>数据清理</h3>
<p>自动清理过期的AI回复记录</p>
</div>
</div>
<div class="section-body">
<!-- Switch: 启用自动清理 -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">启用自动清理</span>
<span class="form-hint">每天自动清理超过保留天数的记录</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="settings.cleanup.cleanupEnabled" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
<!-- Slider: 保留天数 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">保留天数</span>
<span class="form-badge">{{ settings.cleanup.retentionDays }} </span>
</div>
<span class="form-hint">超过此天数的AI回复记录将被自动清理</span>
<div class="slider">
<input type="range" v-model.number="settings.cleanup.retentionDays" min="1" max="365" class="slider__input" />
<div class="slider__marks">
<span>1</span><span>180</span><span>365</span>
</div>
</div>
</div>
<!-- Manual Cleanup -->
<div class="form-row form-row--bordered">
<div class="form-row__label">
<span class="form-label">手动清理</span>
<span class="form-hint">立即执行一次清理操作</span>
</div>
<div class="form-row__action">
<VButton size="sm" type="secondary" @click="performCleanup" :disabled="cleanupLoading">
{{ cleanupLoading ? '清理中...' : '立即清理' }}
</VButton>
</div>
</div>
<div v-if="cleanupResult !== null" class="cleanup-result">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
清理完成共删除 {{ cleanupResult }} 条记录
</div>
</div>
</section>
</div>
<!-- Right: Sticky Save Bar -->
<div class="settings-sidebar">
<div class="settings-sidebar__inner">
<div class="sidebar-card sidebar-card--primary">
<div class="sidebar-card__header">
<h4 class="sidebar-card__title">操作</h4>
<span v-if="hasUnsavedChanges" class="sidebar-card__badge">未保存</span>
</div>
<div class="sidebar-card__actions">
<VButton block type="primary" @click="saveSettings" :disabled="saving">
<template v-if="!saving">
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</template>
{{ saving ? '保存中...' : '保存设置' }}
</VButton>
<VButton block @click="fetchSettings" :disabled="saving">
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
重置为当前值
</VButton>
</div>
</div>
<div v-if="cleanupResult !== null" class="cleanup-result">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
清理完成共删除 {{ cleanupResult }} 条记录
</div>
</div>
</section>
</div>
<!-- Right: Sticky Save Bar -->
<div class="settings-sidebar">
<div class="settings-sidebar__inner">
<div class="sidebar-card">
<h4 class="sidebar-card__title">操作</h4>
<div class="sidebar-card__actions">
<VButton block type="primary" @click="saveSettings" :disabled="saving">
{{ saving ? '保存中...' : '保存设置' }}
</VButton>
<VButton block @click="fetchSettings">重置为当前值</VButton>
</div>
</div>
<div class="sidebar-card">
<h4 class="sidebar-card__title">可用模板变量</h4>
<div class="sidebar-card__vars">
<div v-for="v in promptVariables" :key="v.name" class="sidebar-var">
<code>{{ v.name }}</code>
<span>{{ v.desc }}</span>
<div v-if="activeTab === 'prompt'" class="sidebar-card">
<h4 class="sidebar-card__title">可用模板变量</h4>
<div class="sidebar-card__vars">
<div v-for="v in promptVariables" :key="v.name" class="sidebar-var">
<code>{{ v.name }}</code>
<span>{{ v.desc }}</span>
</div>
</div>
</div>
</div>
@@ -414,8 +447,16 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from "vue"
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"
import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace, IconPlug } from "@halo-dev/components"
const activeTab = ref("basic")
const tabItems = [
{ label: "基本设置", value: "basic" },
{ label: "AI角色", value: "persona" },
{ label: "模型设置", value: "model" },
{ label: "Prompt", value: "prompt" },
{ label: "数据清理", value: "cleanup" },
]
const promptVariables = [
{ name: '{{persona_prompt}}', desc: '人格提示词' },
@@ -489,6 +530,12 @@ const settings = reactive({
cleanup: { cleanupEnabled: true, retentionDays: 30 },
})
// Track unsaved changes: snapshot of settings after last fetch/save
const lastSavedSnapshot = ref("")
const hasUnsavedChanges = computed(() => {
return JSON.stringify(settings) !== lastSavedSnapshot.value
})
const configMapName = "comment-ai-autopilot-configmap"
const apiBase = "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1"
@@ -817,7 +864,11 @@ const fetchSettings = async () => {
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) }
finally { loading.value = false }
finally {
loading.value = false
// Update snapshot after fetch to reset unsaved indicator
lastSavedSnapshot.value = JSON.stringify(settings)
}
}
const saveSettings = async () => {
@@ -834,6 +885,8 @@ const saveSettings = async () => {
}
await coreApiClient.configMap.updateConfigMap({ name: configMapName, configMap: updated })
Toast.success("设置已保存")
// Update snapshot after save to reset unsaved indicator
lastSavedSnapshot.value = JSON.stringify(settings)
} catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") }
finally { saving.value = false }
}
@@ -847,6 +900,43 @@ onMounted(async () => {
<style scoped>
/* ===== Layout ===== */
.settings-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
padding: 4px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow-x: auto;
}
.settings-tab {
flex: 1;
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
color: #6b7280;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-align: center;
}
.settings-tab:hover {
background: #f3f4f6;
color: #374151;
}
.settings-tab--active {
background: #3b82f6;
color: #fff;
}
.settings-tab--active:hover {
background: #2563eb;
color: #fff;
}
.settings-container {
display: grid;
grid-template-columns: 1fr 280px;
@@ -1221,14 +1311,39 @@ onMounted(async () => {
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
padding: 20px;
}
.sidebar-card--primary {
border-color: #dbeafe;
background: linear-gradient(135deg, #eff6ff 0%, #fff 100%);
}
.sidebar-card__header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 10px;
border-bottom: 1px solid #f3f4f6;
}
.sidebar-card__badge {
display: inline-flex; align-items: center;
padding: 2px 8px; font-size: 11px; font-weight: 500;
background: #fef3c7; color: #d97706; border-radius: 4px;
}
.sidebar-card__title {
font-size: 14px; font-weight: 600; color: #1f2937;
margin: 0 0 14px; padding-bottom: 10px;
border-bottom: 1px solid #f3f4f6;
}
.sidebar-card--primary .sidebar-card__title {
margin-bottom: 0; padding-bottom: 0; border-bottom: none;
}
.sidebar-card__actions {
display: flex; flex-direction: column; gap: 8px;
}
/* Align icon + text inside VButton (btn-content wraps the default slot) */
.sidebar-card__actions :deep(.btn-content),
.section-body :deep(.btn-content) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.sidebar-card__vars {
display: flex; flex-direction: column; gap: 8px;
}