style: 彻底重写 LogsView & SettingsView - 原生 Scoped CSS 替代 Tailwind

- LogsView.vue: 移除所有 Tailwind 类,改用 <style scoped> 原生 CSS
- SettingsView.vue: 移除所有 Tailwind 类,改用 <style scoped> 原生 CSS
- 标签配色、气泡样式、引用框全部使用纯 CSS 实现,避免 Halo 主题冲突
- 版本号升级至 1.0.2 强制刷新前端缓存
This commit is contained in:
bbb-lsy07
2026-06-21 11:00:49 +08:00
parent 4d8b6ffc76
commit 9015acaa73
4 changed files with 330 additions and 1061 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
} }
group 'top.nxxy335.commentaiautopilot' group 'top.nxxy335.commentaiautopilot'
version '1.0.1' version '1.0.2'
repositories { repositories {
mavenCentral() mavenCentral()
+1 -1
View File
@@ -30,4 +30,4 @@ spec:
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
settingName: "comment-ai-autopilot-settings" settingName: "comment-ai-autopilot-settings"
configMapName: "comment-ai-autopilot-configmap" configMapName: "comment-ai-autopilot-configmap"
version: "1.0.1" version: "1.0.2"
+169 -206
View File
@@ -1,33 +1,31 @@
<template> <template>
<div class="comment-ai-autopilot-logs"> <div class="logs-container">
<VPageHeader title="AI回复日志"> <VPageHeader title="AI回复日志">
<template #icon> <template #icon><IconPlug class="header-icon" /></template>
<IconPlug class="mr-2 self-center" />
</template>
<template #actions> <template #actions>
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })"> 返回概览 </VButton> <VButton @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
</template> </template>
</VPageHeader> </VPageHeader>
<!-- 批量操作工具栏 --> <!-- 批量操作工具栏 -->
<div v-if="selectedNames.size > 0" class="m-4 mb-0 flex flex-wrap items-center gap-3 bg-blue-50 border border-blue-200 rounded-lg px-4 py-3"> <div v-if="selectedNames.size > 0" class="toolbar batch-toolbar">
<span class="text-sm text-blue-700 font-medium">已选择 {{ selectedNames.size }} </span> <span class="batch-text">已选择 {{ selectedNames.size }} </span>
<button class="px-3 py-1.5 text-xs font-medium rounded-md bg-green-600 text-white hover:bg-green-700 transition" @click="batchApprove">批量通过</button> <button class="btn-batch btn-pass" @click="batchApprove">批量通过</button>
<button class="px-3 py-1.5 text-xs font-medium rounded-md bg-orange-500 text-white hover:bg-orange-600 transition" @click="batchReject">批量拒绝</button> <button class="btn-batch btn-reject" @click="batchReject">批量拒绝</button>
<button class="px-3 py-1.5 text-xs font-medium rounded-md bg-red-600 text-white hover:bg-red-700 transition" @click="batchDelete">批量删除</button> <button class="btn-batch btn-delete" @click="batchDelete">批量删除</button>
<button class="px-3 py-1.5 text-xs font-medium rounded-md text-gray-500 hover:bg-gray-200 transition ml-auto" @click="selectedNames.clear(); selectAll = false">取消选择</button> <button class="btn-batch btn-cancel" @click="selectedNames.clear(); selectAll = false">取消选择</button>
</div> </div>
<!-- 过滤工具栏 --> <!-- 过滤工具栏 -->
<div class="m-4 mb-0 flex flex-col md:flex-row items-center gap-3"> <div class="toolbar filter-toolbar">
<select v-model="filterStatus" class="w-full md:w-auto px-3 py-2 text-sm border border-gray-200 rounded-lg bg-gray-50 focus:bg-white focus:border-blue-500 outline-none transition"> <select v-model="filterStatus" class="filter-select">
<option value="">全部状态</option> <option value="">全部状态</option>
<option value="PASS">通过</option> <option value="PASS">通过</option>
<option value="FAIL">失败</option> <option value="FAIL">失败</option>
<option value="PENDING">待审核</option> <option value="PENDING">待审核</option>
<option value="REJECTED">已拒绝</option> <option value="REJECTED">已拒绝</option>
</select> </select>
<select v-model="filterSentiment" class="w-full md:w-auto px-3 py-2 text-sm border border-gray-200 rounded-lg bg-gray-50 focus:bg-white focus:border-blue-500 outline-none transition"> <select v-model="filterSentiment" class="filter-select">
<option value="">全部情感</option> <option value="">全部情感</option>
<option value="VERY_POSITIVE">非常正面</option> <option value="VERY_POSITIVE">非常正面</option>
<option value="POSITIVE">正面</option> <option value="POSITIVE">正面</option>
@@ -35,79 +33,60 @@
<option value="NEGATIVE">负面</option> <option value="NEGATIVE">负面</option>
<option value="VERY_NEGATIVE">非常负面</option> <option value="VERY_NEGATIVE">非常负面</option>
</select> </select>
<div class="relative w-full md:w-64"> <input v-model="filterKeyword" type="text" placeholder="搜索回复内容..." class="filter-input" />
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button class="btn-reset" @click="resetFilters">重置</button>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input v-model="filterKeyword" type="text" placeholder="搜索回复内容..." class="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded-lg bg-gray-50 focus:bg-white focus:border-blue-500 outline-none transition" />
</div>
<button class="w-full md:w-auto flex items-center justify-center gap-1 px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-lg transition" @click="resetFilters">
<svg class="w-4 h-4" 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>
重置
</button>
</div> </div>
<!-- 列表区 --> <!-- 列表区 -->
<div class="m-4"> <div class="list-area">
<VLoading v-if="loading" /> <VLoading v-if="loading" />
<div v-else-if="replies.length === 0" class="empty-state">暂无记录</div>
<div v-else-if="replies.length === 0" class="flex flex-col items-center justify-center py-16 text-gray-400"> <div v-else class="reply-list">
<svg class="w-12 h-12 mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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 class="select-all-wrap">
<span class="text-sm">暂无记录</span> <input type="checkbox" :checked="selectAll" @change="toggleSelectAll" />
<span>全选本页</span>
</div> </div>
<div v-else class="space-y-4"> <div v-for="reply in replies" :key="reply.metadata.name" class="reply-card">
<div class="flex items-center gap-2 px-1"> <div class="card-main">
<input type="checkbox" :checked="selectAll" @change="toggleSelectAll" class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> <input type="checkbox" :checked="selectedNames.has(reply.metadata.name)" @change="toggleSelect(reply.metadata.name)" />
<span class="text-xs text-gray-500">全选本页</span> <div class="card-content">
<div class="card-header">
<div class="tags-wrap">
<span class="custom-tag" :class="'tag-' + reply.spec.status">{{ getStatusLabel(reply.spec.status) }}</span>
<span class="custom-tag" :class="reply.spec.published ? 'tag-published' : 'tag-draft'">{{ reply.spec.published ? '已发布' : '未发布' }}</span>
<span v-if="reply.spec.isAiConversation" class="custom-tag tag-conv">对话</span>
<span v-if="reply.spec.sentiment" class="custom-tag" :class="'tag-' + reply.spec.sentiment">{{ getSentimentLabel(reply.spec.sentiment) }}</span>
</div> </div>
<span class="card-time">{{ formatDate(reply.metadata.creationTimestamp) }}</span>
<div v-for="reply in replies" :key="reply.metadata.name" class="bg-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition duration-200">
<div class="p-4 flex items-start gap-3">
<input type="checkbox" :checked="selectedNames.has(reply.metadata.name)" @change="toggleSelect(reply.metadata.name)" class="mt-1 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 shrink-0" />
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="getStatusClass(reply.spec.status)">{{ getStatusLabel(reply.spec.status) }}</span>
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="reply.spec.published ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-500'">{{ reply.spec.published ? '已发布' : '未发布' }}</span>
<span v-if="reply.spec.isAiConversation" class="px-2 py-0.5 rounded text-xs font-medium bg-purple-50 text-purple-700">对话</span>
<span v-if="reply.spec.sentiment" class="px-2 py-0.5 rounded text-xs font-medium" :class="getSentimentClass(reply.spec.sentiment)">{{ getSentimentLabel(reply.spec.sentiment) }}</span>
</div> </div>
<span class="text-xs text-gray-400 shrink-0">{{ formatDate(reply.metadata.creationTimestamp) }}</span> <div class="card-text">{{ stripHtml(reply.spec.reply) || '(空)' }}</div>
</div>
<div class="text-sm text-gray-700 leading-relaxed break-words line-clamp-3">
{{ stripHtml(reply.spec.reply) || '(空)' }}
</div> </div>
</div> </div>
</div> <div class="card-footer">
<div class="footer-info">
<div class="px-4 py-3 bg-gray-50 border-t border-gray-100 flex flex-wrap items-center justify-between gap-3"> <span>评分: <strong>{{ reply.spec.score }}</strong></span>
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-500"> <span v-if="reply.spec.postSlug">
<span class="flex items-center gap-1"> 关联: <a :href="getPostUrl(reply.spec.postSlug)" target="_blank">{{ reply.spec.postSlug }}</a>
评分 <span :class="getScoreTextClass(reply.spec.score)" class="font-bold">{{ reply.spec.score }}</span>
<span class="px-1.5 py-0.5 rounded ml-1" :class="getScoreBgClass(reply.spec.score)">{{ getScoreLabel(reply.spec.score) }}</span>
</span> </span>
<span v-if="reply.spec.postSlug" class="flex items-center gap-1"> <span v-if="reply.spec.retryCount > 0" class="retry-text">重试 {{ reply.spec.retryCount }} </span>
{{ reply.spec.postKind === 'SinglePage' ? '页面' : '文章' }}
<a :href="reply.spec.postKind === 'SinglePage' ? getPageUrl(reply.spec.postSlug) : getPostUrl(reply.spec.postSlug)" target="_blank" class="text-blue-500 hover:underline">{{ reply.spec.postSlug }}</a>
</span>
<span v-if="reply.spec.retryCount > 0" class="text-orange-500">重试 {{ reply.spec.retryCount }} </span>
</div> </div>
<div class="flex items-center gap-2"> <div class="footer-actions">
<template v-if="reply.spec.status === 'PASS' && !reply.spec.published"> <template v-if="reply.spec.status === 'PASS' && !reply.spec.published">
<button class="px-2 py-1 text-xs text-green-600 bg-green-50 hover:bg-green-100 rounded transition" @click="handleApprove(reply.metadata.name)">审核通过</button> <button class="action-btn pass" @click="handleApprove(reply.metadata.name)">通过</button>
<button class="px-2 py-1 text-xs text-red-600 bg-red-50 hover:bg-red-100 rounded transition" @click="handleReject(reply.metadata.name)">拒绝</button> <button class="action-btn reject" @click="handleReject(reply.metadata.name)">拒绝</button>
</template> </template>
<button class="px-2 py-1 flex items-center gap-1 text-xs text-blue-600 bg-blue-50 hover:bg-blue-100 rounded transition" @click="openConversation(reply)">查看对话</button> <button class="action-btn view" @click="openConversation(reply)">查看对话</button>
<button class="px-2 py-1 text-xs text-gray-500 bg-gray-200 hover:bg-red-50 hover:text-red-600 rounded transition" @click="handleDelete(reply.metadata.name)">删除</button> <button class="action-btn delete" @click="handleDelete(reply.metadata.name)">删除</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="totalPages > 1" class="flex items-center justify-between mt-6 px-2"> <div v-if="totalPages > 1" class="pagination">
<span class="text-sm text-gray-500"> {{ total }} </span> <span> {{ total }} </span>
<div class="flex gap-2"> <div>
<VButton size="sm" :disabled="page <= 1" @click="page--">上一页</VButton> <VButton size="sm" :disabled="page <= 1" @click="page--">上一页</VButton>
<VButton size="sm" :disabled="page >= totalPages" @click="page++">下一页</VButton> <VButton size="sm" :disabled="page >= totalPages" @click="page++">下一页</VButton>
</div> </div>
@@ -116,38 +95,30 @@
<!-- 完整对话弹窗 --> <!-- 完整对话弹窗 -->
<teleport to="body"> <teleport to="body">
<div v-if="showDialog" class="fixed inset-0 z-[9999] flex items-center justify-center p-4"> <div v-if="showDialog" class="dialog-overlay" @click.self="showDialog = false">
<div class="absolute inset-0 bg-gray-900/50 backdrop-blur-sm" @click="showDialog = false"></div> <div class="dialog-box">
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden"> <div class="dialog-header">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100"> <h3>对话上下文</h3>
<h3 class="text-lg font-bold text-gray-800">对话上下文</h3> <button class="close-btn" @click="showDialog = false">×</button>
<button class="p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700 rounded-lg transition" @click="showDialog = 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>
<div class="flex-1 overflow-y-auto p-6 space-y-5 bg-gray-50/50"> <div class="dialog-body">
<VLoading v-if="conversationLoading" /> <VLoading v-if="conversationLoading" />
<div v-else-if="conversationMessages.length === 0" class="text-center text-gray-400 py-10 text-sm">暂无内容</div> <div v-else-if="conversationMessages.length === 0" class="empty-state">暂无内容</div>
<div v-else> <div v-else class="chat-container">
<div v-for="(msg, idx) in conversationMessages" :key="idx" class="flex mb-5" :class="msg.isAi ? 'justify-start' : 'justify-end'"> <div v-for="(msg, idx) in conversationMessages" :key="idx" class="chat-row" :class="msg.isAi ? 'row-ai' : 'row-user'">
<div class="max-w-[85%] md:max-w-[75%]"> <div class="chat-message">
<div class="text-xs mb-1.5 px-1 font-medium" :class="msg.isAi ? 'text-blue-600' : 'text-gray-500 text-right'"> <div class="chat-owner">{{ msg.owner }}</div>
{{ msg.owner }} <div class="chat-bubble" :class="msg.isAi ? 'bubble-ai' : 'bubble-user'">
<!-- 原生 CSS 渲染的引用框 -->
<div v-if="msg.quoteOwner && msg.quoteContent" class="quote-box">
<span class="quote-owner">@{{ msg.quoteOwner }}:</span>
<span class="quote-content">{{ truncateQuote(msg.quoteContent) }}</span>
</div> </div>
<div class="rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm break-words" :class="msg.isAi ? 'bg-white border border-blue-100 text-gray-800 rounded-tl-sm' : 'bg-blue-600 text-white rounded-tr-sm'">
<!-- 引用摘要模块如果该条消息有回复对象则渲染 --> <div class="chat-text" v-html="renderContent(msg.content)"></div>
<div v-if="msg.quoteOwner && msg.quoteContent"
class="mb-2.5 px-3 py-2 rounded-lg border-l-[3px] text-xs"
:class="msg.isAi ? 'bg-gray-50 border-gray-300 text-gray-500' : 'bg-black/10 border-white/40 text-blue-100'">
<span class="font-semibold" :class="msg.isAi ? 'text-gray-700' : 'text-white'">@{{ msg.quoteOwner }}</span>
{{ truncateQuote(msg.quoteContent, 40) }}
</div>
<!-- 实际发言内容 -->
<div v-html="renderContent(msg.content)"></div>
</div>
<div class="text-[11px] text-gray-400 mt-1.5 px-1" :class="msg.isAi ? 'text-left' : 'text-right'">
{{ formatDate(msg.time) }}
</div> </div>
<div class="chat-time">{{ formatDate(msg.time) }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -164,68 +135,29 @@ import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components" import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components" import { IconPlug } from "@halo-dev/components"
interface AiCommentReplyItem { interface AiCommentReplyItem { metadata: { name: string; creationTimestamp: string }; spec: any }
metadata: { name: string; creationTimestamp: string } interface ConversationMessage { type: string; owner: string; content: string; time: string; isAi: boolean; quoteOwner?: string; quoteContent?: string }
spec: {
commentId: string; postId: string; postSlug: string; postKind: string
reply: string; score: number; status: string; retryCount: number
replyTo: string; isAiConversation: boolean; published: boolean; sentiment: string | null
}
}
interface ConversationMessage { const replies = ref<AiCommentReplyItem[]>([]); const loading = ref(false); const page = ref(1); const size = ref(20); const total = ref(0); const totalPages = ref(0);
type: string const selectedNames = ref<Set<string>>(new Set()); const selectAll = ref(false);
owner: string const filterStatus = ref(""); const filterSentiment = ref(""); const filterKeyword = ref("");
content: string const showDialog = ref(false); const conversationLoading = ref(false); const conversationMessages = ref<ConversationMessage[]>([]);
time: string
isAi: boolean
quoteOwner?: string
quoteContent?: string
}
const replies = ref<AiCommentReplyItem[]>([]) const toggleSelect = (name: string) => { selectedNames.value.has(name) ? selectedNames.value.delete(name) : selectedNames.value.add(name); selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name)) }
const loading = ref(false) const toggleSelectAll = () => { if (selectAll.value) { selectedNames.value.clear(); selectAll.value = false } else { selectedNames.value = new Set(replies.value.map(r => r.metadata.name)); selectAll.value = true } }
const page = ref(1)
const size = ref(20)
const total = ref(0)
const totalPages = ref(0)
const selectedNames = ref<Set<string>>(new Set())
const selectAll = ref(false)
const filterStatus = ref("")
const filterSentiment = ref("")
const filterKeyword = ref("")
const showDialog = ref(false)
const conversationLoading = ref(false)
const conversationMessages = ref<ConversationMessage[]>([])
const toggleSelect = (name: string) => {
selectedNames.value.has(name) ? selectedNames.value.delete(name) : selectedNames.value.add(name)
selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name))
}
const toggleSelectAll = () => {
if (selectAll.value) { selectedNames.value.clear(); selectAll.value = false }
else { selectedNames.value = new Set(replies.value.map(r => r.metadata.name)); selectAll.value = true }
}
const fetchReplies = async () => { const fetchReplies = async () => {
loading.value = true loading.value = true;
try { try {
const params: Record<string, string | number> = { page: page.value, size: size.value } const params: any = { page: page.value, size: size.value }
if (filterStatus.value) params.status = filterStatus.value if (filterStatus.value) params.status = filterStatus.value; if (filterSentiment.value) params.sentiment = filterSentiment.value; if (filterKeyword.value) params.keyword = filterKeyword.value;
if (filterSentiment.value) params.sentiment = filterSentiment.value
if (filterKeyword.value) params.keyword = filterKeyword.value
const { data } = await axiosInstance.get("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies", { params }) const { data } = await axiosInstance.get("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies", { params })
replies.value = data.items || []; total.value = data.total || 0; totalPages.value = Math.ceil(total.value / size.value) replies.value = data.items || []; total.value = data.total || 0; totalPages.value = Math.ceil(total.value / size.value)
} catch (e) { Toast.error("获取数据失败") } finally { loading.value = false } } catch (e) { Toast.error("获取数据失败") } finally { loading.value = false }
} }
const openConversation = async (reply: AiCommentReplyItem) => { const openConversation = async (reply: AiCommentReplyItem) => {
showDialog.value = true showDialog.value = true; conversationLoading.value = true; conversationMessages.value = []
conversationLoading.value = true
conversationMessages.value = []
try { try {
const { data } = await axiosInstance.get(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/conversation/${reply.spec.commentId}`) const { data } = await axiosInstance.get(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/conversation/${reply.spec.commentId}`)
conversationMessages.value = data.messages || [] conversationMessages.value = data.messages || []
@@ -235,85 +167,116 @@ const openConversation = async (reply: AiCommentReplyItem) => {
const handleDelete = async (name: string) => { try { await axiosInstance.delete(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`); Toast.success("删除成功"); fetchReplies() } catch (e) { Toast.error("删除失败") } } const handleDelete = async (name: string) => { try { await axiosInstance.delete(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`); Toast.success("删除成功"); fetchReplies() } catch (e) { Toast.error("删除失败") } }
const handleApprove = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/approve`); Toast.success("审核通过"); fetchReplies() } catch (e) { Toast.error("审核失败") } } const handleApprove = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/approve`); Toast.success("审核通过"); fetchReplies() } catch (e) { Toast.error("审核失败") } }
const handleReject = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/reject`); Toast.success("已拒绝"); fetchReplies() } catch (e) { Toast.error("拒绝失败") } } const handleReject = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/reject`); Toast.success("已拒绝"); fetchReplies() } catch (e) { Toast.error("拒绝失败") } }
const batchApprove = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-approve", { names: Array.from(selectedNames.value) }); Toast.success("批量通过成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("批量通过失败") } } const batchApprove = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-approve", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } }
const batchReject = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-reject", { names: Array.from(selectedNames.value) }); Toast.success("批量拒绝成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("批量拒绝失败") } } const batchReject = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-reject", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } }
const batchDelete = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-delete", { names: Array.from(selectedNames.value) }); Toast.success("批量删除成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("批量删除失败") } } const batchDelete = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-delete", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } }
const getScoreTextClass = (score: number) => score >= 85 ? "text-green-600" : score >= 60 ? "text-yellow-600" : "text-red-600"
const getScoreBgClass = (score: number) => score >= 85 ? "bg-green-100 text-green-700" : score >= 70 ? "bg-blue-100 text-blue-700" : score >= 50 ? "bg-yellow-100 text-yellow-800" : "bg-red-100 text-red-700"
const getScoreLabel = (score: number) => score >= 85 ? "优秀" : score >= 70 ? "良好" : score >= 50 ? "一般" : "较差"
const getStatusClass = (status: string) => {
switch (status) {
case "PASS": return "bg-green-100 text-green-700"
case "FAIL": return "bg-red-100 text-red-700"
case "REJECTED": return "bg-orange-100 text-orange-700"
default: return "bg-gray-100 text-gray-600"
}
}
const getStatusLabel = (status: string) => {
switch (status) {
case "PASS": return "通过"; case "FAIL": return "失败"; case "PENDING": return "待审核"; case "REJECTED": return "已拒绝"; default: return status
}
}
const getSentimentClass = (sentiment: string) => {
switch (sentiment) {
case "VERY_POSITIVE": return "bg-green-100 text-green-800"
case "POSITIVE": return "bg-emerald-50 text-emerald-600"
case "NEGATIVE": return "bg-rose-50 text-rose-600"
case "VERY_NEGATIVE": return "bg-red-100 text-red-800"
default: return "bg-gray-100 text-gray-600"
}
}
const getSentimentLabel = (sentiment: string) => {
switch (sentiment) {
case "VERY_POSITIVE": return "非常正面"; case "POSITIVE": return "正面"; case "NEGATIVE": return "负面"; case "VERY_NEGATIVE": return "非常负面"; default: return "中性"
}
}
const getStatusLabel = (s: string) => { const m:any = { PASS: '通过', FAIL: '失败', PENDING: '待审', REJECTED: '拒绝' }; return m[s] || s }
const getSentimentLabel = (s: string) => { const m:any = { VERY_POSITIVE: '极好', POSITIVE: '正面', NEUTRAL: '中性', NEGATIVE: '负面', VERY_NEGATIVE: '极差' }; return m[s] || s }
const formatDate = (ts: string) => ts ? new Date(ts).toLocaleString("zh-CN") : "" const formatDate = (ts: string) => ts ? new Date(ts).toLocaleString("zh-CN") : ""
const getPostUrl = (slug: string) => `${window.location.origin}/archives/${slug}` const getPostUrl = (slug: string) => `${window.location.origin}/archives/${slug}`
const getPageUrl = (slug: string) => `${window.location.origin}/pages/${slug}` const stripHtml = (html: string) => html ? html.replace(/<[^>]+>/g, "").replace(/\n+/g, " ").trim() : ""
const stripHtml = (html: string) => { // 彻底清除冗余 Markdown 的正则替换
if (!html) return "" const truncateQuote = (content: string, length = 35) => {
return html.replace(/<[^>]+>/g, "").replace(/\n+/g, " ").trim()
}
const truncateQuote = (content: string, length = 40) => {
if (!content) return "" if (!content) return ""
let plain = stripHtml(content) let plain = stripHtml(content)
// 清理旧版本测试时残留的 Markdown 引用文本,防止摘要里套娃
plain = plain.replace(/^>\s*(?:💬\s*)?\*\*(.*?)\*\*\s*[:]\s*/gm, '').trim() plain = plain.replace(/^>\s*(?:💬\s*)?\*\*(.*?)\*\*\s*[:]\s*/gm, '').trim()
return plain.length > length ? plain.substring(0, length) + "..." : plain return plain.length > length ? plain.substring(0, length) + "..." : plain
} }
const renderContent = (content: string) => { const renderContent = (content: string) => {
if (!content) return "<span class='opacity-50'>(空)</span>" if (!content) return "<span style='opacity:0.5'>(空)</span>"
let parsed = content let parsed = content.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "")
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "") parsed = parsed.replace(/^>\s*(?:💬\s*)?\*\*(.*?)\*\*\s*[:]\s*/gm, "") // 清理脏数据
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "")
// 清理之前测试时已经存入数据库的历史遗留 "> 💬 @某人 :" 文本
parsed = parsed.replace(/^>\s*(?:💬\s*)?\*\*(.*?)\*\*\s*[:]\s*/gm, "")
return parsed.replace(/\n/g, "<br/>") return parsed.replace(/\n/g, "<br/>")
} }
const resetFilters = () => { filterStatus.value = ""; filterSentiment.value = ""; filterKeyword.value = ""; page.value = 1; fetchReplies() } const resetFilters = () => { filterStatus.value = ""; filterSentiment.value = ""; filterKeyword.value = ""; page.value = 1; fetchReplies() }
watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() }) watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() })
watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() }) watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() })
onMounted(fetchReplies) onMounted(fetchReplies)
</script> </script>
<style scoped> <style scoped>
.line-clamp-3 { /* 全局基础布局 */
display: -webkit-box; .logs-container { padding-bottom: 20px; }
-webkit-line-clamp: 3; .header-icon { margin-right: 8px; align-self: center; }
-webkit-box-orient: vertical;
overflow: hidden; /* 工具栏与按钮 */
} .toolbar { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px; align-items: center; }
.batch-toolbar { background: #eff6ff; padding: 12px 16px; border-radius: 8px; border: 1px solid #bfdbfe; }
.batch-text { font-size: 14px; color: #1d4ed8; font-weight: bold; }
.btn-batch { padding: 6px 12px; border-radius: 6px; border: none; font-size: 12px; cursor: pointer; color: #fff; }
.btn-pass { background: #16a34a; } .btn-reject { background: #f97316; } .btn-delete { background: #dc2626; }
.btn-cancel { background: transparent; color: #6b7280; margin-left: auto; }
.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; outline: none; }
.btn-reset { padding: 8px 16px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; cursor: pointer; }
/* 列表区 */
.list-area { margin: 16px; }
.empty-state { text-align: center; padding: 60px 0; color: #9ca3af; }
.reply-list { display: flex; flex-direction: column; gap: 16px; }
.select-all-wrap { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #6b7280; padding: 0 4px; }
.reply-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
.card-main { display: flex; gap: 12px; padding: 16px; }
.card-content { flex: 1; min-width: 0; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.tags-wrap { display: flex; gap: 6px; flex-wrap: wrap; }
.card-time { font-size: 12px; color: #9ca3af; }
.card-text { font-size: 14px; color: #374151; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.card-footer { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: #f9fafb; border-top: 1px solid #f3f4f6; }
.footer-info { font-size: 12px; color: #6b7280; display: flex; gap: 16px; }
.footer-actions { display: flex; gap: 8px; }
.action-btn { padding: 4px 8px; border-radius: 4px; font-size: 12px; border: none; cursor: pointer; }
.action-btn.pass { background: #dcfce7; color: #16a34a; }
.action-btn.reject { background: #fee2e2; color: #dc2626; }
.action-btn.view { background: #dbeafe; color: #2563eb; }
.action-btn.delete { background: #e5e7eb; color: #4b5563; }
/* 标签配色体系 (纯CSS,抛弃Tailwind限制) */
.custom-tag { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: bold; }
.tag-PASS { background: #dcfce7; color: #15803d; }
.tag-FAIL { background: #fee2e2; color: #b91c1c; }
.tag-PENDING { background: #fef9c3; color: #a16207; }
.tag-REJECTED { background: #ffedd5; color: #c2410c; }
.tag-published { background: #dbeafe; color: #1d4ed8; }
.tag-draft { background: #f3f4f6; color: #4b5563; }
.tag-conv { background: #f3e8ff; color: #7e22ce; }
.tag-VERY_POSITIVE { background: #dcfce7; color: #14532d; }
.tag-POSITIVE { background: #ecfdf5; color: #15803d; }
.tag-NEGATIVE { background: #ffe4e6; color: #e11d48; }
.tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; }
/* 对话弹窗与聊天气泡 */
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 9999; }
.dialog-box { width: 90%; max-width: 600px; background: #fff; border-radius: 16px; display: flex; flex-direction: column; max-height: 85vh; }
.dialog-header { display: flex; justify-content: space-between; padding: 16px 24px; border-bottom: 1px solid #f3f4f6; }
.dialog-header h3 { margin: 0; font-size: 18px; }
.close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: #9ca3af; }
.dialog-body { padding: 24px; overflow-y: auto; background: #f9fafb; flex: 1; border-bottom-left-radius: 16px; border-bottom-right-radius: 16px; }
.chat-container { display: flex; flex-direction: column; gap: 20px; }
.chat-row { display: flex; width: 100%; }
.row-ai { justify-content: flex-start; }
.row-user { justify-content: flex-end; }
.chat-message { max-width: 80%; display: flex; flex-direction: column; }
.chat-owner { font-size: 12px; margin-bottom: 6px; font-weight: bold; }
.row-ai .chat-owner { color: #2563eb; text-align: left; }
.row-user .chat-owner { color: #6b7280; text-align: right; }
.chat-time { font-size: 11px; color: #9ca3af; margin-top: 6px; }
.row-user .chat-time { text-align: right; }
/* 核心:独立的气泡和引用框 CSS */
.chat-bubble { padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.6; word-wrap: break-word; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
.bubble-ai { background: #ffffff; color: #1f2937; border: 1px solid #e0e7ff; border-top-left-radius: 2px; }
.bubble-user { background: #2563eb; color: #ffffff; border-top-right-radius: 2px; }
/* 绝对可靠的引用框样式 */
.quote-box { margin-bottom: 10px; padding: 8px 12px; border-radius: 6px; font-size: 12px; border-left: 3px solid; }
.quote-owner { font-weight: bold; margin-right: 6px; }
.bubble-ai .quote-box { background: #f3f4f6; border-left-color: #9ca3af; color: #4b5563; }
.bubble-user .quote-box { background: rgba(0,0,0,0.15); border-left-color: rgba(255,255,255,0.4); color: #d1d5db; }
.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; font-size: 14px; color: #6b7280; }
</style> </style>
File diff suppressed because it is too large Load Diff