first commit
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
plugins {
|
||||
id 'base'
|
||||
id "com.github.node-gradle.node" version "7.1.0"
|
||||
}
|
||||
|
||||
group 'top.nxxy335.commentaiautopilot.ui'
|
||||
|
||||
// Fix Gradle 9.x compatibility with pnpm symlinks
|
||||
tasks.named('pnpmInstall') {
|
||||
doNotTrackState("pnpm symlinks are not compatible with Gradle state tracking")
|
||||
}
|
||||
|
||||
tasks.register('pnpmBuild', PnpmTask) {
|
||||
group = 'build'
|
||||
description = 'Build the UI project using pnpm'
|
||||
args = ['build']
|
||||
dependsOn tasks.named('pnpmInstall')
|
||||
inputs.dir(layout.projectDirectory.dir('src'))
|
||||
inputs.files(fileTree(
|
||||
dir: layout.projectDirectory,
|
||||
includes: ['*.cjs', '*.ts', '*.js', '*.json', '*.yaml']))
|
||||
outputs.dir(layout.buildDirectory.dir('dist'))
|
||||
}
|
||||
|
||||
tasks.register('pnpmCheck', PnpmTask) {
|
||||
group = 'verification'
|
||||
description = 'Run unit tests for the UI project using pnpm'
|
||||
args = ['test:unit']
|
||||
dependsOn tasks.named('pnpmInstall')
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('pnpmCheck')
|
||||
}
|
||||
|
||||
tasks.named('assemble') {
|
||||
dependsOn tasks.named('pnpmBuild')
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
/// <reference types="unplugin-icons/types/vue" />
|
||||
@@ -0,0 +1,30 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
{
|
||||
...pluginVitest.configs.recommended,
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
...pluginOxlint.configs['flat/recommended'],
|
||||
skipFormatting,
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"build-only": "rsbuild build",
|
||||
"dev": "rsbuild build --watch --env-mode=development",
|
||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path ../.gitignore",
|
||||
"lint:eslint": "eslint . --fix",
|
||||
"lint": "run-s lint:*",
|
||||
"prettier": "prettier --write src/",
|
||||
"test:unit": "vitest --passWithNoTests",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2
|
||||
},
|
||||
"dependencies": {
|
||||
"@halo-dev/api-client": "^2.23.0",
|
||||
"@halo-dev/components": "^2.23.0",
|
||||
"@halo-dev/ui-shared": "^2.23.0",
|
||||
"axios": "^1.13.5",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"vue": "^3.5.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "^1.7.3",
|
||||
"@rsbuild/plugin-sass": "^1.5.0",
|
||||
"@halo-dev/ui-plugin-bundler-kit": "^2.23.0",
|
||||
"@iconify-json/ri": "^1.2.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^20.19.1",
|
||||
"@vitest/eslint-plugin": "^1.2.7",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.5.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-oxlint": "^0.16.12",
|
||||
"eslint-plugin-vue": "~10.0.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"oxlint": "^0.16.12",
|
||||
"prettier": "^3.6.0",
|
||||
"sass": "^1.89.2",
|
||||
"typescript": "~5.8.3",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vitest": "^4.1.0",
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4"
|
||||
}
|
||||
Generated
+4816
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
import { rsbuildConfig } from '@halo-dev/ui-plugin-bundler-kit';
|
||||
import Icons from "unplugin-icons/rspack";
|
||||
import { pluginSass } from "@rsbuild/plugin-sass";
|
||||
import type { RsbuildConfig } from "@rsbuild/core";
|
||||
|
||||
export default rsbuildConfig({
|
||||
rsbuild: {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "./src",
|
||||
},
|
||||
},
|
||||
plugins: [pluginSass()],
|
||||
tools: {
|
||||
rspack: {
|
||||
plugins: [Icons({ compiler: "vue3" })],
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as RsbuildConfig
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,84 @@
|
||||
import { definePlugin } from "@halo-dev/ui-shared";
|
||||
import { IconPlug, VDropdownItem, Dialog, Toast } from "@halo-dev/components";
|
||||
import { markRaw, type Ref } from "vue";
|
||||
import { axiosInstance } from "@halo-dev/api-client";
|
||||
import type { ListedComment } from "@halo-dev/api-client";
|
||||
|
||||
export default definePlugin({
|
||||
components: {},
|
||||
routes: [
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "/comment-ai-autopilot",
|
||||
name: "CommentAiAutopilot",
|
||||
component: () => import("./views/HomeView.vue"),
|
||||
meta: {
|
||||
title: "AI回评",
|
||||
searchable: true,
|
||||
menu: {
|
||||
name: "AI回评",
|
||||
group: "tool",
|
||||
icon: markRaw(IconPlug),
|
||||
priority: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "/comment-ai-autopilot/logs",
|
||||
name: "CommentAiAutopilotLogs",
|
||||
component: () => import("./views/LogsView.vue"),
|
||||
meta: {
|
||||
title: "AI回复日志",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "/comment-ai-autopilot/settings",
|
||||
name: "CommentAiAutopilotSettings",
|
||||
component: () => import("./views/SettingsView.vue"),
|
||||
meta: {
|
||||
title: "插件设置",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
extensionPoints: {
|
||||
"comment:list-item:operation:create": (
|
||||
comment: Ref<ListedComment>,
|
||||
) => [
|
||||
{
|
||||
priority: 20,
|
||||
component: markRaw(VDropdownItem),
|
||||
label: "触发AI回复",
|
||||
action: () => {
|
||||
const commentName = comment.value.comment.metadata.name;
|
||||
Dialog.warning({
|
||||
title: "触发AI回复",
|
||||
description: `确定要对该评论触发AI回复吗?`,
|
||||
confirmType: "primary",
|
||||
confirmText: "确定",
|
||||
cancelText: "取消",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/comments/${commentName}/trigger`,
|
||||
);
|
||||
Toast.success("AI回复已触发");
|
||||
} catch (e: any) {
|
||||
const msg =
|
||||
e?.response?.data?.message || "触发失败";
|
||||
Toast.error(msg);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="comment-ai-autopilot-home">
|
||||
<VPageHeader title="AI回评">
|
||||
<template #icon>
|
||||
<IconPlug class="mr-2 self-center" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<VButton type="primary" @click="openSettings"> 插件设置 </VButton>
|
||||
</template>
|
||||
</VPageHeader>
|
||||
|
||||
<div class="m-4">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">总回复数</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats?.total || 0 }}</div>
|
||||
</VCard>
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">通过数</div>
|
||||
<div class="mt-1 text-2xl font-bold text-green-600">{{ stats?.passCount || 0 }}</div>
|
||||
</VCard>
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">失败数</div>
|
||||
<div class="mt-1 text-2xl font-bold text-red-600">{{ stats?.failCount || 0 }}</div>
|
||||
</VCard>
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">待审核</div>
|
||||
<div class="mt-1 text-2xl font-bold text-orange-500">{{ stats?.reviewingCount || 0 }}</div>
|
||||
</VCard>
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">平均评分</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats?.avgScore?.toFixed(1) || '0.0' }}</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- Sentiment Distribution -->
|
||||
<VCard class="mt-4" :body-class="['!p-4']">
|
||||
<h3 class="text-base font-medium mb-3">情感分布</h3>
|
||||
<div class="flex items-end gap-6">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-16 bg-green-100 rounded-t" :style="{ height: getSentimentBarHeight('POSITIVE') + 'px' }"></div>
|
||||
<div class="mt-1 text-sm font-medium text-green-600">{{ stats?.sentimentDistribution?.POSITIVE || 0 }}</div>
|
||||
<div class="text-xs text-gray-500">正面</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-16 bg-gray-200 rounded-t" :style="{ height: getSentimentBarHeight('NEUTRAL') + 'px' }"></div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-600">{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}</div>
|
||||
<div class="text-xs text-gray-500">中性</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-16 bg-red-100 rounded-t" :style="{ height: getSentimentBarHeight('NEGATIVE') + 'px' }"></div>
|
||||
<div class="mt-1 text-sm font-medium text-red-600">{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}</div>
|
||||
<div class="text-xs text-gray-500">负面</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- Daily Trend -->
|
||||
<VCard class="mt-4" :body-class="['!p-4']">
|
||||
<h3 class="text-base font-medium mb-3">近7日回复趋势</h3>
|
||||
<div class="flex items-end gap-2 h-32">
|
||||
<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">{{ day.count }}</div>
|
||||
<div
|
||||
class="w-full bg-blue-400 rounded-t transition-all"
|
||||
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
|
||||
></div>
|
||||
<div class="text-[10px] text-gray-400 mt-1">{{ formatTrendDate(day.date) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- AI Persona Section -->
|
||||
<VCard class="mt-4" :body-class="['!p-4']">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">AI角色信息</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
AI虚拟评论者,以独立身份参与评论讨论
|
||||
</p>
|
||||
</div>
|
||||
<VButton @click="openSettings"> 修改配置 </VButton>
|
||||
</div>
|
||||
|
||||
<div v-if="persona" class="mt-4 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-lg">
|
||||
🤖
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">{{ persona.name }}</div>
|
||||
<div class="text-xs text-gray-400">AI虚拟评论者</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="persona.prompt" class="flex items-start gap-2">
|
||||
<span class="text-sm text-gray-500 shrink-0 whitespace-nowrap">人格提示词:</span>
|
||||
<span class="text-sm text-gray-600 line-clamp-3">{{ persona.prompt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-4 text-sm text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="mt-4 flex gap-4">
|
||||
<VButton @click="$router.push({ name: 'CommentAiAutopilotLogs' })">
|
||||
查看AI回复日志
|
||||
</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue"
|
||||
import { axiosInstance } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
interface DailyCount {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface StatsResponse {
|
||||
total: number
|
||||
passCount: number
|
||||
failCount: number
|
||||
reviewingCount: number
|
||||
avgScore: number
|
||||
sentimentDistribution: Record<string, number>
|
||||
dailyTrend: DailyCount[]
|
||||
}
|
||||
|
||||
interface PersonaResponse {
|
||||
name: string
|
||||
prompt: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
const stats = ref<StatsResponse | null>(null)
|
||||
const persona = ref<PersonaResponse | null>(null)
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats",
|
||||
)
|
||||
stats.value = data
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch stats", e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPersona = async () => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/persona",
|
||||
)
|
||||
persona.value = data
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch persona", e)
|
||||
}
|
||||
}
|
||||
|
||||
const openSettings = () => {
|
||||
window.location.href = "/console/comment-ai-autopilot/settings"
|
||||
}
|
||||
|
||||
const getSentimentBarHeight = (sentiment: string): number => {
|
||||
const dist = stats.value?.sentimentDistribution
|
||||
if (!dist) return 0
|
||||
const max = Math.max(dist.POSITIVE || 0, dist.NEUTRAL || 0, dist.NEGATIVE || 0, 1)
|
||||
const value = dist[sentiment] || 0
|
||||
return Math.max((value / max) * 80, value > 0 ? 8 : 0)
|
||||
}
|
||||
|
||||
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) * 80, count > 0 ? 8 : 0)
|
||||
}
|
||||
|
||||
const formatTrendDate = (dateStr: string): string => {
|
||||
if (!dateStr) return ''
|
||||
const parts = dateStr.split('-')
|
||||
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
fetchPersona()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<div class="comment-ai-autopilot-logs">
|
||||
<VPageHeader title="AI回复日志">
|
||||
<template #icon>
|
||||
<IconPlug class="mr-2 self-center" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })"> 返回概览 </VButton>
|
||||
</template>
|
||||
</VPageHeader>
|
||||
|
||||
<!-- Batch Operation Toolbar -->
|
||||
<div v-if="selectedNames.size > 0" class="mx-4 mt-2 flex items-center gap-3 bg-blue-50 border border-blue-200 rounded-lg px-4 py-2.5">
|
||||
<span class="text-sm text-blue-700">已选择 {{ selectedNames.size }} 项</span>
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded bg-green-600 text-white hover:bg-green-700 transition-colors"
|
||||
@click="batchApprove"
|
||||
>
|
||||
批量通过
|
||||
</button>
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
||||
@click="batchReject"
|
||||
>
|
||||
批量拒绝
|
||||
</button>
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
|
||||
@click="batchDelete"
|
||||
>
|
||||
批量删除
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-gray-500 hover:text-gray-700 ml-auto"
|
||||
@click="selectedNames.clear(); selectAll = false"
|
||||
>
|
||||
取消选择
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="m-4">
|
||||
<VLoading v-if="loading" />
|
||||
|
||||
<div v-else-if="replies.length === 0" class="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<svg class="w-12 h-12 mb-3" 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>
|
||||
<span>暂无AI回复记录</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="replies.length > 0" class="flex items-center gap-2 mb-2 px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectAll"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-xs text-gray-500">全选</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="reply in replies"
|
||||
:key="reply.metadata.name"
|
||||
class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-sm transition-all"
|
||||
>
|
||||
<!-- Card body -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedNames.has(reply.metadata.name)"
|
||||
@change="toggleSelect(reply.metadata.name)"
|
||||
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Row 1: Status tags + time -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="getStatusClass(reply.spec.status)"
|
||||
>
|
||||
{{ getStatusLabel(reply.spec.status) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs"
|
||||
:class="reply.spec.published ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'"
|
||||
>
|
||||
{{ reply.spec.published ? '已发布' : '未发布' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="reply.spec.isAiConversation"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600"
|
||||
>
|
||||
对话
|
||||
</span>
|
||||
<span
|
||||
v-if="reply.spec.sentiment"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="getSentimentClass(reply.spec.sentiment)"
|
||||
>
|
||||
{{ getSentimentLabel(reply.spec.sentiment) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 flex-shrink-0 ml-2">{{ formatDate(reply.metadata.creationTimestamp) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: AI reply content (truncated) -->
|
||||
<div class="text-sm text-gray-800 leading-relaxed break-words line-clamp-3">
|
||||
{{ stripHtml(reply.spec.reply) || '(空)' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card footer: meta info + actions -->
|
||||
<div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>
|
||||
评分 <span :class="getScoreClass(reply.spec.score)" class="font-medium text-gray-600">{{ reply.spec.score }}</span>
|
||||
</span>
|
||||
<span v-if="reply.spec.postSlug" class="flex items-center gap-1">
|
||||
文章
|
||||
<a
|
||||
:href="getPostUrl(reply.spec.postSlug)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-500 hover:text-blue-700 hover:underline"
|
||||
>{{ reply.spec.postSlug }}</a>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-if="reply.spec.retryCount > 0">
|
||||
重试 <span class="text-gray-600">{{ reply.spec.retryCount }}</span> 次
|
||||
</span>
|
||||
</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-green-600 hover:text-green-800 transition-colors px-2 py-1 rounded hover:bg-green-50"
|
||||
@click="handleApprove(reply.metadata.name)"
|
||||
>
|
||||
审核通过
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors px-2 py-1 rounded hover:bg-red-50"
|
||||
@click="handleReject(reply.metadata.name)"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-500 hover:text-blue-700 transition-colors px-2 py-1 rounded hover:bg-blue-50"
|
||||
@click="openConversation(reply)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-red-400 hover:text-red-600 transition-colors px-2 py-1 rounded hover:bg-red-50"
|
||||
@click="handleDelete(reply.metadata.name)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between mt-4 px-1">
|
||||
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
|
||||
<div class="flex gap-2">
|
||||
<VButton size="sm" :disabled="page <= 1" @click="page--">上一页</VButton>
|
||||
<VButton size="sm" :disabled="page >= totalPages" @click="page++">下一页</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Dialog -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="showDialog"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
>
|
||||
<!-- Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40"
|
||||
@click="showDialog = false"
|
||||
></div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] mx-4 flex flex-col overflow-hidden">
|
||||
<!-- Dialog header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<h3 class="text-base font-semibold text-gray-800">完整对话</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
|
||||
@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>
|
||||
|
||||
<!-- Dialog body: conversation -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
<VLoading v-if="conversationLoading" />
|
||||
|
||||
<div v-else-if="conversationMessages.length === 0" class="text-center text-gray-400 py-8">
|
||||
暂无对话内容
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="(msg, idx) in conversationMessages"
|
||||
:key="idx"
|
||||
class="flex"
|
||||
:class="msg.isAi ? 'justify-start' : 'justify-end'"
|
||||
>
|
||||
<div class="max-w-[80%]">
|
||||
<!-- Owner name -->
|
||||
<div
|
||||
class="text-xs mb-1"
|
||||
:class="msg.isAi ? 'text-blue-500' : 'text-gray-500'"
|
||||
>
|
||||
<span class="font-medium">{{ msg.owner }}</span>
|
||||
</div>
|
||||
<!-- Bubble -->
|
||||
<div
|
||||
class="rounded-2xl px-4 py-2.5 text-sm leading-relaxed break-words"
|
||||
: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)"
|
||||
></div>
|
||||
<!-- Time -->
|
||||
<div class="text-[10px] text-gray-300 mt-1" :class="msg.isAi ? 'text-left' : 'text-right'">
|
||||
{{ formatDate(msg.time) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog footer -->
|
||||
<div class="px-5 py-3 border-t border-gray-100 flex justify-end">
|
||||
<button
|
||||
class="px-4 py-1.5 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
@click="showDialog = false"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue"
|
||||
import { axiosInstance } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
interface AiCommentReplyItem {
|
||||
metadata: {
|
||||
name: string
|
||||
creationTimestamp: string
|
||||
}
|
||||
spec: {
|
||||
commentId: string
|
||||
postId: string
|
||||
postSlug: string
|
||||
reply: string
|
||||
score: number
|
||||
status: string
|
||||
retryCount: number
|
||||
replyTo: string
|
||||
isAiConversation: boolean
|
||||
published: boolean
|
||||
sentiment: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface ConversationMessage {
|
||||
type: string
|
||||
owner: string
|
||||
content: string
|
||||
time: string
|
||||
isAi: boolean
|
||||
}
|
||||
|
||||
const replies = ref<AiCommentReplyItem[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const size = ref(20)
|
||||
const total = ref(0)
|
||||
const totalPages = ref(0)
|
||||
|
||||
// Selection state
|
||||
const selectedNames = ref<Set<string>>(new Set())
|
||||
const selectAll = ref(false)
|
||||
|
||||
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<ConversationMessage[]>([])
|
||||
|
||||
const fetchReplies = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
|
||||
{ params: { page: page.value, size: size.value } },
|
||||
)
|
||||
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 openConversation = async (reply: AiCommentReplyItem) => {
|
||||
showDialog.value = true
|
||||
conversationLoading.value = true
|
||||
conversationMessages.value = []
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
try {
|
||||
await axiosInstance.delete(
|
||||
`/apis/comment-ai-autopilot.nxxy335.top/v1alpha1/aicommentreplies/${name}`,
|
||||
)
|
||||
Toast.success("删除成功")
|
||||
fetchReplies()
|
||||
} catch (e) {
|
||||
console.error("Failed to delete reply", 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 getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case "PASS":
|
||||
return "bg-green-100 text-green-700"
|
||||
case "FAIL":
|
||||
return "bg-red-100 text-red-700"
|
||||
case "PENDING":
|
||||
return "bg-gray-100 text-gray-700"
|
||||
case "REJECTED":
|
||||
return "bg-red-100 text-red-700"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700"
|
||||
}
|
||||
}
|
||||
|
||||
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 "POSITIVE":
|
||||
return "bg-green-50 text-green-600"
|
||||
case "NEGATIVE":
|
||||
return "bg-red-50 text-red-600"
|
||||
case "NEUTRAL":
|
||||
return "bg-gray-100 text-gray-600"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-600"
|
||||
}
|
||||
}
|
||||
|
||||
const getSentimentLabel = (sentiment: string) => {
|
||||
switch (sentiment) {
|
||||
case "POSITIVE":
|
||||
return "正面"
|
||||
case "NEGATIVE":
|
||||
return "负面"
|
||||
case "NEUTRAL":
|
||||
return "中性"
|
||||
default:
|
||||
return sentiment
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: string) => {
|
||||
if (!timestamp) return ""
|
||||
return new Date(timestamp).toLocaleString("zh-CN")
|
||||
}
|
||||
|
||||
const getPostUrl = (slug: string) => {
|
||||
return `${window.location.origin}/archives/${slug}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags for plain text display (card preview)
|
||||
*/
|
||||
const stripHtml = (html: string) => {
|
||||
if (!html) return ""
|
||||
return html
|
||||
.replace(/<p[^>]*>/gi, "")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and render HTML content for conversation bubbles.
|
||||
* Only allows safe inline tags, strips dangerous elements.
|
||||
*/
|
||||
const renderContent = (content: string) => {
|
||||
if (!content) return "<span class='text-gray-400'>(空)</span>"
|
||||
return content
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "")
|
||||
.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, "")
|
||||
.replace(/<embed[^>]*>/gi, "")
|
||||
.replace(/<form[^>]*>[\s\S]*?<\/form>/gi, "")
|
||||
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, "")
|
||||
.replace(/on\w+\s*=\s*[^\s>]*/gi, "")
|
||||
.replace(/<p[^>]*>/gi, "<p style='margin:0 0 0.5em 0'>")
|
||||
.replace(/<a /gi, "<a target='_blank' rel='noopener noreferrer' ")
|
||||
}
|
||||
|
||||
watch(page, () => {
|
||||
selectedNames.value.clear()
|
||||
selectAll.value = false
|
||||
fetchReplies()
|
||||
})
|
||||
|
||||
onMounted(fetchReplies)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="comment-ai-autopilot-settings">
|
||||
<VPageHeader title="插件设置">
|
||||
<template #icon>
|
||||
<IconPlug class="mr-2 self-center" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })"> 返回概览 </VButton>
|
||||
</template>
|
||||
</VPageHeader>
|
||||
|
||||
<div class="m-4">
|
||||
<VCard :body-class="['!p-0']">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex border-b">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<VLoading v-if="loading" />
|
||||
|
||||
<!-- Basic Settings -->
|
||||
<div v-if="activeTab === 'basic' && !loading" class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium">自动回复</div>
|
||||
<div class="text-sm text-gray-500">启用后,AI将自动回复新评论</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" v-model="settings.basic.autoReply" class="peer sr-only" />
|
||||
<div class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium">自动发布</div>
|
||||
<div class="text-sm text-gray-500">审核通过后自动发布AI回复</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" v-model="settings.basic.autoPublish" class="peer sr-only" />
|
||||
<div class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-medium">最大重试次数</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="settings.basic.maxRetryCount"
|
||||
min="1"
|
||||
max="10"
|
||||
class="mt-1 block w-full max-w-xs rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-medium">评论者黑名单</label>
|
||||
<div class="mt-1 text-sm text-gray-500">输入评论者显示名称,多个用逗号分隔。这些评论者的评论不会触发AI回复</div>
|
||||
<textarea
|
||||
v-model="settings.basic.blockedCommenters"
|
||||
rows="3"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="例如: 张三,李四"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persona Settings -->
|
||||
<div v-if="activeTab === 'persona' && !loading" class="space-y-6">
|
||||
<div>
|
||||
<label class="font-medium">AI角色昵称</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.persona.personaName"
|
||||
class="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="小回"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-medium">AI角色邮箱</label>
|
||||
<div class="mt-1 text-sm text-gray-500">用于Gravatar头像服务展示头像,留空则使用默认头像</div>
|
||||
<input
|
||||
type="email"
|
||||
v-model="settings.persona.personaEmail"
|
||||
class="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="ai@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-medium">AI角色人格提示词</label>
|
||||
<textarea
|
||||
v-model="settings.persona.personaPrompt"
|
||||
rows="4"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="定义AI角色的性格和回复风格"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Settings -->
|
||||
<div v-if="activeTab === 'model' && !loading" class="space-y-6">
|
||||
<div>
|
||||
<label class="font-medium">AI模型名称</label>
|
||||
<div class="mt-1 text-sm text-gray-500">留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型</div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.model.modelName"
|
||||
class="mt-2 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="留空使用默认模型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Settings -->
|
||||
<div v-if="activeTab === 'prompt' && !loading" class="space-y-6">
|
||||
<div>
|
||||
<label class="font-medium">自定义Prompt模板</label>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
可用变量: <code class="text-xs bg-gray-100 px-1">{'{{'}persona_prompt{{'}}'}}</code>,
|
||||
<code class="text-xs bg-gray-100 px-1">{'{{'}safety_prompt{{'}}'}}</code>,
|
||||
<code class="text-xs bg-gray-100 px-1">{'{{'}article{{'}}'}}</code>,
|
||||
<code class="text-xs bg-gray-100 px-1">{'{{'}comment{{'}}'}}</code>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="settings.prompt.customPromptTemplate"
|
||||
rows="10"
|
||||
class="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm font-mono focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="自定义Prompt模板"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div v-if="!loading" class="mt-6 flex justify-end">
|
||||
<VButton type="primary" @click="saveSettings" :disabled="saving">
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue"
|
||||
import { axiosInstance } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VCard, VLoading, Toast } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
const tabs = [
|
||||
{ key: "basic", label: "基本设置" },
|
||||
{ key: "persona", label: "AI角色设置" },
|
||||
{ key: "model", label: "模型设置" },
|
||||
{ key: "prompt", label: "Prompt设置" },
|
||||
]
|
||||
|
||||
const activeTab = ref("basic")
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const settings = reactive({
|
||||
basic: {
|
||||
autoReply: true,
|
||||
autoPublish: true,
|
||||
maxRetryCount: 3,
|
||||
blockedCommenters: "",
|
||||
},
|
||||
persona: {
|
||||
personaName: "小回",
|
||||
personaPrompt: "",
|
||||
personaEmail: "",
|
||||
},
|
||||
model: {
|
||||
modelName: "",
|
||||
},
|
||||
prompt: {
|
||||
customPromptTemplate: "",
|
||||
},
|
||||
})
|
||||
|
||||
const configMapName = "comment-ai-autopilot-configmap"
|
||||
|
||||
const fetchSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
`/api/v1alpha1/configmaps/${configMapName}`,
|
||||
)
|
||||
if (data.data) {
|
||||
const d = data.data
|
||||
if (d.basic) {
|
||||
settings.basic.autoReply = d.basic.autoReply !== false
|
||||
settings.basic.autoPublish = d.basic.autoPublish !== false
|
||||
settings.basic.maxRetryCount = d.basic.maxRetryCount || 3
|
||||
settings.basic.blockedCommenters = d.basic.blockedCommenters || ""
|
||||
}
|
||||
if (d.persona) {
|
||||
settings.persona.personaName = d.persona.personaName || "小回"
|
||||
settings.persona.personaPrompt = d.persona.personaPrompt || ""
|
||||
settings.persona.personaEmail = d.persona.personaEmail || ""
|
||||
}
|
||||
if (d.model) {
|
||||
settings.model.modelName = d.model.modelName || ""
|
||||
}
|
||||
if (d.prompt) {
|
||||
settings.prompt.customPromptTemplate = d.prompt.customPromptTemplate || ""
|
||||
}
|
||||
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch settings", e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// Fetch latest version first
|
||||
const { data: latest } = await axiosInstance.get(
|
||||
`/api/v1alpha1/configmaps/${configMapName}`,
|
||||
)
|
||||
|
||||
const updated = { ...latest }
|
||||
updated.data = {
|
||||
...updated.data,
|
||||
basic: {
|
||||
autoReply: settings.basic.autoReply,
|
||||
autoPublish: settings.basic.autoPublish,
|
||||
maxRetryCount: settings.basic.maxRetryCount,
|
||||
blockedCommenters: settings.basic.blockedCommenters,
|
||||
},
|
||||
persona: {
|
||||
personaName: settings.persona.personaName,
|
||||
personaPrompt: settings.persona.personaPrompt,
|
||||
personaEmail: settings.persona.personaEmail,
|
||||
},
|
||||
model: {
|
||||
modelName: settings.model.modelName,
|
||||
},
|
||||
prompt: {
|
||||
customPromptTemplate: settings.prompt.customPromptTemplate,
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
await axiosInstance.put(
|
||||
`/api/v1alpha1/configmaps/${configMapName}`,
|
||||
updated,
|
||||
)
|
||||
Toast.success("设置已保存")
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings", e)
|
||||
Toast.error("保存设置失败")
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchSettings)
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"include": [
|
||||
"rsbuild.config.*",
|
||||
"vitest.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user