feat: v1.0.0-beta.1 - AI Foundation reflection integration and draft mode fix

This commit is contained in:
sunny-335
2026-06-16 23:20:51 +08:00
parent 72726da9b8
commit d737cbc8f1
26 changed files with 410 additions and 601 deletions
+73 -2
View File
@@ -177,6 +177,53 @@
</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 -->
@@ -252,11 +299,16 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue"
import { ref, computed, onMounted, watch } 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
@@ -264,6 +316,7 @@ interface StatsResponse {
reviewingCount: number
avgScore: number
sentimentDistribution: Record<string, number>
dailyTrend: DailyCount[]
}
interface PersonaResponse {
@@ -278,6 +331,7 @@ 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)
@@ -303,7 +357,7 @@ const scoreBarColor = computed(() => {
const fetchStats = async () => {
try {
const { data } = await axiosInstance.get(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=7`,
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=${range.value}`,
)
stats.value = data
} catch (e) {
@@ -375,6 +429,23 @@ 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()
+2 -70
View File
@@ -65,9 +65,6 @@
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"
@@ -175,12 +172,6 @@
</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)"
@@ -308,28 +299,6 @@
</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>
@@ -382,8 +351,6 @@ 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)) {
@@ -410,12 +377,6 @@ 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 {
@@ -423,8 +384,6 @@ 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 },
@@ -434,7 +393,6 @@ 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
}
@@ -457,34 +415,10 @@ 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(
`/apis/comment-ai-autopilot.nxxy335.top/v1alpha1/aicommentreplies/${name}`,
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`,
)
Toast.success("删除成功")
fetchReplies()
@@ -675,13 +609,11 @@ const resetFilters = () => {
filterStatus.value = ""
filterSentiment.value = ""
filterKeyword.value = ""
filterStartDate.value = ""
filterEndDate.value = ""
page.value = 1
fetchReplies()
}
watch([filterStatus, filterSentiment, filterKeyword, filterStartDate, filterEndDate], () => {
watch([filterStatus, filterSentiment, filterKeyword], () => {
page.value = 1
fetchReplies()
})
+11 -104
View File
@@ -122,38 +122,26 @@
<span>请添加至少一个AI角色</span>
</div>
<div v-else class="persona-list">
<div v-for="(persona, index) in personas" :key="persona.metadata.name" class="persona-card">
<div v-for="p in personas" :key="p.metadata.name" class="persona-card">
<div class="persona-card__avatar">
<img v-if="getPersonaAvatar(persona)" :src="getPersonaAvatar(persona)" alt="头像" />
<span v-else class="persona-card__avatar-fallback">{{ (persona.spec.displayName || '?').charAt(0) }}</span>
<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">
{{ persona.spec.displayName || '未命名' }}
<span v-if="persona.spec.isDefault" class="persona-card__badge">默认</span>
{{ p.spec.displayName || '未命名' }}
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
</div>
<div class="persona-card__prompt">{{ persona.spec.prompt || '暂无提示词' }}</div>
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
</div>
<div class="persona-card__actions">
<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)">
<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="!persona.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(persona)">
<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="!persona.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(persona)">
<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>
@@ -444,9 +432,6 @@ 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({
@@ -642,7 +627,6 @@ const fetchPersonas = async () => {
})
} catch (e) {
console.error("Failed to fetch personas", e)
Toast.error("获取角色列表失败")
personas.value = []
} finally {
personasLoading.value = false
@@ -727,7 +711,7 @@ const savePersona = async () => {
isDefault: personaForm.isDefault,
},
apiVersion: 'comment-ai-autopilot.nxxy335.top/v1alpha1',
kind: 'Persona',
kind: 'AiPersona',
metadata: personaEditing.value
? { name: personaEditing.value.metadata.name }
: { generateName: 'persona-' },
@@ -795,56 +779,6 @@ 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) => {
@@ -882,7 +816,7 @@ const fetchSettings = async () => {
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); Toast.error("获取设置失败") }
} catch (e) { console.error("Failed to fetch settings", e) }
finally { loading.value = false }
}
@@ -922,33 +856,6 @@ 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 {