mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-26 16:40:17 +08:00
feat: 重构设置弹窗,新增会话索引设置
This commit is contained in:
@@ -95,7 +95,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<!-- 存储管理 -->
|
||||
<div v-show="activeTab === 'storage'">
|
||||
<div v-show="activeTab === 'storage'" class="h-full">
|
||||
<StorageTab ref="storageTabRef" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AIModelConfigTab from './AI/AIModelConfigTab.vue'
|
||||
import AIPromptConfigTab from './AI/AIPromptConfigTab.vue'
|
||||
import AIPromptPresetTab from './AI/AIPromptPresetTab.vue'
|
||||
import SubTabs from '@/components/UI/SubTabs.vue'
|
||||
import { useSubTabsScroll } from '@/composables/useSubTabsScroll'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -20,86 +21,18 @@ const navItems = computed(() => [
|
||||
{ id: 'preset', label: t('settings.tabs.aiPreset') },
|
||||
])
|
||||
|
||||
// 当前激活的导航项
|
||||
const activeNav = ref('model')
|
||||
|
||||
// 是否由用户点击触发(用于区分点击滚动和手动滚动)
|
||||
const isUserClick = ref(false)
|
||||
|
||||
// 滚动容器引用
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Section 引用
|
||||
const sectionRefs = ref<Record<string, HTMLElement | null>>({
|
||||
model: null,
|
||||
chat: null,
|
||||
preset: null,
|
||||
})
|
||||
// 使用二级导航滚动联动 composable
|
||||
const { activeNav, scrollContainerRef, setSectionRef, handleNavChange } = useSubTabsScroll(navItems)
|
||||
void scrollContainerRef // 在模板中通过 ref="scrollContainerRef" 使用
|
||||
|
||||
// AI 配置变更回调
|
||||
function handleAIConfigChanged() {
|
||||
emit('config-changed')
|
||||
}
|
||||
|
||||
// 处理导航点击(通过 @change 事件)
|
||||
function handleNavChange(id: string) {
|
||||
const section = sectionRefs.value[id]
|
||||
if (section && scrollContainerRef.value) {
|
||||
// 标记为用户点击触发
|
||||
isUserClick.value = true
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
// 滚动动画结束后恢复
|
||||
setTimeout(() => {
|
||||
isUserClick.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听滚动更新当前激活项
|
||||
function handleScroll() {
|
||||
// 如果是用户点击触发的滚动,不更新 activeNav(避免冲突)
|
||||
if (isUserClick.value || !scrollContainerRef.value) return
|
||||
|
||||
const container = scrollContainerRef.value
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const offset = 50 // 偏移量,提前触发
|
||||
|
||||
// 检查是否滚动到底部(误差范围 5px)
|
||||
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 5
|
||||
if (isAtBottom) {
|
||||
// 滚动到底部时,激活最后一个导航项
|
||||
const lastItem = navItems.value[navItems.value.length - 1]
|
||||
if (lastItem) {
|
||||
activeNav.value = lastItem.id
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 检查每个 section 的位置
|
||||
for (const item of navItems.value) {
|
||||
const section = sectionRefs.value[item.id]
|
||||
if (section) {
|
||||
const rect = section.getBoundingClientRect()
|
||||
// 如果 section 顶部在容器可视区域内
|
||||
if (rect.top <= containerRect.top + offset && rect.bottom > containerRect.top + offset) {
|
||||
activeNav.value = item.id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Template refs
|
||||
const aiModelConfigRef = ref<InstanceType<typeof AIModelConfigTab> | null>(null)
|
||||
void aiModelConfigRef
|
||||
|
||||
onMounted(() => {
|
||||
scrollContainerRef.value?.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
scrollContainerRef.value?.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -113,7 +46,7 @@ onUnmounted(() => {
|
||||
<div ref="scrollContainerRef" class="min-w-0 flex-1 overflow-y-auto">
|
||||
<div class="space-y-8">
|
||||
<!-- 模型配置 -->
|
||||
<div :ref="(el) => (sectionRefs.model = el as HTMLElement)">
|
||||
<div :ref="(el) => setSectionRef('model', el as HTMLElement)">
|
||||
<AIModelConfigTab ref="aiModelConfigRef" @config-changed="handleAIConfigChanged" />
|
||||
</div>
|
||||
|
||||
@@ -121,7 +54,7 @@ onUnmounted(() => {
|
||||
<div class="border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<!-- 对话配置 -->
|
||||
<div :ref="(el) => (sectionRefs.chat = el as HTMLElement)">
|
||||
<div :ref="(el) => setSectionRef('chat', el as HTMLElement)">
|
||||
<AIPromptConfigTab @config-changed="handleAIConfigChanged" />
|
||||
</div>
|
||||
|
||||
@@ -129,7 +62,7 @@ onUnmounted(() => {
|
||||
<div class="border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<!-- 提示词配置 -->
|
||||
<div :ref="(el) => (sectionRefs.preset = el as HTMLElement)">
|
||||
<div :ref="(el) => setSectionRef('preset', el as HTMLElement)">
|
||||
<AIPromptPresetTab @config-changed="handleAIConfigChanged" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 会话索引管理区块
|
||||
* 配置会话索引阈值和批量生成功能
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 会话索引状态
|
||||
interface SessionIndexStatus {
|
||||
id: string
|
||||
name: string
|
||||
hasIndex: boolean
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
// 会话索引配置
|
||||
const DEFAULT_GAP_MINUTES = 30 // 默认30分钟
|
||||
const sessionGapMinutes = ref(DEFAULT_GAP_MINUTES)
|
||||
|
||||
// 批量生成相关状态
|
||||
const allSessionsStatus = ref<SessionIndexStatus[]>([])
|
||||
const isLoadingSessionStatus = ref(false)
|
||||
const isBatchGenerating = ref(false)
|
||||
const batchProgress = ref({ current: 0, total: 0, currentName: '' })
|
||||
|
||||
// 计算统计信息
|
||||
const sessionIndexStats = computed(() => {
|
||||
const total = allSessionsStatus.value.length
|
||||
const generated = allSessionsStatus.value.filter((s) => s.hasIndex).length
|
||||
const notGenerated = total - generated
|
||||
return { total, generated, notGenerated }
|
||||
})
|
||||
|
||||
// 进度百分比
|
||||
const batchProgressPercent = computed(() => {
|
||||
if (batchProgress.value.total === 0) return 0
|
||||
return Math.round((batchProgress.value.current / batchProgress.value.total) * 100)
|
||||
})
|
||||
|
||||
// 保存会话阈值(这里只是保存到本地存储,实际每个 session 的阈值在生成时传入)
|
||||
function saveSessionThreshold() {
|
||||
if (sessionGapMinutes.value < 1) sessionGapMinutes.value = 1
|
||||
if (sessionGapMinutes.value > 1440) sessionGapMinutes.value = 1440
|
||||
// 保存到 localStorage 作为全局默认值
|
||||
localStorage.setItem('sessionGapThreshold', String(sessionGapMinutes.value * 60))
|
||||
}
|
||||
|
||||
// 加载会话阈值
|
||||
function loadSessionThreshold() {
|
||||
const saved = localStorage.getItem('sessionGapThreshold')
|
||||
if (saved) {
|
||||
sessionGapMinutes.value = Math.round(parseInt(saved, 10) / 60)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有会话的索引状态
|
||||
async function loadSessionIndexStatus() {
|
||||
isLoadingSessionStatus.value = true
|
||||
try {
|
||||
// 获取所有会话
|
||||
const sessions = await window.chatApi.getSessions()
|
||||
|
||||
// 获取每个会话的索引状态
|
||||
const statusList: SessionIndexStatus[] = []
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
const stats = await window.sessionApi.getStats(session.id)
|
||||
statusList.push({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
hasIndex: stats.hasIndex,
|
||||
sessionCount: stats.sessionCount,
|
||||
})
|
||||
} catch {
|
||||
statusList.push({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
hasIndex: false,
|
||||
sessionCount: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
allSessionsStatus.value = statusList
|
||||
} catch (error) {
|
||||
console.error('加载会话索引状态失败:', error)
|
||||
} finally {
|
||||
isLoadingSessionStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 批量生成所有未生成索引的会话
|
||||
async function batchGenerateIndex() {
|
||||
const notGeneratedSessions = allSessionsStatus.value.filter((s) => !s.hasIndex)
|
||||
if (notGeneratedSessions.length === 0) return
|
||||
|
||||
isBatchGenerating.value = true
|
||||
batchProgress.value = { current: 0, total: notGeneratedSessions.length, currentName: '' }
|
||||
|
||||
// 获取阈值
|
||||
const gapThreshold = sessionGapMinutes.value * 60
|
||||
|
||||
for (let i = 0; i < notGeneratedSessions.length; i++) {
|
||||
const session = notGeneratedSessions[i]
|
||||
batchProgress.value = {
|
||||
current: i,
|
||||
total: notGeneratedSessions.length,
|
||||
currentName: session.name,
|
||||
}
|
||||
|
||||
try {
|
||||
const count = await window.sessionApi.generate(session.id, gapThreshold)
|
||||
// 更新状态
|
||||
const statusItem = allSessionsStatus.value.find((s) => s.id === session.id)
|
||||
if (statusItem) {
|
||||
statusItem.hasIndex = true
|
||||
statusItem.sessionCount = count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`生成会话 ${session.name} 索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
batchProgress.value = {
|
||||
current: notGeneratedSessions.length,
|
||||
total: notGeneratedSessions.length,
|
||||
currentName: '',
|
||||
}
|
||||
isBatchGenerating.value = false
|
||||
}
|
||||
|
||||
// 批量重新生成所有会话的索引
|
||||
async function batchRegenerateAll() {
|
||||
if (allSessionsStatus.value.length === 0) return
|
||||
|
||||
isBatchGenerating.value = true
|
||||
batchProgress.value = { current: 0, total: allSessionsStatus.value.length, currentName: '' }
|
||||
|
||||
// 获取阈值
|
||||
const gapThreshold = sessionGapMinutes.value * 60
|
||||
|
||||
for (let i = 0; i < allSessionsStatus.value.length; i++) {
|
||||
const session = allSessionsStatus.value[i]
|
||||
batchProgress.value = {
|
||||
current: i,
|
||||
total: allSessionsStatus.value.length,
|
||||
currentName: session.name,
|
||||
}
|
||||
|
||||
try {
|
||||
const count = await window.sessionApi.generate(session.id, gapThreshold)
|
||||
// 更新状态
|
||||
session.hasIndex = true
|
||||
session.sessionCount = count
|
||||
} catch (error) {
|
||||
console.error(`生成会话 ${session.name} 索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
batchProgress.value = {
|
||||
current: allSessionsStatus.value.length,
|
||||
total: allSessionsStatus.value.length,
|
||||
currentName: '',
|
||||
}
|
||||
isBatchGenerating.value = false
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadSessionThreshold()
|
||||
loadSessionIndexStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 会话索引配置 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UIcon name="i-heroicons-clock" class="h-4 w-4 text-blue-500" />
|
||||
{{ t('settings.storage.session.title') }}
|
||||
</h3>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.storage.session.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 刷新按钮 -->
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:loading="isLoadingSessionStatus"
|
||||
@click="loadSessionIndexStatus"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 默认阈值设置 -->
|
||||
<div class="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<div>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('settings.storage.session.defaultThreshold') }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-400">
|
||||
{{ t('settings.storage.session.thresholdHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model.number="sessionGapMinutes"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
size="xs"
|
||||
class="w-20"
|
||||
@blur="saveSessionThreshold"
|
||||
/>
|
||||
<span class="text-xs text-gray-500">{{ t('settings.storage.session.thresholdUnit') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会话索引统计 -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('settings.storage.session.batchTitle') }}
|
||||
</span>
|
||||
<div v-if="!isLoadingSessionStatus" class="mt-1 flex items-center gap-3 text-xs">
|
||||
<span class="text-gray-500">
|
||||
{{ t('settings.storage.session.totalSessions', { count: sessionIndexStats.total }) }}
|
||||
</span>
|
||||
<span class="text-green-600 dark:text-green-400">
|
||||
{{ t('settings.storage.session.generatedCount', { count: sessionIndexStats.generated }) }}
|
||||
</span>
|
||||
<span v-if="sessionIndexStats.notGenerated > 0" class="text-amber-600 dark:text-amber-400">
|
||||
{{ t('settings.storage.session.notGeneratedCount', { count: sessionIndexStats.notGenerated }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="mt-1 flex items-center gap-1 text-xs text-gray-400">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-3 w-3 animate-spin" />
|
||||
{{ t('settings.storage.session.loadingStatus') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
v-if="sessionIndexStats.notGenerated > 0"
|
||||
size="xs"
|
||||
color="primary"
|
||||
:loading="isBatchGenerating"
|
||||
:disabled="isLoadingSessionStatus"
|
||||
@click="batchGenerateIndex"
|
||||
>
|
||||
<UIcon v-if="!isBatchGenerating" name="i-heroicons-sparkles" class="mr-1 h-3 w-3" />
|
||||
{{ t('settings.storage.session.batchGenerate') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="soft"
|
||||
:loading="isBatchGenerating"
|
||||
:disabled="isLoadingSessionStatus || sessionIndexStats.total === 0"
|
||||
@click="batchRegenerateAll"
|
||||
>
|
||||
<UIcon v-if="!isBatchGenerating" name="i-heroicons-arrow-path" class="mr-1 h-3 w-3" />
|
||||
{{ t('settings.storage.session.batchRegenerate') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量生成进度 -->
|
||||
<div v-if="isBatchGenerating" class="mt-3 space-y-2">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500">
|
||||
{{ t('settings.storage.session.generating') }} {{ batchProgress.currentName }}
|
||||
</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ batchProgress.current }}/{{ batchProgress.total }} ({{ batchProgressPercent }}%)
|
||||
</span>
|
||||
</div>
|
||||
<UProgress :value="batchProgressPercent" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 存储管理区块
|
||||
* 显示本地缓存目录信息及清理功能
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 缓存目录信息类型
|
||||
interface CacheDirectoryInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
icon: string
|
||||
canClear: boolean
|
||||
size: number
|
||||
fileCount: number
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
interface CacheInfo {
|
||||
baseDir: string
|
||||
directories: CacheDirectoryInfo[]
|
||||
totalSize: number
|
||||
}
|
||||
|
||||
// 状态
|
||||
const cacheInfo = ref<CacheInfo | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const clearingId = ref<string | null>(null)
|
||||
|
||||
// 格式化文件大小
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)
|
||||
return `${size} ${units[i]}`
|
||||
}
|
||||
|
||||
// 计算总大小
|
||||
const totalSizeFormatted = computed(() => {
|
||||
if (!cacheInfo.value) return '0 B'
|
||||
return formatSize(cacheInfo.value.totalSize)
|
||||
})
|
||||
|
||||
// 加载缓存信息
|
||||
async function loadCacheInfo() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
cacheInfo.value = await window.cacheApi.getInfo()
|
||||
} catch (error) {
|
||||
console.error('获取缓存信息失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清理缓存
|
||||
async function clearCache(cacheId: string) {
|
||||
clearingId.value = cacheId
|
||||
try {
|
||||
const result = await window.cacheApi.clear(cacheId)
|
||||
if (result.success) {
|
||||
// 刷新缓存信息
|
||||
await loadCacheInfo()
|
||||
} else {
|
||||
console.error('清理缓存失败:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理缓存失败:', error)
|
||||
} finally {
|
||||
clearingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 打开目录
|
||||
async function openDirectory(cacheId: string) {
|
||||
try {
|
||||
await window.cacheApi.openDir(cacheId)
|
||||
} catch (error) {
|
||||
console.error('打开目录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadCacheInfo()
|
||||
})
|
||||
|
||||
// 暴露刷新方法
|
||||
defineExpose({
|
||||
refresh: loadCacheInfo,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 标题和总览 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UIcon name="i-heroicons-folder-open" class="h-4 w-4 text-amber-500" />
|
||||
{{ t('settings.storage.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('settings.storage.description') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 总大小 -->
|
||||
<div class="rounded-lg bg-gray-100 px-3 py-1.5 dark:bg-gray-800">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('settings.storage.totalUsage') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ totalSizeFormatted }}</span>
|
||||
</div>
|
||||
<!-- 刷新按钮 -->
|
||||
<UButton icon="i-heroicons-arrow-path" variant="ghost" size="sm" :loading="isLoading" @click="loadCacheInfo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoading && !cacheInfo" class="flex items-center justify-center py-8">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span class="ml-2 text-sm text-gray-500">{{ t('settings.storage.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 缓存目录列表 -->
|
||||
<div v-else-if="cacheInfo" class="space-y-2">
|
||||
<div
|
||||
v-for="dir in cacheInfo.directories"
|
||||
:key="dir.id"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2.5 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 左侧信息 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
:class="{
|
||||
'bg-green-100 dark:bg-green-900/30': dir.id === 'databases',
|
||||
'bg-violet-100 dark:bg-violet-900/30': dir.id === 'ai',
|
||||
'bg-amber-100 dark:bg-amber-900/30': dir.id === 'downloads',
|
||||
'bg-blue-100 dark:bg-blue-900/30': dir.id === 'logs',
|
||||
}"
|
||||
>
|
||||
<UIcon
|
||||
:name="dir.icon"
|
||||
class="h-4 w-4"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': dir.id === 'databases',
|
||||
'text-violet-600 dark:text-violet-400': dir.id === 'ai',
|
||||
'text-amber-600 dark:text-amber-400': dir.id === 'downloads',
|
||||
'text-blue-600 dark:text-blue-400': dir.id === 'logs',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t(dir.name) }}</h4>
|
||||
<UBadge v-if="!dir.exists" variant="soft" color="gray" size="xs">
|
||||
{{ t('settings.storage.notExist') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t(dir.description) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧信息和操作按钮 -->
|
||||
<div class="flex items-center">
|
||||
<!-- 文件数和大小(固定宽度对齐) -->
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span class="w-14 text-right">{{ dir.fileCount }} {{ t('settings.storage.files') }}</span>
|
||||
<span class="w-16 text-right">{{ formatSize(dir.size) }}</span>
|
||||
</div>
|
||||
<!-- 操作按钮(固定宽度) -->
|
||||
<div class="ml-4 flex w-36 shrink-0 items-center justify-end gap-1">
|
||||
<UButton
|
||||
v-if="dir.canClear && dir.size > 0"
|
||||
icon="i-heroicons-trash"
|
||||
variant="soft"
|
||||
color="red"
|
||||
size="xs"
|
||||
:loading="clearingId === dir.id"
|
||||
:disabled="clearingId !== null"
|
||||
@click="clearCache(dir.id)"
|
||||
>
|
||||
{{ t('settings.storage.clear') }}
|
||||
</UButton>
|
||||
<UButton icon="i-heroicons-folder-open" variant="ghost" size="xs" @click="openDirectory(dir.id)">
|
||||
{{ t('settings.storage.open') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<div class="text-xs text-amber-700 dark:text-amber-400">
|
||||
<p class="font-medium">{{ t('settings.storage.notes.title') }}</p>
|
||||
<ul class="mt-1 list-inside list-disc space-y-0.5 text-amber-600 dark:text-amber-500">
|
||||
<li>{{ t('settings.storage.notes.logSafe') }}</li>
|
||||
<li>{{ t('settings.storage.notes.noRecover') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,207 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
/**
|
||||
* 数据和存储设置 Tab
|
||||
* 包含存储管理和会话管理两个子 Tab
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import StorageManageSection from './DataStorage/StorageManageSection.vue'
|
||||
import SessionIndexSection from './DataStorage/SessionIndexSection.vue'
|
||||
import SubTabs from '@/components/UI/SubTabs.vue'
|
||||
import { useSubTabsScroll } from '@/composables/useSubTabsScroll'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 缓存目录信息类型
|
||||
interface CacheDirectoryInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
icon: string
|
||||
canClear: boolean
|
||||
size: number
|
||||
fileCount: number
|
||||
exists: boolean
|
||||
}
|
||||
// 导航配置
|
||||
const navItems = computed(() => [
|
||||
{ id: 'storage', label: t('settings.tabs.storageManage') },
|
||||
{ id: 'session', label: t('settings.tabs.sessionManage') },
|
||||
])
|
||||
|
||||
interface CacheInfo {
|
||||
baseDir: string
|
||||
directories: CacheDirectoryInfo[]
|
||||
totalSize: number
|
||||
}
|
||||
// 使用二级导航滚动联动 composable
|
||||
const { activeNav, scrollContainerRef, setSectionRef, handleNavChange } = useSubTabsScroll(navItems)
|
||||
void scrollContainerRef // 在模板中通过 ref="scrollContainerRef" 使用
|
||||
|
||||
// 状态
|
||||
const cacheInfo = ref<CacheInfo | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const clearingId = ref<string | null>(null)
|
||||
|
||||
// 格式化文件大小
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)
|
||||
return `${size} ${units[i]}`
|
||||
}
|
||||
|
||||
// 计算总大小
|
||||
const totalSizeFormatted = computed(() => {
|
||||
if (!cacheInfo.value) return '0 B'
|
||||
return formatSize(cacheInfo.value.totalSize)
|
||||
})
|
||||
|
||||
// 加载缓存信息
|
||||
async function loadCacheInfo() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
cacheInfo.value = await window.cacheApi.getInfo()
|
||||
} catch (error) {
|
||||
console.error('获取缓存信息失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清理缓存
|
||||
async function clearCache(cacheId: string) {
|
||||
clearingId.value = cacheId
|
||||
try {
|
||||
const result = await window.cacheApi.clear(cacheId)
|
||||
if (result.success) {
|
||||
// 刷新缓存信息
|
||||
await loadCacheInfo()
|
||||
} else {
|
||||
console.error('清理缓存失败:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理缓存失败:', error)
|
||||
} finally {
|
||||
clearingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 打开目录
|
||||
async function openDirectory(cacheId: string) {
|
||||
try {
|
||||
await window.cacheApi.openDir(cacheId)
|
||||
} catch (error) {
|
||||
console.error('打开目录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadCacheInfo()
|
||||
})
|
||||
// Template refs
|
||||
const storageManageRef = ref<InstanceType<typeof StorageManageSection> | null>(null)
|
||||
|
||||
// 暴露刷新方法
|
||||
defineExpose({
|
||||
refresh: loadCacheInfo,
|
||||
refresh: () => storageManageRef.value?.refresh(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 标题和总览 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UIcon name="i-heroicons-folder-open" class="h-4 w-4 text-amber-500" />
|
||||
{{ t('settings.storage.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('settings.storage.description') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 总大小 -->
|
||||
<div class="rounded-lg bg-gray-100 px-3 py-1.5 dark:bg-gray-800">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('settings.storage.totalUsage') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ totalSizeFormatted }}</span>
|
||||
<div class="flex h-full gap-6">
|
||||
<!-- 左侧锚点导航 -->
|
||||
<div class="w-28 shrink-0">
|
||||
<SubTabs v-model="activeNav" :items="navItems" orientation="vertical" @change="handleNavChange" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容区域 -->
|
||||
<div ref="scrollContainerRef" class="min-w-0 flex-1 overflow-y-auto">
|
||||
<div class="space-y-8">
|
||||
<!-- 存储管理 -->
|
||||
<div :ref="(el) => setSectionRef('storage', el as HTMLElement)">
|
||||
<StorageManageSection ref="storageManageRef" />
|
||||
</div>
|
||||
<!-- 刷新按钮 -->
|
||||
<UButton icon="i-heroicons-arrow-path" variant="ghost" size="sm" :loading="isLoading" @click="loadCacheInfo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoading && !cacheInfo" class="flex items-center justify-center py-8">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span class="ml-2 text-sm text-gray-500">{{ t('settings.storage.loading') }}</span>
|
||||
</div>
|
||||
<!-- 分隔线 -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<!-- 缓存目录列表 -->
|
||||
<div v-else-if="cacheInfo" class="space-y-2">
|
||||
<div
|
||||
v-for="dir in cacheInfo.directories"
|
||||
:key="dir.id"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2.5 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 左侧信息 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
:class="{
|
||||
'bg-green-100 dark:bg-green-900/30': dir.id === 'databases',
|
||||
'bg-violet-100 dark:bg-violet-900/30': dir.id === 'ai',
|
||||
'bg-amber-100 dark:bg-amber-900/30': dir.id === 'downloads',
|
||||
'bg-blue-100 dark:bg-blue-900/30': dir.id === 'logs',
|
||||
}"
|
||||
>
|
||||
<UIcon
|
||||
:name="dir.icon"
|
||||
class="h-4 w-4"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': dir.id === 'databases',
|
||||
'text-violet-600 dark:text-violet-400': dir.id === 'ai',
|
||||
'text-amber-600 dark:text-amber-400': dir.id === 'downloads',
|
||||
'text-blue-600 dark:text-blue-400': dir.id === 'logs',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t(dir.name) }}</h4>
|
||||
<UBadge v-if="!dir.exists" variant="soft" color="gray" size="xs">
|
||||
{{ t('settings.storage.notExist') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t(dir.description) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧信息和操作按钮 -->
|
||||
<div class="flex items-center">
|
||||
<!-- 文件数和大小(固定宽度对齐) -->
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span class="w-14 text-right">{{ dir.fileCount }} {{ t('settings.storage.files') }}</span>
|
||||
<span class="w-16 text-right">{{ formatSize(dir.size) }}</span>
|
||||
</div>
|
||||
<!-- 操作按钮(固定宽度) -->
|
||||
<div class="ml-4 flex w-36 shrink-0 items-center justify-end gap-1">
|
||||
<UButton
|
||||
v-if="dir.canClear && dir.size > 0"
|
||||
icon="i-heroicons-trash"
|
||||
variant="soft"
|
||||
color="red"
|
||||
size="xs"
|
||||
:loading="clearingId === dir.id"
|
||||
:disabled="clearingId !== null"
|
||||
@click="clearCache(dir.id)"
|
||||
>
|
||||
{{ t('settings.storage.clear') }}
|
||||
</UButton>
|
||||
<UButton icon="i-heroicons-folder-open" variant="ghost" size="xs" @click="openDirectory(dir.id)">
|
||||
{{ t('settings.storage.open') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<div class="text-xs text-amber-700 dark:text-amber-400">
|
||||
<p class="font-medium">{{ t('settings.storage.notes.title') }}</p>
|
||||
<ul class="mt-1 list-inside list-disc space-y-0.5 text-amber-600 dark:text-amber-500">
|
||||
<li>{{ t('settings.storage.notes.logSafe') }}</li>
|
||||
<li>{{ t('settings.storage.notes.noRecover') }}</li>
|
||||
</ul>
|
||||
<!-- 会话管理 -->
|
||||
<div :ref="(el) => setSectionRef('session', el as HTMLElement)">
|
||||
<SessionIndexSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ref, computed, onMounted, onUnmounted, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
/**
|
||||
* 导航项配置
|
||||
*/
|
||||
export interface SubTabNavItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级导航滚动联动 composable
|
||||
* 实现左侧锚点导航与右侧内容区域的滚动联动
|
||||
*/
|
||||
export function useSubTabsScroll(navItems: ComputedRef<SubTabNavItem[]> | Ref<SubTabNavItem[]>) {
|
||||
// 当前激活的导航项
|
||||
const activeNav = ref(navItems.value[0]?.id || '')
|
||||
|
||||
// 是否由用户点击触发(用于区分点击滚动和手动滚动)
|
||||
const isUserClick = ref(false)
|
||||
|
||||
// 滚动容器引用
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Section 引用
|
||||
const sectionRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
|
||||
/**
|
||||
* 设置 section 引用
|
||||
*/
|
||||
function setSectionRef(id: string, el: HTMLElement | null) {
|
||||
sectionRefs.value[id] = el
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导航点击(通过 @change 事件)
|
||||
*/
|
||||
function handleNavChange(id: string) {
|
||||
const section = sectionRefs.value[id]
|
||||
if (section && scrollContainerRef.value) {
|
||||
// 标记为用户点击触发
|
||||
isUserClick.value = true
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
// 滚动动画结束后恢复
|
||||
setTimeout(() => {
|
||||
isUserClick.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听滚动更新当前激活项
|
||||
*/
|
||||
function handleScroll() {
|
||||
// 如果是用户点击触发的滚动,不更新 activeNav(避免冲突)
|
||||
if (isUserClick.value || !scrollContainerRef.value) return
|
||||
|
||||
const container = scrollContainerRef.value
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const offset = 50 // 偏移量,提前触发
|
||||
|
||||
// 检查是否滚动到底部(误差范围 5px)
|
||||
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 5
|
||||
if (isAtBottom) {
|
||||
// 滚动到底部时,激活最后一个导航项
|
||||
const lastItem = navItems.value[navItems.value.length - 1]
|
||||
if (lastItem) {
|
||||
activeNav.value = lastItem.id
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 检查每个 section 的位置
|
||||
for (const item of navItems.value) {
|
||||
const section = sectionRefs.value[item.id]
|
||||
if (section) {
|
||||
const rect = section.getBoundingClientRect()
|
||||
// 如果 section 顶部在容器可视区域内
|
||||
if (rect.top <= containerRect.top + offset && rect.bottom > containerRect.top + offset) {
|
||||
activeNav.value = item.id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
scrollContainerRef.value?.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
scrollContainerRef.value?.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
return {
|
||||
activeNav,
|
||||
scrollContainerRef,
|
||||
sectionRefs,
|
||||
setSectionRef,
|
||||
handleNavChange,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"aiConfig": "AI Models",
|
||||
"aiPrompt": "Chat Config",
|
||||
"aiPreset": "Prompts",
|
||||
"storage": "Storage",
|
||||
"storage": "Data & Storage",
|
||||
"storageManage": "Storage",
|
||||
"sessionManage": "Sessions",
|
||||
"about": "About"
|
||||
},
|
||||
"basic": {
|
||||
@@ -129,6 +131,29 @@
|
||||
"name": "Log Files",
|
||||
"description": "App logs including import, AI, error logs"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"title": "Session Index Settings",
|
||||
"description": "Session index splits chat history into conversation segments by time gaps for better AI analysis",
|
||||
"defaultThreshold": "Default Gap Threshold",
|
||||
"thresholdUnit": "minutes",
|
||||
"thresholdHelp": "Messages exceeding this time gap will be split into a new session",
|
||||
"notGenerated": "Session index not generated",
|
||||
"generateHint": "Generate index to help AI better understand conversation context",
|
||||
"generate": "Generate Index",
|
||||
"regenerate": "Regenerate",
|
||||
"generating": "Generating",
|
||||
"sessionCount": "{count} sessions",
|
||||
"generated": "Session index generated",
|
||||
"generateSuccess": "Session index generated successfully, {count} sessions created",
|
||||
"generateError": "Generation failed",
|
||||
"batchTitle": "Batch Generate Index",
|
||||
"totalSessions": "{count} chats total",
|
||||
"generatedCount": "{count} generated",
|
||||
"notGeneratedCount": "{count} not generated",
|
||||
"loadingStatus": "Loading...",
|
||||
"batchGenerate": "Generate Missing",
|
||||
"batchRegenerate": "Regenerate All"
|
||||
}
|
||||
},
|
||||
"aiPrompt": {
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"aiConfig": "模型配置",
|
||||
"aiPrompt": "对话配置",
|
||||
"aiPreset": "提示词配置",
|
||||
"storage": "存储管理",
|
||||
"storage": "数据和存储",
|
||||
"storageManage": "存储管理",
|
||||
"sessionManage": "会话管理",
|
||||
"about": "关于"
|
||||
},
|
||||
"basic": {
|
||||
@@ -129,6 +131,29 @@
|
||||
"name": "日志文件",
|
||||
"description": "软件的运行日志,包含导入、AI、错误等日志"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"title": "会话索引设置",
|
||||
"description": "会话索引将聊天记录按时间间隔自动分割为对话段落,便于AI分析和浏览",
|
||||
"defaultThreshold": "默认分割间隔",
|
||||
"thresholdUnit": "分钟",
|
||||
"thresholdHelp": "超过该时间间隔的消息将被分到新的会话段落",
|
||||
"notGenerated": "尚未生成会话索引",
|
||||
"generateHint": "生成索引后可让 AI 更精准地理解对话上下文",
|
||||
"generate": "生成索引",
|
||||
"regenerate": "重新生成",
|
||||
"generating": "正在生成",
|
||||
"sessionCount": "{count} 个会话",
|
||||
"generated": "已生成会话索引",
|
||||
"generateSuccess": "会话索引生成成功,共 {count} 个会话",
|
||||
"generateError": "生成失败",
|
||||
"batchTitle": "批量生成索引",
|
||||
"totalSessions": "共 {count} 个聊天",
|
||||
"generatedCount": "已生成 {count} 个",
|
||||
"notGeneratedCount": "未生成 {count} 个",
|
||||
"loadingStatus": "加载中...",
|
||||
"batchGenerate": "生成未索引",
|
||||
"batchRegenerate": "全部重新生成"
|
||||
}
|
||||
},
|
||||
"aiPrompt": {
|
||||
|
||||
Reference in New Issue
Block a user