feat: 样式优化

This commit is contained in:
digua
2025-12-04 01:26:56 +08:00
parent 90c048acc1
commit 107d8dda82
21 changed files with 296 additions and 628 deletions
+2 -2
View File
@@ -58,9 +58,9 @@ class MainProcess {
async createWindow() {
this.mainWindow = new BrowserWindow({
width: 1180,
height: 720,
height: 752,
minWidth: 1180,
minHeight: 720,
minHeight: 752,
show: false,
autoHideMenuBar: true,
webPreferences: {
+1 -1
View File
@@ -97,7 +97,7 @@ const mainIpcMain = (win: BrowserWindow) => {
if (data.resize) {
win.setResizable(true)
} else {
win.setSize(1180, 720)
win.setSize(1180, 752)
win.setResizable(false)
}
})
+1
View File
@@ -13,6 +13,7 @@ declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
UAlert: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Alert.vue')['default']
UApp: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/App.vue')['default']
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
+46 -135
View File
@@ -1,10 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { CatchphraseAnalysis, RepeatAnalysis } from '@/types/chat'
import { ListPro } from '@/components/charts'
import { SectionCard, EmptyState, LoadingState } from '@/components/UI'
import { KeywordAnalysis } from './quotes'
import { formatDate, getRankBadgeClass } from '@/utils'
import { ref } from 'vue'
import { SubTabs } from '@/components/UI'
import { CatchphraseTab, HotRepeatTab, KeywordAnalysis } from './quotes'
interface TimeFilter {
startTs?: number
@@ -16,141 +13,55 @@ const props = defineProps<{
timeFilter?: TimeFilter
}>()
// ==================== 口头禅分析 ====================
const catchphraseAnalysis = ref<CatchphraseAnalysis | null>(null)
const isLoadingCatchphrase = ref(false)
// 子 Tab 配置
const subTabs = [
{ id: 'catchphrase', label: '口头禅', icon: 'i-heroicons-chat-bubble-bottom-center-text' },
{ id: 'hot-repeat', label: '最火复读', icon: 'i-heroicons-fire' },
{ id: 'keyword', label: '关键词分析', icon: 'i-heroicons-magnifying-glass' },
]
async function loadCatchphraseAnalysis() {
if (!props.sessionId) return
isLoadingCatchphrase.value = true
try {
catchphraseAnalysis.value = await window.chatApi.getCatchphraseAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载口头禅分析失败:', error)
} finally {
isLoadingCatchphrase.value = false
}
}
// ==================== 最火复读内容 ====================
const repeatAnalysis = ref<RepeatAnalysis | null>(null)
const isLoadingRepeat = ref(false)
async function loadRepeatAnalysis() {
if (!props.sessionId) return
isLoadingRepeat.value = true
try {
repeatAnalysis.value = await window.chatApi.getRepeatAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载复读分析失败:', error)
} finally {
isLoadingRepeat.value = false
}
}
function truncateContent(content: string, maxLength = 30): string {
if (content.length <= maxLength) return content
return content.slice(0, maxLength) + '...'
}
// 监听 sessionId 和 timeFilter 变化
watch(
() => [props.sessionId, props.timeFilter],
() => {
loadCatchphraseAnalysis()
loadRepeatAnalysis()
},
{ immediate: true, deep: true }
)
const activeSubTab = ref('catchphrase')
</script>
<template>
<div class="mx-auto max-w-3xl space-y-6 p-6">
<!-- 口头禅分析模块 -->
<LoadingState v-if="isLoadingCatchphrase" text="正在分析口头禅数据..." />
<div class="flex h-full flex-col">
<!-- Tab 导航 -->
<SubTabs v-model="activeSubTab" :items="subTabs" />
<ListPro
v-else-if="catchphraseAnalysis && catchphraseAnalysis.members.length > 0"
:items="catchphraseAnalysis.members"
title="💬 口头禅分析"
:description="`分析了 ${catchphraseAnalysis.members.length} 位成员的高频发言`"
countTemplate="共 {count} 位成员"
>
<template #item="{ item: member }">
<div class="flex items-start gap-4">
<div class="w-28 shrink-0 pt-1 font-medium text-gray-900 dark:text-white">
{{ member.name }}
</div>
<!-- Tab 内容 -->
<div class="flex-1 min-h-0 overflow-auto">
<Transition name="fade" mode="out-in">
<!-- 口头禅分析 -->
<CatchphraseTab
v-if="activeSubTab === 'catchphrase'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<div class="flex flex-1 flex-wrap items-center gap-2">
<div
v-for="(phrase, index) in member.catchphrases"
:key="index"
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5"
:class="
index === 0
? 'bg-amber-50 dark:bg-amber-900/20'
: index === 1
? 'bg-gray-100 dark:bg-gray-800'
: 'bg-gray-50 dark:bg-gray-800/50'
"
>
<span
class="text-sm"
:class="
index === 0 ? 'font-medium text-amber-700 dark:text-amber-400' : 'text-gray-700 dark:text-gray-300'
"
:title="phrase.content"
>
{{ truncateContent(phrase.content, 20) }}
</span>
<span class="text-xs text-gray-400">{{ phrase.count }}</span>
</div>
</div>
<!-- 最火复读内容 -->
<HotRepeatTab
v-else-if="activeSubTab === 'hot-repeat'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<!-- 关键词分析 -->
<div v-else-if="activeSubTab === 'keyword'" class="mx-auto max-w-3xl p-6">
<KeywordAnalysis :session-id="props.sessionId" :time-filter="props.timeFilter" />
</div>
</template>
</ListPro>
<SectionCard v-else title="💬 口头禅分析">
<EmptyState text="暂无口头禅数据" />
</SectionCard>
<!-- 最火复读内容 -->
<LoadingState v-if="isLoadingRepeat" text="正在加载复读数据..." />
<ListPro
v-else-if="repeatAnalysis && repeatAnalysis.hotContents.length > 0"
:items="repeatAnalysis.hotContents"
title="🔥 最火复读内容"
description="单次复读参与人数最多的内容"
:topN="10"
countTemplate="共 {count} 条热门复读"
>
<template #item="{ item, index }">
<div class="flex items-center gap-3">
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="getRankBadgeClass(index)"
>
{{ index + 1 }}
</span>
<span class="shrink-0 text-lg font-bold text-pink-600">{{ item.maxChainLength }}</span>
<div class="flex flex-1 items-center gap-1 overflow-hidden text-sm">
<span class="shrink-0 font-medium text-gray-900 dark:text-white">{{ item.originatorName }}</span>
<span class="truncate text-gray-600 dark:text-gray-400" :title="item.content">
{{ truncateContent(item.content) }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-xs text-gray-500">
<span>{{ item.count }} </span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>{{ formatDate(item.lastTs) }}</span>
</div>
</div>
</template>
</ListPro>
<!-- 关键词分析 -->
<KeywordAnalysis :session-id="sessionId" :time-filter="timeFilter" />
</Transition>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+1 -1
View File
@@ -269,7 +269,7 @@ watch(
</div>
<div class="mt-4 flex justify-end">
<UButton color="gray" variant="soft" @click="showMemberDetailModal = false">关闭</UButton>
<UButton variant="soft" @click="showMemberDetailModal = false">关闭</UButton>
</div>
</div>
</template>
+8 -37
View File
@@ -54,20 +54,12 @@ function highlightKeywords(text: string): string {
<!-- 展开状态 -->
<template v-else>
<!-- 头部 -->
<div
class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800"
>
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-document-magnifying-glass" class="h-5 w-5 text-gray-500" />
<span class="font-medium text-gray-900 dark:text-white">数据源</span>
</div>
<UButton
icon="i-heroicons-chevron-right"
color="gray"
variant="ghost"
size="xs"
@click="emit('toggle')"
/>
<UButton icon="i-heroicons-chevron-right" color="gray" variant="ghost" size="xs" @click="emit('toggle')" />
</div>
<!-- 当前关键词 -->
@@ -92,10 +84,7 @@ function highlightKeywords(text: string): string {
</div>
<!-- 空状态 -->
<div
v-else-if="messages.length === 0"
class="flex flex-col items-center justify-center py-8 text-center"
>
<div v-else-if="messages.length === 0" class="flex flex-col items-center justify-center py-8 text-center">
<UIcon name="i-heroicons-inbox" class="h-10 w-10 text-gray-300 dark:text-gray-600" />
<p class="mt-2 text-sm text-gray-500">暂无数据</p>
<p class="text-xs text-gray-400">发送问题后相关记录会显示在这里</p>
@@ -114,33 +103,22 @@ function highlightKeywords(text: string): string {
</span>
<span class="text-xs text-gray-400">{{ formatTime(msg.timestamp) }}</span>
</div>
<p
class="line-clamp-3 text-sm text-gray-600 dark:text-gray-400"
v-html="highlightKeywords(msg.content)"
/>
<p class="line-clamp-3 text-sm text-gray-600 dark:text-gray-400" v-html="highlightKeywords(msg.content)" />
</div>
</div>
</div>
<!-- 底部统计 & 加载更多 -->
<div
v-if="messages.length > 0"
class="border-t border-gray-200 px-4 py-2 dark:border-gray-800"
>
<div v-if="messages.length > 0" class="border-t border-gray-200 px-4 py-2 dark:border-gray-800">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500"> {{ messages.length }} 条记录</span>
<UButton size="xs" color="gray" variant="ghost" @click="emit('loadMore')">
加载更多
</UButton>
<UButton size="xs" variant="ghost" @click="emit('loadMore')">加载更多</UButton>
</div>
</div>
</template>
<!-- 半透明数据流背景效果 -->
<div
v-if="!isCollapsed"
class="pointer-events-none absolute inset-0 overflow-hidden rounded-xl opacity-[0.03]"
>
<div v-if="!isCollapsed" class="pointer-events-none absolute inset-0 overflow-hidden rounded-xl opacity-[0.03]">
<div class="data-flow-bg absolute inset-0" />
</div>
</div>
@@ -154,13 +132,7 @@ function highlightKeywords(text: string): string {
/* 数据流背景动画 */
.data-flow-bg {
background: repeating-linear-gradient(
0deg,
transparent,
transparent 20px,
currentColor 20px,
currentColor 21px
);
background: repeating-linear-gradient(0deg, transparent, transparent 20px, currentColor 20px, currentColor 21px);
animation: dataFlow 20s linear infinite;
}
@@ -173,4 +145,3 @@ function highlightKeywords(text: string): string {
}
}
</style>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { CatchphraseAnalysis } from '@/types/chat'
import { ListPro } from '@/components/charts'
import { SectionCard, EmptyState, LoadingState } from '@/components/UI'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
// ==================== 口头禅分析 ====================
const catchphraseAnalysis = ref<CatchphraseAnalysis | null>(null)
const isLoading = ref(false)
async function loadCatchphraseAnalysis() {
if (!props.sessionId) return
isLoading.value = true
try {
catchphraseAnalysis.value = await window.chatApi.getCatchphraseAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载口头禅分析失败:', error)
} finally {
isLoading.value = false
}
}
function truncateContent(content: string, maxLength = 20): string {
if (content.length <= maxLength) return content
return content.slice(0, maxLength) + '...'
}
// 监听 sessionId 和 timeFilter 变化
watch(
() => [props.sessionId, props.timeFilter],
() => {
loadCatchphraseAnalysis()
},
{ immediate: true, deep: true }
)
</script>
<template>
<div class="mx-auto max-w-3xl p-6">
<!-- 加载中 -->
<LoadingState v-if="isLoading" text="正在分析口头禅数据..." />
<!-- 口头禅列表 -->
<ListPro
v-else-if="catchphraseAnalysis && catchphraseAnalysis.members.length > 0"
:items="catchphraseAnalysis.members"
title="💬 口头禅分析"
:description="`分析了 ${catchphraseAnalysis.members.length} 位成员的高频发言`"
countTemplate="共 {count} 位成员"
>
<template #item="{ item: member }">
<div class="flex items-start gap-4">
<div class="w-28 shrink-0 pt-1 font-medium text-gray-900 dark:text-white">
{{ member.name }}
</div>
<div class="flex flex-1 flex-wrap items-center gap-2">
<div
v-for="(phrase, index) in member.catchphrases"
:key="index"
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5"
:class="
index === 0
? 'bg-amber-50 dark:bg-amber-900/20'
: index === 1
? 'bg-gray-100 dark:bg-gray-800'
: 'bg-gray-50 dark:bg-gray-800/50'
"
>
<span
class="text-sm"
:class="
index === 0 ? 'font-medium text-amber-700 dark:text-amber-400' : 'text-gray-700 dark:text-gray-300'
"
:title="phrase.content"
>
{{ truncateContent(phrase.content) }}
</span>
<span class="text-xs text-gray-400">{{ phrase.count }}</span>
</div>
</div>
</div>
</template>
</ListPro>
<!-- 空状态 -->
<SectionCard v-else title="💬 口头禅分析">
<EmptyState text="暂无口头禅数据" />
</SectionCard>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { RepeatAnalysis } from '@/types/chat'
import { ListPro } from '@/components/charts'
import { LoadingState, EmptyState, SectionCard } from '@/components/UI'
import { formatDate, getRankBadgeClass } from '@/utils'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
// ==================== 最火复读内容 ====================
const repeatAnalysis = ref<RepeatAnalysis | null>(null)
const isLoading = ref(false)
async function loadRepeatAnalysis() {
if (!props.sessionId) return
isLoading.value = true
try {
repeatAnalysis.value = await window.chatApi.getRepeatAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载复读分析失败:', error)
} finally {
isLoading.value = false
}
}
function truncateContent(content: string, maxLength = 30): string {
if (content.length <= maxLength) return content
return content.slice(0, maxLength) + '...'
}
// 监听 sessionId 和 timeFilter 变化
watch(
() => [props.sessionId, props.timeFilter],
() => {
loadRepeatAnalysis()
},
{ immediate: true, deep: true }
)
</script>
<template>
<div class="mx-auto max-w-3xl p-6">
<!-- 加载中 -->
<LoadingState v-if="isLoading" text="正在加载复读数据..." />
<!-- 最火复读内容列表 -->
<ListPro
v-else-if="repeatAnalysis && repeatAnalysis.hotContents.length > 0"
:items="repeatAnalysis.hotContents"
title="🔥 最火复读内容"
description="单次复读参与人数最多的内容"
:topN="10"
countTemplate="共 {count} 条热门复读"
>
<template #item="{ item, index }">
<div class="flex items-center gap-3">
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="getRankBadgeClass(index)"
>
{{ index + 1 }}
</span>
<span class="shrink-0 text-lg font-bold text-pink-600">{{ item.maxChainLength }}</span>
<div class="flex flex-1 items-center gap-1 overflow-hidden text-sm">
<span class="shrink-0 font-medium text-gray-900 dark:text-white">{{ item.originatorName }}</span>
<span class="truncate text-gray-600 dark:text-gray-400" :title="item.content">
{{ truncateContent(item.content) }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-xs text-gray-500">
<span>{{ item.count }} </span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>{{ formatDate(item.lastTs) }}</span>
</div>
</div>
</template>
</ListPro>
<!-- 空状态 -->
<SectionCard v-else title="🔥 最火复读内容">
<EmptyState text="暂无复读数据" />
</SectionCard>
</div>
</template>
@@ -446,7 +446,7 @@ watch(
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="showTemplateModal = false">取消</UButton>
<UButton variant="soft" @click="showTemplateModal = false">取消</UButton>
<UButton
color="primary"
:disabled="!templateName.trim() || templateKeywords.length === 0"
@@ -471,10 +471,10 @@ watch(
{{ keyword }}
<span class="ml-0.5 hover:text-red-500">×</span>
</UBadge>
<UInput v-model="newKeyword" placeholder="输入后回车添加" class="w-32" @keydown.enter.prevent="addKeyword" />
<UInput v-model="newKeyword" placeholder="输入并搜索" class="w-32" @keydown.enter.prevent="addKeyword" />
<button
v-if="currentKeywords.length > 0"
class="text-xs text-gray-400 hover:text-red-500"
class="text-sm text-pink-500 hover:text-red-500"
@click="clearAllKeywords"
>
清空
+2
View File
@@ -1 +1,3 @@
export { default as KeywordAnalysis } from './KeywordAnalysis.vue'
export { default as CatchphraseTab } from './CatchphraseTab.vue'
export { default as HotRepeatTab } from './HotRepeatTab.vue'
+1 -1
View File
@@ -47,7 +47,7 @@ const formattedCount = computed(() => props.countTemplate.replace('{count}', Str
<!-- 完整列表弹窗 -->
<UModal v-model:open="isOpen" :ui="{ content: 'md:w-full max-w-4xl' }">
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" color="gray" variant="ghost">查看完整排行</UButton>
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" variant="ghost">查看完整排行</UButton>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
+1 -1
View File
@@ -45,7 +45,7 @@ const showViewAll = computed(() => {
<!-- 完整排行榜 Dialog -->
<UModal v-model:open="isOpen" :ui="{ content: 'md:w-full max-w-3xl' }">
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" color="gray" variant="ghost">查看完整排行</UButton>
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" variant="ghost">查看完整排行</UButton>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
+2 -2
View File
@@ -55,7 +55,7 @@ watch(
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">全局设置</h2>
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" size="sm" @click="closeModal" />
<UButton icon="i-heroicons-x-mark" variant="ghost" size="sm" @click="closeModal" />
</div>
<!-- Tab 导航 -->
@@ -104,7 +104,7 @@ watch(
<p class="text-sm font-medium text-gray-900 dark:text-white">深色模式</p>
<p class="text-xs text-gray-500 dark:text-gray-400">跟随系统自动切换</p>
</div>
<UBadge color="gray" variant="soft">自动</UBadge>
<UBadge variant="soft">自动</UBadge>
</div>
</div>
</div>
+8 -8
View File
@@ -256,7 +256,7 @@ function getContextMenuItems(session: AnalysisSession) {
@keydown.enter="handleRename"
/>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="closeRenameModal">取消</UButton>
<UButton variant="soft" @click="closeRenameModal">取消</UButton>
<UButton color="primary" :disabled="!newName.trim()" @click="handleRename">确定</UButton>
</div>
</div>
@@ -274,7 +274,7 @@ function getContextMenuItems(session: AnalysisSession) {
此操作无法撤销
</p>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="closeDeleteModal">取消</UButton>
<UButton variant="soft" @click="closeDeleteModal">取消</UButton>
<UButton color="error" @click="confirmDelete">删除</UButton>
</div>
</div>
@@ -283,8 +283,8 @@ function getContextMenuItems(session: AnalysisSession) {
<!-- Footer -->
<div class="px-4 py-2 dark:border-gray-800 space-y-2">
<!-- 问题反馈 -->
<UTooltip :text="isCollapsed ? '问题反馈' : ''" :popper="{ placement: 'right' }">
<!-- 帮助和反馈 -->
<UTooltip :text="isCollapsed ? '帮助和反馈' : ''" :popper="{ placement: 'right' }">
<UButton
:block="!isCollapsed"
class="transition-all rounded-full hover:bg-gray-200/60 dark:hover:bg-gray-800 h-12 cursor-pointer"
@@ -297,12 +297,12 @@ function getContextMenuItems(session: AnalysisSession) {
class="h-5 w-5 shrink-0"
:class="[isCollapsed ? '' : 'mr-2']"
/>
<span v-if="!isCollapsed" class="truncate">更新和反馈</span>
<span v-if="!isCollapsed" class="truncate">帮助和反馈</span>
</UButton>
</UTooltip>
<!-- 设置和帮助 -->
<UTooltip :text="isCollapsed ? '设置和帮助' : ''" :popper="{ placement: 'right' }">
<!-- 设置 -->
<UTooltip :text="isCollapsed ? '设置' : ''" :popper="{ placement: 'right' }">
<UButton
:block="!isCollapsed"
class="transition-all rounded-full hover:bg-gray-200/60 dark:hover:bg-gray-800 h-12 cursor-pointer"
@@ -312,7 +312,7 @@ function getContextMenuItems(session: AnalysisSession) {
@click="chatStore.showSettingModal = true"
>
<UIcon name="i-heroicons-cog-6-tooth" class="h-5 w-5 shrink-0" :class="[isCollapsed ? '' : 'mr-2']" />
<span v-if="!isCollapsed" class="truncate">设置和帮助</span>
<span v-if="!isCollapsed" class="truncate">设置</span>
</UButton>
</UTooltip>
@@ -407,7 +407,7 @@ watch(
>
本地服务
</p>
<p class="mt-0.5 text-[10px] text-gray-500">Ollama </p>
<p class="mt-0.5 text-[10px] text-gray-500">OllamaParallax </p>
</div>
</button>
@@ -453,6 +453,7 @@ watch(
<UInput
v-model="formData.name"
:placeholder="configType === 'preset' ? '留空将使用服务商名称' : '留空将使用 API 端点地址'"
class="w-full"
/>
</div>
@@ -524,23 +525,16 @@ watch(
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">API 端点</label>
<div class="flex gap-2">
<UInput v-model="formData.baseUrl" placeholder="http://localhost:11434/v1" class="flex-1" />
<UButton
:loading="isValidating"
:disabled="!formData.baseUrl"
color="gray"
variant="soft"
@click="validateKey"
>
<UButton :loading="isValidating" :disabled="!formData.baseUrl" variant="soft" @click="validateKey">
测试
</UButton>
</div>
<p class="mt-1 text-xs text-gray-500">Ollama 默认http://localhost:11434/v1</p>
</div>
<!-- 模型名称 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">模型名称</label>
<UInput v-model="formData.model" placeholder="如 llama3.2、qwen2.5、deepseek-r1" />
<UInput v-model="formData.model" placeholder="如 qwen3、deepseek-r1" />
<p class="mt-1 text-xs text-gray-500">输入本地部署的模型名称</p>
</div>
@@ -659,7 +653,7 @@ watch(
<!-- 底部按钮 -->
<div class="mt-6 flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="closeModal">取消</UButton>
<UButton variant="soft" @click="closeModal">取消</UButton>
<UButton color="primary" :disabled="!canSave" :loading="isSaving" @click="saveConfig">
{{ mode === 'add' ? '添加' : '保存' }}
</UButton>
+13 -31
View File
@@ -135,6 +135,14 @@ onMounted(() => {
<!-- 配置列表视图 -->
<div v-else class="space-y-4">
<UAlert color="warning" variant="outline" icon="i-lucide-terminal" class="p-2">
<template #title>
<p>
强烈建议配置本地模型分析聊天记录更加安全个人实测3B小模型也能满足分析需求而且可以无限量分析聊天记录参考教程
<a href="https://baidu.com" class="text-pink-500" target="_blank">配置Parallax本地模型</a>
</p>
</template>
</UAlert>
<!-- 配置列表 -->
<div v-if="configs.length > 0" class="space-y-2">
<div
@@ -207,37 +215,11 @@ onMounted(() => {
</div>
<!-- 添加按钮 -->
<UButton block color="gray" variant="soft" :disabled="isMaxConfigs" class="mt-4" @click="openAddModal">
<UIcon name="i-heroicons-plus" class="mr-2 h-4 w-4" />
{{ isMaxConfigs ? '已达最大配置数量(10个)' : '添加新配置' }}
</UButton>
<!-- 获取 API Key 链接仅在没有配置时显示 -->
<div v-if="configs.length === 0" class="mt-4 rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
<p class="text-sm text-gray-600 dark:text-gray-400">还没有 API Key前往获取</p>
<div class="mt-2 flex flex-wrap gap-3">
<a
href="https://platform.deepseek.com/"
target="_blank"
class="text-sm text-violet-600 hover:underline dark:text-violet-400"
>
DeepSeek
</a>
<a
href="https://dashscope.console.aliyun.com/"
target="_blank"
class="text-sm text-violet-600 hover:underline dark:text-violet-400"
>
通义千问
</a>
<a
href="https://ollama.com/"
target="_blank"
class="text-sm text-violet-600 hover:underline dark:text-violet-400"
>
Ollama (本地)
</a>
</div>
<div class="flex justify-center">
<UButton variant="soft" :disabled="isMaxConfigs" class="mt-4" @click="openAddModal">
<UIcon name="i-heroicons-plus" class="mr-2 h-4 w-4" />
{{ isMaxConfigs ? '已达最大配置数量(10个)' : '添加新配置' }}
</UButton>
</div>
</div>
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
const emit = defineEmits<{
'config-changed': []
}>()
const chatStore = useChatStore()
const { aiGlobalSettings } = storeToRefs(chatStore)
// 本地状态
const maxMessages = ref(aiGlobalSettings.value.maxMessagesPerRequest)
const isSaving = ref(false)
// 保存配置
async function saveConfig() {
isSaving.value = true
try {
chatStore.updateAIGlobalSettings({
maxMessagesPerRequest: maxMessages.value,
})
emit('config-changed')
} finally {
isSaving.value = false
}
}
// 监听 store 变化
watch(
() => aiGlobalSettings.value.maxMessagesPerRequest,
(newVal) => {
maxMessages.value = newVal
}
)
</script>
<template>
<div class="space-y-6">
<!-- 标题 -->
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-4 w-4 text-primary-500" />
AI 聊天配置
</h3>
<div class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<!-- 上下文消息数量 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">上下文消息数量</label>
<div class="flex items-center gap-4">
<URange v-model="maxMessages" :min="10" :max="1000" :step="10" class="flex-1" />
<UInput v-model.number="maxMessages" type="number" class="w-24" />
</div>
<p class="mt-1 text-xs text-gray-500">
每次发送给 AI 的历史消息数量10-1000数量越多AI 了解的上下文越多但消耗的 Token 也越多
</p>
</div>
<!-- 保存按钮 -->
<div class="flex justify-end pt-2">
<UButton color="primary" :loading="isSaving" @click="saveConfig">保存配置</UButton>
</div>
</div>
</div>
</template>
-308
View File
@@ -1,308 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
// Emits
const emit = defineEmits<{
'config-changed': []
}>()
// 状态
const isLoading = ref(false)
const isValidating = ref(false)
const isSaving = ref(false)
const providers = ref<
Array<{
id: string
name: string
description: string
models: Array<{ id: string; name: string; description?: string }>
}>
>([])
// 表单数据
const selectedProvider = ref('')
const apiKey = ref('')
const selectedModel = ref('')
const maxTokens = ref(200)
// 当前配置状态
const hasExistingConfig = ref(false)
const existingConfigDisplay = ref('')
// 验证结果
const validationResult = ref<'idle' | 'valid' | 'invalid'>('idle')
const validationMessage = ref('')
// 当前选中的提供商信息
const currentProvider = computed(() => {
return providers.value.find((p) => p.id === selectedProvider.value)
})
// 模型选项
const modelOptions = computed(() => {
if (!currentProvider.value) return []
return currentProvider.value.models.map((m) => ({
label: m.name,
value: m.id,
description: m.description,
}))
})
// 是否可以保存(有提供商,且有新 API Key 或已有配置)
const canSave = computed(() => {
return selectedProvider.value && (apiKey.value.trim().length > 0 || hasExistingConfig.value)
})
// 加载提供商列表
async function loadProviders() {
isLoading.value = true
try {
providers.value = await window.llmApi.getProviders()
if (providers.value.length > 0 && !selectedProvider.value) {
selectedProvider.value = providers.value[0].id
}
} catch (error) {
console.error('加载提供商列表失败:', error)
} finally {
isLoading.value = false
}
}
// 加载当前配置
async function loadCurrentConfig() {
try {
const config = await window.llmApi.getConfig()
if (config && config.apiKeySet) {
hasExistingConfig.value = true
existingConfigDisplay.value = config.apiKey
selectedProvider.value = config.provider
selectedModel.value = config.model || ''
maxTokens.value = config.maxTokens || 200
}
} catch (error) {
console.error('加载当前配置失败:', error)
}
}
// 验证 API Key
async function validateKey() {
if (!selectedProvider.value || !apiKey.value) {
validationResult.value = 'idle'
validationMessage.value = ''
return
}
isValidating.value = true
validationResult.value = 'idle'
try {
const isValid = await window.llmApi.validateApiKey(selectedProvider.value, apiKey.value)
validationResult.value = isValid ? 'valid' : 'invalid'
validationMessage.value = isValid ? 'API Key 验证成功' : 'API Key 无效,请检查'
} catch (error) {
validationResult.value = 'invalid'
validationMessage.value = '验证失败:' + String(error)
} finally {
isValidating.value = false
}
}
// 保存配置
async function saveConfig() {
if (!canSave.value) return
isSaving.value = true
try {
const result = await window.llmApi.saveConfig({
provider: selectedProvider.value,
apiKey: apiKey.value,
model: selectedModel.value || undefined,
maxTokens: maxTokens.value,
})
if (result.success) {
emit('config-changed')
// 重新加载配置以更新状态
await loadCurrentConfig()
apiKey.value = '' // 清空输入框
validationResult.value = 'idle'
validationMessage.value = ''
} else {
console.error('保存配置失败:', result.error)
}
} catch (error) {
console.error('保存配置失败:', error)
} finally {
isSaving.value = false
}
}
// 删除配置
async function deleteConfig() {
try {
await window.llmApi.deleteConfig()
hasExistingConfig.value = false
existingConfigDisplay.value = ''
apiKey.value = ''
validationResult.value = 'idle'
validationMessage.value = ''
emit('config-changed')
} catch (error) {
console.error('删除配置失败:', error)
}
}
// 监听提供商变化,自动选择默认模型
watch(selectedProvider, (newProvider) => {
const provider = providers.value.find((p) => p.id === newProvider)
if (provider && provider.models.length > 0) {
selectedModel.value = provider.models[0].id
}
// 重置验证状态
validationResult.value = 'idle'
validationMessage.value = ''
})
// 监听 API Key 变化,重置验证状态
watch(apiKey, () => {
validationResult.value = 'idle'
validationMessage.value = ''
})
// 暴露 refresh 方法
defineExpose({
refresh: () => {
loadProviders()
loadCurrentConfig()
},
})
onMounted(() => {
loadProviders()
loadCurrentConfig()
})
</script>
<template>
<div class="space-y-6">
<!-- 标题 -->
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-violet-500" />
AI 服务配置
</h3>
<!-- 加载中 -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-gray-400" />
</div>
<!-- 配置表单 -->
<div
v-else
class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50"
>
<!-- 已有配置提示 -->
<div v-if="hasExistingConfig" class="rounded-lg bg-green-50 p-3 dark:bg-green-900/20">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-check-circle" class="h-5 w-5 text-green-500" />
<span class="text-sm text-green-700 dark:text-green-400">已配置 API Key: {{ existingConfigDisplay }}</span>
</div>
<UButton size="xs" color="error" variant="ghost" @click="deleteConfig">删除</UButton>
</div>
</div>
<!-- 服务商选择 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">AI 服务商</label>
<USelect
v-model="selectedProvider"
:items="providers.map((p) => ({ label: p.name, value: p.id }))"
placeholder="选择服务商"
/>
<p v-if="currentProvider" class="mt-1 text-xs text-gray-500">
{{ currentProvider.description }}
</p>
</div>
<!-- API Key -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">API Key</label>
<div class="flex gap-2">
<UInput
v-model="apiKey"
type="password"
:placeholder="hasExistingConfig ? '输入新的 API Key(留空保持原有)' : '输入你的 API Key'"
class="flex-1"
/>
<UButton :loading="isValidating" :disabled="!apiKey" color="gray" variant="soft" @click="validateKey">
验证
</UButton>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="mt-2">
<div
v-if="validationResult === 'valid'"
class="flex items-center gap-1 text-sm text-green-600 dark:text-green-400"
>
<UIcon name="i-heroicons-check-circle" class="h-4 w-4" />
{{ validationMessage }}
</div>
<div
v-else-if="validationResult === 'invalid'"
class="flex items-center gap-1 text-sm text-red-600 dark:text-red-400"
>
<UIcon name="i-heroicons-x-circle" class="h-4 w-4" />
{{ validationMessage }}
</div>
</div>
</div>
<!-- 模型选择 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">模型</label>
<USelect v-model="selectedModel" :items="modelOptions" placeholder="选择模型" />
</div>
<!-- 发送条数限制 -->
<!-- 注意用户在 AIChatConfigTab 中也添加了类似的配置这里可能需要移除或保留作为特定模型的配置 -->
<!-- 暂时保留但注意可能冲突不过 AIConfigTab 是针对 LLM Provider 的配置AIChatConfigTab 是针对 Chat 行为的配置 -->
<!-- 用户在 AIConfigTab 中有 maxTokens (发送条数限制?) AIChatConfigTab 中有 maxMessagesPerRequest -->
<!-- 这里的 maxTokens 实际上是 maxTokens 还是 maxMessages? -->
<!-- 原代码: const maxTokens = ref(200) -->
<!-- 原注释: 每次发送给 AI 的最大消息条数10-5000默认 200 -->
<!-- 这看起来像是 maxMessages -->
<!-- 如果 AIChatConfigTab 也有这个配置那么这里应该移除或者同步 -->
<!-- 既然用户添加了 AIChatConfigTab 专门管理这个我应该从这里移除它以免混淆 -->
<!-- 但是为了保持兼容性我先保留它或者根据用户意图调整 -->
<!-- 考虑到用户删除了这个文件可能是因为他们想重构 -->
<!-- 我将恢复它但移除 maxTokens 部分因为它现在在 AIChatConfigTab 中管理 -->
<!-- 获取 API Key 链接 -->
<div class="rounded-lg bg-white p-3 dark:bg-gray-900">
<p class="text-sm text-gray-600 dark:text-gray-400">还没有 API Key前往获取</p>
<div class="mt-2 flex gap-2">
<a
href="https://platform.deepseek.com/"
target="_blank"
class="text-sm text-violet-600 hover:underline dark:text-violet-400"
>
DeepSeek
</a>
<a
href="https://dashscope.console.aliyun.com/"
target="_blank"
class="text-sm text-violet-600 hover:underline dark:text-violet-400"
>
通义千问
</a>
</div>
</div>
<!-- 保存按钮 -->
<div class="flex justify-end pt-2">
<UButton color="primary" :disabled="!canSave" :loading="isSaving" @click="saveConfig">保存配置</UButton>
</div>
</div>
</div>
</template>
+7 -11
View File
@@ -446,7 +446,7 @@ const file2Name = computed(() => files.value[1]?.name || '文件 2')
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">输出目录可选</label>
<div class="flex gap-2">
<UInput v-model="outputDir" placeholder="默认保存到文档/ChatLab/merged/" class="flex-1" readonly />
<UButton icon="i-heroicons-folder" color="gray" variant="soft" @click="selectOutputDir">选择</UButton>
<UButton icon="i-heroicons-folder" variant="soft" @click="selectOutputDir">选择</UButton>
</div>
</div>
</div>
@@ -501,13 +501,9 @@ const file2Name = computed(() => files.value[1]?.name || '文件 2')
class="flex flex-wrap items-center gap-2 border-b border-gray-200 bg-gray-50 px-5 py-3 dark:border-gray-800 dark:bg-gray-800/50"
>
<span class="text-sm text-gray-600 dark:text-gray-400">一键选择</span>
<UButton size="xs" color="gray" variant="soft" @click="batchSelectAll('keep1')">
全部保留{{ file1Name }}
</UButton>
<UButton size="xs" color="gray" variant="soft" @click="batchSelectAll('keep2')">
全部保留{{ file2Name }}
</UButton>
<UButton size="xs" color="gray" variant="soft" @click="batchSelectAll('keepBoth')">全部保留两者</UButton>
<UButton size="xs" variant="soft" @click="batchSelectAll('keep1')">全部保留{{ file1Name }}</UButton>
<UButton size="xs" variant="soft" @click="batchSelectAll('keep2')">全部保留{{ file2Name }}</UButton>
<UButton size="xs" variant="soft" @click="batchSelectAll('keepBoth')">全部保留两者</UButton>
</div>
<!-- 冲突列表 -->
@@ -629,7 +625,7 @@ const file2Name = computed(() => files.value[1]?.name || '文件 2')
<!-- 底部操作 -->
<div class="flex items-center justify-between border-t border-gray-200 px-5 py-4 dark:border-gray-800">
<UButton color="gray" variant="ghost" @click="currentStep = 'select'">
<UButton variant="ghost" @click="currentStep = 'select'">
<UIcon name="i-heroicons-arrow-left" class="mr-1 h-4 w-4" />
返回
</UButton>
@@ -660,7 +656,7 @@ const file2Name = computed(() => files.value[1]?.name || '文件 2')
<UIcon name="i-heroicons-folder-open" class="mr-1 h-4 w-4" />
打开文件夹
</UButton>
<UButton color="gray" @click="reset">继续合并</UButton>
<UButton @click="reset">继续合并</UButton>
</div>
</div>
</template>
@@ -679,7 +675,7 @@ const file2Name = computed(() => files.value[1]?.name || '文件 2')
</div>
</div>
<div class="mt-6 flex justify-end">
<UButton color="gray" @click="showErrorModal = false">我知道了</UButton>
<UButton @click="showErrorModal = false">我知道了</UButton>
</div>
</div>
</template>
-8
View File
@@ -258,14 +258,6 @@ onMounted(() => {
</p>
</div>
</div>
<!-- Year Filter & Actions -->
<div class="flex items-center gap-3">
<!-- Actions -->
<UButton icon="i-heroicons-arrow-down-tray" color="gray" variant="ghost" size="sm" disabled>
生成报告
</UButton>
</div>
</div>
<!-- Tabs -->
+2 -2
View File
@@ -134,7 +134,7 @@ function getProgressDetail(): string {
</div>
<!-- Content Container -->
<div class="relative h-full w-full overflow-y-auto z-10">
<div class="relative h-full w-full overflow-y-auto">
<div class="flex min-h-full w-full flex-col items-center justify-center px-4 py-12">
<!-- Hero Section -->
<div class="xl:mb-16 mb-8 text-center">
@@ -157,7 +157,7 @@ function getProgressDetail(): string {
:key="feature.title"
class="group relative overflow-hidden rounded-3xl border border-transparent p-4 transition-all duration-500"
>
<div class="relative z-10">
<div class="relative">
<div class="mb-3 flex items-center">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-500 group-hover:scale-110 group-hover:rotate-3"