feat: v1.0.0-beta.1 - AI Foundation reflection integration and draft mode fix
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user