From 957df1a539eefa428f90dc04653e18da42fd38d1 Mon Sep 17 00:00:00 2001 From: bbb-lsy07 Date: Sun, 21 Jun 2026 09:55:03 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E6=B0=94?= =?UTF-8?q?=E6=B3=A1=E5=A2=9E=E5=8A=A0=E5=BC=95=E7=94=A8=E6=91=98=E8=A6=81?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8C=E8=A7=A3=E5=86=B3=E5=A4=9A=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=B7=B7=E6=9D=82=E4=BA=A4=E8=B0=88=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E4=B8=8D=E6=B8=85=E6=99=B0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 ConversationMessage Record 新增 quoteOwner/quoteContent 字段 - 后端 getConversation 方法重写,构建 Reply 映射字典溯源引用关系 - 前端 ConversationMessage 类型定义新增 quoteOwner/quoteContent - 前端新增 truncateQuote 截断方法(复用 stripHtml,限30字符) - 前端对话气泡模板渲染灰色引用条(bg-black/5 + border-l-2) --- .../endpoint/CommentAiAutopilotEndpoint.java | 49 ++++++++++++++----- ui/src/views/LogsView.vue | 26 +++++++++- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java index 4910205..135a523 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java @@ -291,26 +291,51 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { var commentTime = String.valueOf(comment.getMetadata().getCreationTimestamp()); var isCommentAi = isAiOwner(comment.getSpec().getOwner()); + // 首条评论没有引用对象 var commentMsg = new ConversationMessage( - "comment", commentOwner, commentContent, commentTime, isCommentAi + "comment", commentOwner, commentContent, commentTime, isCommentAi, null, null ); return client.list(Reply.class, reply -> commentName.equals(reply.getSpec().getCommentName()), null) .sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp())) - .map(reply -> { - var replyOwner = extractOwnerName(reply.getSpec().getOwner()); - var replyContent = extractContent(reply.getSpec().getRaw(), reply.getSpec().getContent()); - var replyTime = String.valueOf(reply.getMetadata().getCreationTimestamp()); - var isAi = isAiOwner(reply.getSpec().getOwner()); - return new ConversationMessage("reply", replyOwner, replyContent, replyTime, isAi); - }) - .collectList() + .collectList() // 收集为List以便统一处理引用映射 .map(replyList -> { List messages = new ArrayList<>(); messages.add(commentMsg); - messages.addAll(replyList); + + // 构建 Reply 的映射字典,方便查找引用关系 + Map replyMap = new HashMap<>(); + for (Reply r : replyList) { + replyMap.put(r.getMetadata().getName(), r); + } + + for (Reply reply : replyList) { + var replyOwner = extractOwnerName(reply.getSpec().getOwner()); + var replyContent = extractContent(reply.getSpec().getRaw(), reply.getSpec().getContent()); + var replyTime = String.valueOf(reply.getMetadata().getCreationTimestamp()); + var isAi = isAiOwner(reply.getSpec().getOwner()); + + String quoteOwner = null; + String quoteContent = null; + + // 获取引用的 Reply 名称 (Halo中如果为空,代表直接回复顶级 Comment) + String quoteReplyName = reply.getSpec().getQuoteReply(); + if (quoteReplyName != null && !quoteReplyName.isBlank()) { + Reply quotedReply = replyMap.get(quoteReplyName); + if (quotedReply != null) { + quoteOwner = extractOwnerName(quotedReply.getSpec().getOwner()); + quoteContent = extractContent(quotedReply.getSpec().getRaw(), quotedReply.getSpec().getContent()); + } + } else { + // 没有 quoteReply 表示直接回复首条评论 + quoteOwner = commentOwner; + quoteContent = commentContent; + } + + messages.add(new ConversationMessage("reply", replyOwner, replyContent, replyTime, isAi, quoteOwner, quoteContent)); + } return messages; }); }) @@ -712,7 +737,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { String owner, String content, String time, - boolean isAi + boolean isAi, + String quoteOwner, + String quoteContent ) {} public record CommenterInfo( diff --git a/ui/src/views/LogsView.vue b/ui/src/views/LogsView.vue index ff337eb..b634006 100644 --- a/ui/src/views/LogsView.vue +++ b/ui/src/views/LogsView.vue @@ -299,8 +299,19 @@ :class="msg.isAi ? 'bg-blue-50 text-gray-800 rounded-tl-md' : 'bg-gray-100 text-gray-800 rounded-tr-md'" - v-html="renderContent(msg.content)" - > + > + +
+ @{{ msg.quoteOwner }}: + {{ truncateQuote(msg.quoteContent, 30) }} +
+ + +
+
{{ formatDate(msg.time) }} @@ -358,6 +369,8 @@ interface ConversationMessage { content: string time: string isAi: boolean + quoteOwner?: string + quoteContent?: string } const replies = ref([]) @@ -639,6 +652,15 @@ const stripHtml = (html: string) => { .trim() } +/** + * Truncate quote content for preview + */ +const truncateQuote = (content: string, length = 30) => { + if (!content) return "" + const plain = stripHtml(content) + return plain.length > length ? plain.substring(0, length) + "..." : plain +} + /** * Sanitize and render HTML content for conversation bubbles. * Only allows safe inline tags, strips dangerous elements. -- 2.54.0 From 0477f532bb8684e119ddbe42993636ca25a0cd54 Mon Sep 17 00:00:00 2001 From: bbb-lsy07 Date: Sun, 21 Jun 2026 10:03:12 +0800 Subject: [PATCH 02/11] =?UTF-8?q?chore:=20=E8=A1=A5=E5=85=85=20.gitignore?= =?UTF-8?q?=20=E8=A7=84=E5=88=99=EF=BC=88*.jar=E3=80=81ui/dist=20=E7=AD=89?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 1b20f46..59379dd 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ lerna-debug.log* *.ctxt ### Package Files +*.jar *.war *.nar *.ear @@ -70,6 +71,12 @@ lerna-debug.log* *.tar.gz *.rar +### UI build output +ui/dist/ +ui/dist-ssr/ +ui/*.local +ui/.eslintcache + ### Local file application-local.yml application-local.yaml -- 2.54.0 From 058d562e7ecf2884b1ab7af04c0ed2e249140814 Mon Sep 17 00:00:00 2001 From: bbb-lsy07 Date: Sun, 21 Jun 2026 10:16:05 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20Markdown=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E6=B3=95=20-=20AI=E5=9B=9E=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8B=BC=E6=8E=A5=E5=BC=95=E7=94=A8=E5=9D=97=EF=BC=88?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E6=97=A0=E5=85=B3=E9=80=9A=E7=94=A8=E6=96=B9?= =?UTF-8?q?=E6=A1=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 CommentReplyPublisher.doPublish 重写,发布前查询被回复对象并拼接 Markdown Blockquote - 新增 buildQuoteMarkdown 辅助方法,Jsoup 清除 HTML 后截断 40 字符生成引用 - 前端 LogsView 恢复简洁气泡模板,移除 quoteOwner/quoteContent 前端引用逻辑 - renderContent 新增换行符处理,确保 Markdown 引用块正确渲染 --- .../service/CommentReplyPublisher.java | 134 +++++++++++------- ui/src/views/LogsView.vue | 28 +--- 2 files changed, 88 insertions(+), 74 deletions(-) diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java index 82428d9..f7c5802 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java @@ -80,63 +80,97 @@ public class CommentReplyPublisher { private Mono doPublish(String parentCommentName, String replyContent, String postName, String quoteReplyName, boolean autoPublish, String personaName) { - return resolvePersona(personaName).flatMap(persona -> { - String displayName = persona.displayName(); - String email = persona.email(); - Reply reply = new Reply(); - reply.setMetadata(new Metadata()); - reply.getMetadata().setName(generateReplyName()); - reply.setSpec(new Reply.ReplySpec()); + // 1. 获取被回复的对象信息,构建 Markdown 引用 + Mono quoteMarkdownMono = Mono.empty(); - var spec = reply.getSpec(); - spec.setCommentName(parentCommentName); - spec.setRaw(replyContent); - spec.setContent(replyContent); - spec.setApproved(autoPublish); - if (autoPublish) { - spec.setApprovedTime(Instant.now()); - } - spec.setPriority(0); - spec.setTop(false); - spec.setAllowNotification(false); - spec.setHidden(false); + if (quoteReplyName != null && !quoteReplyName.isBlank()) { + // 如果是回复另一条回复 + quoteMarkdownMono = client.fetch(Reply.class, quoteReplyName) + .map(r -> buildQuoteMarkdown( + r.getSpec().getOwner() != null ? r.getSpec().getOwner().getDisplayName() : "匿名用户", + r.getSpec().getRaw() != null ? r.getSpec().getRaw() : r.getSpec().getContent() + )); + } else if (parentCommentName != null && !parentCommentName.isBlank()) { + // 如果是直接回复顶级评论 + quoteMarkdownMono = client.fetch(Comment.class, parentCommentName) + .map(c -> buildQuoteMarkdown( + c.getSpec().getOwner() != null ? c.getSpec().getOwner().getDisplayName() : "匿名用户", + c.getSpec().getRaw() != null ? c.getSpec().getRaw() : c.getSpec().getContent() + )); + } - if (quoteReplyName != null && !quoteReplyName.isBlank()) { - spec.setQuoteReply(quoteReplyName); - } + // 2. 解析 AI 角色并合并最终文本进行发布 + return Mono.zip(resolvePersona(personaName), quoteMarkdownMono.defaultIfEmpty("")) + .flatMap(tuple -> { + ResolvedPersona persona = tuple.getT1(); + String quoteMarkdown = tuple.getT2(); - var owner = new Comment.CommentOwner(); - owner.setKind(Comment.CommentOwner.KIND_EMAIL); - if (email != null && !email.isBlank()) { - owner.setName(email); - } else { - owner.setName(AI_PERSONA_OWNER_PREFIX + displayName); - } - owner.setDisplayName(displayName + " AI"); + String displayName = persona.displayName(); + String email = persona.email(); - Map ownerAnnotations = new HashMap<>(); - ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); - // 使用Gravatar邮箱头像 - if (email != null && !email.isBlank()) { - String gravatarUrl = GravatarUtil.generateUrl(email); - ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); - } - owner.setAnnotations(ownerAnnotations); - spec.setOwner(owner); + // 将引用文本拼接到 AI 回复内容的最前面 + String finalContent = quoteMarkdown.isBlank() ? replyContent : quoteMarkdown + "\n\n" + replyContent; - log.info("[Publisher] Creating reply for comment: {}, owner: kind={}, name={}, displayName={}, annotations={}", - parentCommentName, owner.getKind(), owner.getName(), owner.getDisplayName(), ownerAnnotations); + Reply reply = new Reply(); + reply.setMetadata(new Metadata()); + reply.getMetadata().setName(generateReplyName()); + reply.setSpec(new Reply.ReplySpec()); - return client.create(reply) - .doOnSuccess(created -> { - var createdOwner = created.getSpec().getOwner(); - log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}, owner annotations after create: {}", - displayName, parentCommentName, quoteReplyName, - createdOwner != null ? createdOwner.getAnnotations() : "null"); - }) - .doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage())); - }); + var spec = reply.getSpec(); + spec.setCommentName(parentCommentName); + spec.setRaw(finalContent); // 保存带有引用的完整 Markdown + spec.setContent(finalContent); // 同上 + spec.setApproved(autoPublish); + if (autoPublish) { + spec.setApprovedTime(Instant.now()); + } + spec.setPriority(0); + spec.setTop(false); + spec.setAllowNotification(false); + spec.setHidden(false); + + if (quoteReplyName != null && !quoteReplyName.isBlank()) { + spec.setQuoteReply(quoteReplyName); + } + + var owner = new Comment.CommentOwner(); + owner.setKind(Comment.CommentOwner.KIND_EMAIL); + if (email != null && !email.isBlank()) { + owner.setName(email); + } else { + owner.setName(AI_PERSONA_OWNER_PREFIX + displayName); + } + owner.setDisplayName(displayName + " AI"); + + Map ownerAnnotations = new HashMap<>(); + ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); + if (email != null && !email.isBlank()) { + String gravatarUrl = GravatarUtil.generateUrl(email); + ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); + } + owner.setAnnotations(ownerAnnotations); + spec.setOwner(owner); + + log.info("[Publisher] Creating reply for comment: {}, finalContent length: {}", parentCommentName, finalContent.length()); + + return client.create(reply) + .doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}", displayName, parentCommentName)) + .doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage())); + }); + } + + /** + * 新增辅助方法:构建 Markdown 引用块 + */ + private String buildQuoteMarkdown(String username, String rawText) { + if (rawText == null || rawText.isBlank()) return ""; + // 清除 HTML 标签,防止破坏 Markdown 结构 + String plainText = org.jsoup.Jsoup.clean(rawText, org.jsoup.safety.Safelist.none()).trim(); + // 截断过长的引用内容(超过 40 个字符加省略号) + String truncated = plainText.length() > 40 ? plainText.substring(0, 40) + "..." : plainText; + // 生成标准 Markdown Blockquote + return "> 💬 **@" + username + "** : " + truncated; } /** diff --git a/ui/src/views/LogsView.vue b/ui/src/views/LogsView.vue index b634006..401366f 100644 --- a/ui/src/views/LogsView.vue +++ b/ui/src/views/LogsView.vue @@ -299,19 +299,8 @@ :class="msg.isAi ? 'bg-blue-50 text-gray-800 rounded-tl-md' : 'bg-gray-100 text-gray-800 rounded-tr-md'" - > - -
- @{{ msg.quoteOwner }}: - {{ truncateQuote(msg.quoteContent, 30) }} -
- - -
-
+ v-html="renderContent(msg.content)" + >
{{ formatDate(msg.time) }} @@ -369,8 +358,6 @@ interface ConversationMessage { content: string time: string isAi: boolean - quoteOwner?: string - quoteContent?: string } const replies = ref([]) @@ -652,15 +639,6 @@ const stripHtml = (html: string) => { .trim() } -/** - * Truncate quote content for preview - */ -const truncateQuote = (content: string, length = 30) => { - if (!content) return "" - const plain = stripHtml(content) - return plain.length > length ? plain.substring(0, length) + "..." : plain -} - /** * Sanitize and render HTML content for conversation bubbles. * Only allows safe inline tags, strips dangerous elements. @@ -668,6 +646,8 @@ const truncateQuote = (content: string, length = 30) => { const renderContent = (content: string) => { if (!content) return "(空)" return content + // 将 Markdown 的换行符替换为 HTML 的换行 + .replace(/\n/g, "
") .replace(/]*>[\s\S]*?<\/script>/gi, "") .replace(/]*>[\s\S]*?<\/iframe>/gi, "") .replace(/]*>[\s\S]*?<\/object>/gi, "") -- 2.54.0 From 89379597969cf70675430d9d4caeded5c8ef0cd2 Mon Sep 17 00:00:00 2001 From: bbb-lsy07 Date: Sun, 21 Jun 2026 10:24:25 +0800 Subject: [PATCH 04/11] =?UTF-8?q?style:=20=E9=87=8D=E5=86=99=20LogsView.vu?= =?UTF-8?q?e=20-=20=E7=BA=AF=20Tailwind=20=E6=A0=87=E7=AD=BE=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=20Emoji=EF=BC=8C=E7=A7=BB=E9=99=A4=20300+=20=E8=A1=8C?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=20CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态/情感标签改用纯色 Tailwind 背景标签,去除所有 Emoji - 删除 300+ 行自定义 CSS,全部替换为 Tailwind 原子类 - 对话弹窗 Markdown 引用块正则提取,去除气泡和机器人 Emoji - 优化移动端响应式布局,解决排版错位问题 --- ui/src/views/LogsView.vue | 922 ++++++-------------------------------- 1 file changed, 130 insertions(+), 792 deletions(-) diff --git a/ui/src/views/LogsView.vue b/ui/src/views/LogsView.vue index 401366f..89d7a3d 100644 --- a/ui/src/views/LogsView.vue +++ b/ui/src/views/LogsView.vue @@ -9,51 +9,25 @@ - -
+ +
已选择 {{ selectedNames.size }} 项 - - - - + + + +
- -
- - @@ -61,176 +35,78 @@ -
- +
+ - +
-
+
- - - - 暂无AI回复记录 + + 暂无记录
-
-
- - 全选 +
+
+ + 全选本页
-
- -
-
- -
- -
-
- - {{ getStatusLabel(reply.spec.status) }} - - - {{ reply.spec.published ? '已发布' : '未发布' }} - - - 对话 - - - {{ getSentimentLabel(reply.spec.sentiment) }} - -
- {{ formatDate(reply.metadata.creationTimestamp) }} -
- - -
- {{ stripHtml(reply.spec.reply) || '(空)' }} + +
+
+ +
+
+
+ {{ getStatusLabel(reply.spec.status) }} + {{ reply.spec.published ? '已发布' : '未发布' }} + 对话 + {{ getSentimentLabel(reply.spec.sentiment) }}
+ {{ formatDate(reply.metadata.creationTimestamp) }} +
+
+ {{ stripHtml(reply.spec.reply) || '(空)' }}
- - -
-
- - 评分 {{ reply.spec.score }} - {{ getScoreLabel(reply.spec.score) }} + +
+
+ + 评分 {{ reply.spec.score }} + {{ getScoreLabel(reply.spec.score) }} - - 文章 - {{ reply.spec.postSlug }} - - - - - - 页面 - {{ reply.spec.postSlug }} - - - - - - 重试 {{ reply.spec.retryCount }} 次 + + {{ reply.spec.postKind === 'SinglePage' ? '页面' : '文章' }} + {{ reply.spec.postSlug }} + 重试 {{ reply.spec.retryCount }} 次
- - + +
- -
- 共 {{ total }} 条 +
+ 共 {{ total }} 条
上一页 下一页 @@ -238,87 +114,34 @@
- + -
- -
- - -
- -
-
- - - -

完整对话

-
-
- - -
+
- -
- 暂无对话内容 -
- +
暂无内容
-
-
- -
- {{ msg.owner }} +
+
+
+ {{ msg.owner }}
- -
- -
+
+
{{ formatDate(msg.time) }}
- - -
- -
@@ -332,23 +155,11 @@ import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components" import { IconPlug } from "@halo-dev/components" interface AiCommentReplyItem { - metadata: { - name: string - creationTimestamp: string - } + metadata: { name: string; creationTimestamp: 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 + 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 } } @@ -367,40 +178,26 @@ const size = ref(20) const total = ref(0) const totalPages = ref(0) -// Selection state const selectedNames = ref>(new Set()) const selectAll = ref(false) -// Filter state const filterStatus = ref("") const filterSentiment = ref("") const filterKeyword = ref("") -const toggleSelect = (name: string) => { - if (selectedNames.value.has(name)) { - selectedNames.value.delete(name) - } else { - selectedNames.value.add(name) - } - // Update selectAll state - 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 - } -} - -// Conversation dialog state const showDialog = ref(false) const conversationLoading = ref(false) const conversationMessages = ref([]) +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 () => { loading.value = true try { @@ -408,18 +205,9 @@ const fetchReplies = async () => { if (filterStatus.value) params.status = filterStatus.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 }, - ) - replies.value = data.items || [] - total.value = data.total || 0 - totalPages.value = Math.ceil(total.value / size.value) - } catch (e) { - console.error("Failed to fetch replies", e) - } finally { - loading.value = false - } + 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) + } catch (e) { Toast.error("获取数据失败") } finally { loading.value = false } } const openConversation = async (reply: AiCommentReplyItem) => { @@ -427,543 +215,93 @@ const openConversation = async (reply: AiCommentReplyItem) => { conversationLoading.value = true conversationMessages.value = [] 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 || [] - } catch (e) { - console.error("Failed to fetch conversation", e) - Toast.error("获取对话失败") - } finally { - conversationLoading.value = false - } + } catch (e) { Toast.error("获取对话失败") } finally { conversationLoading.value = false } } -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) { - console.error("Failed to delete reply", 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 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 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 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) { - console.error("Failed to approve reply", 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) { - console.error("Failed to reject reply", e) - Toast.error("拒绝操作失败") - } -} - -const batchApprove = async () => { - if (selectedNames.value.size === 0) 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 === 0) 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 === 0) 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 getScoreClass = (score: number) => { - if (score >= 85) return "text-green-600" - if (score >= 60) return "text-yellow-600" - return "text-red-600" -} - -const getScoreLabelClass = (score: number) => { - if (score >= 85) return "score-label--excellent" - if (score >= 70) return "score-label--good" - if (score >= 50) return "score-label--average" - if (score > 0) return "score-label--poor" - return "" -} - -const getScoreLabel = (score: number) => { - if (score >= 85) return "优秀" - if (score >= 70) return "良好" - if (score >= 50) return "一般" - if (score > 0) return "较差" - return "" -} +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 "status-tag--pass" - case "FAIL": - return "status-tag--fail" - case "PENDING": - return "status-tag--pending" - case "REJECTED": - return "status-tag--rejected" - default: - return "status-tag--pending" + 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 + 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 "status-tag--very-positive" - case "POSITIVE": - return "status-tag--positive" - case "NEGATIVE": - return "status-tag--negative" - case "VERY_NEGATIVE": - return "status-tag--very-negative" - case "NEUTRAL": - return "status-tag--neutral-sentiment" - default: - return "status-tag--neutral-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 "非常负面" - case "NEUTRAL": - return "中性" - default: - return sentiment + case "VERY_POSITIVE": return "非常正面"; case "POSITIVE": return "正面"; case "NEGATIVE": return "负面"; case "VERY_NEGATIVE": return "非常负面"; default: return "中性" } } -const formatDate = (timestamp: string) => { - if (!timestamp) return "" - return new Date(timestamp).toLocaleString("zh-CN") -} +const formatDate = (ts: string) => ts ? new Date(ts).toLocaleString("zh-CN") : "" +const getPostUrl = (slug: string) => `${window.location.origin}/archives/${slug}` +const getPageUrl = (slug: string) => `${window.location.origin}/pages/${slug}` -const getPostUrl = (slug: string) => { - return `${window.location.origin}/archives/${slug}` -} - -const getPageUrl = (slug: string) => { - return `${window.location.origin}/pages/${slug}` -} - -/** - * Strip HTML tags for plain text display (card preview) - */ const stripHtml = (html: string) => { if (!html) return "" - return html - .replace(/]*>/gi, "") - .replace(/<\/p>/gi, "\n") - .replace(//gi, "\n") - .replace(/<[^>]+>/g, "") - .replace(/\n{3,}/g, "\n\n") - .trim() + return html.replace(/<[^>]+>/g, "").replace(/\n+/g, " ").trim() } -/** - * Sanitize and render HTML content for conversation bubbles. - * Only allows safe inline tags, strips dangerous elements. - */ -const renderContent = (content: string) => { - if (!content) return "(空)" - return content - // 将 Markdown 的换行符替换为 HTML 的换行 - .replace(/\n/g, "
") +const renderContent = (content: string, isAi: boolean) => { + if (!content) return "(空)" + let parsed = content .replace(/]*>[\s\S]*?<\/script>/gi, "") .replace(/]*>[\s\S]*?<\/iframe>/gi, "") - .replace(/]*>[\s\S]*?<\/object>/gi, "") - .replace(/]*>/gi, "") - .replace(/]*>[\s\S]*?<\/form>/gi, "") - .replace(/on\w+\s*=\s*["'][^"']*["']/gi, "") - .replace(/on\w+\s*=\s*[^\s>]*/gi, "") - .replace(/]*>/gi, "

") - .replace(/\s*(?:💬\s*)?\*\*(.*?)\*\*\s*[::]\s*(.*?)$/gm, (match, name, text) => { + const quoteBg = isAi ? 'bg-blue-50 border-blue-200' : 'bg-white/20 border-white/30' + const nameColor = isAi ? 'text-blue-700' : 'text-white font-semibold' + const textColor = isAi ? 'text-gray-600' : 'text-blue-100' + return `

` + }) + + parsed = parsed.replace(/\n/g, "
") + return parsed } -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(page, () => { - selectedNames.value.clear() - selectAll.value = false - fetchReplies() -}) +watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() }) +watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() }) onMounted(fetchReplies) -- 2.54.0 From 11620cc113b9d6e127bea13b2180a7f47944ef4c Mon Sep 17 00:00:00 2001 From: bbb-lsy07 Date: Sun, 21 Jun 2026 10:30:41 +0800 Subject: [PATCH 05/11] =?UTF-8?q?style:=20=E9=87=8D=E5=86=99=20SettingsVie?= =?UTF-8?q?w.vue=20-=20=E7=BA=AF=20Tailwind=20=E6=A0=85=E6=A0=BC=E5=B8=83?= =?UTF-8?q?=E5=B1=80=EF=BC=8C=E7=A7=BB=E9=99=A4=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=20CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标签导航改用 Tailwind flex + overflow-x-auto - 所有设置面板(basic/persona/model/prompt/cleanup)改用 Tailwind 原子类 - 开关改用 peer-checked 伪类实现,移除自定义 toggle CSS - 滑块刻度改用 flex justify-between 实现 - 侧边栏 lg:sticky lg:top-24,移动端自然折叠到底部 - 弹窗(评论者选择/角色编辑)改用 fixed inset-0 + backdrop-blur - 删除 600+ 行自定义 CSS --- ui/src/views/SettingsView.vue | 1259 ++++++++------------------------- 1 file changed, 280 insertions(+), 979 deletions(-) diff --git a/ui/src/views/SettingsView.vue b/ui/src/views/SettingsView.vue index 3f7e84e..618a81c 100644 --- a/ui/src/views/SettingsView.vue +++ b/ui/src/views/SettingsView.vue @@ -26,307 +26,289 @@
- -
-
- -
+ +
+ -
- -
-
-
- +
+ + +
+
+
+
-
-

基本设置

-

控制AI回复的基本行为

+
+

基本设置

+

控制AI回复的基本行为

-
- -
-
- 自动回复 - 启用后,AI将自动回复新评论 +
+
+
+
自动回复
+
启用后,AI将自动回复新评论
-
- -
-
- 自动发布 - 关闭后AI回复将存为草稿,需手动审核发布 + +
+
+
自动发布
+
关闭后AI回复将存为草稿,需手动审核发布
-
- -
-
- 最大对话轮次 - {{ settings.basic.maxConversationRounds }} -
- 同一评论线程中AI最多自动回复的轮次 -
- -
- 150100 -
+ +
+
+ + {{ settings.basic.maxConversationRounds }}
+

同一评论线程中AI最多自动回复的轮次

+ +
150100
- -
- - 每分钟最大AI回复数量 - -
- -
-
- 最大重试次数 - {{ settings.basic.maxRetryCount }} -
- AI生成失败时的最大重试次数,采用指数退避策略 -
- -
- 1510 -
+ +
+
+ + {{ settings.basic.maxRetryCount }}
+

AI生成失败时的最大重试次数,采用指数退避策略

+ +
1510
- -
-
- 评论者黑名单 -
- 支持名称、邮箱和正则表达式。正则以 regex: 开头,如 regex:^spam.* - +

支持名称、邮箱和正则表达式。正则以 regex: 开头,如 regex:^spam.*

+
- -
-
-
- + +
+
+
+
-
-

AI角色设置

-

定义AI虚拟评论者的身份和风格

+
+

AI角色设置

+

定义AI虚拟评论者的身份和风格

-
- +
-
- - 请添加至少一个AI角色 +
+ + 请添加至少一个AI角色
-
-
-
- 头像 - {{ (p.spec.displayName || '?').charAt(0) }} +
+
+
+ 头像 +
{{ (p.spec.displayName || '?').charAt(0) }}
-
-
- {{ p.spec.displayName || '未命名' }} - - - 中性语气 - 唤醒: {{ p.spec.wakeWord }} - 默认 +
+
+ {{ p.spec.displayName || '未命名' }} + + + 中性语气 + 唤醒: {{ p.spec.wakeWord }} + 默认
-
{{ p.spec.prompt || '暂无提示词' }}
+
{{ p.spec.prompt || '暂无提示词' }}
-
- - -
- + 添加角色
- -
-
-
- + +
+
+
+
-
-

模型设置

-

配置AI Foundation提供的模型

+
+

模型设置

+

配置AI Foundation提供的模型

-
-
- - 留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型 - +
+
+ +

留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型

+
- -
-
-
- + +
+
+
+
-
-

Prompt设置

-

自定义AI回复的提示词模板

+
+

Prompt设置

+

自定义AI回复的提示词模板

-
- -
- - 选择预设风格,可多选 -
-
- -
-
-
- + +
+
+
+
-
-

数据清理

-

自动清理过期的AI回复记录

+
+

数据清理

+

自动清理过期的AI回复记录

-
- -
-
- 启用自动清理 - 每天自动清理超过保留天数的记录 +
+
+
+
启用自动清理
+
每天自动清理超过保留天数的记录
-
- -
-
- 保留天数 - {{ settings.cleanup.retentionDays }} 天 -
- 超过此天数的AI回复记录将被自动清理 -
- -
- 1天180天365天 -
+ +
+
+ + {{ settings.cleanup.retentionDays }} 天
+

超过此天数的AI回复记录将被自动清理

+ +
1天180天365天
- -
-
- 手动清理 - 立即执行一次清理操作 -
-
- - {{ cleanupLoading ? '清理中...' : '立即清理' }} - + +
+
+
手动清理
+
立即执行一次清理操作
+ + {{ cleanupLoading ? '清理中...' : '立即清理' }} +
-
- + +
+ 清理完成,共删除 {{ cleanupResult }} 条记录
- -
-
-