feat: 重构设置弹窗,新增会话索引设置

This commit is contained in:
digua
2026-01-11 15:05:01 +08:00
committed by digua
parent de3aef8f57
commit e04a4fe2d2
8 changed files with 707 additions and 265 deletions
+1 -1
View File
@@ -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>
+37 -187
View File
@@ -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>
+105
View File
@@ -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,
}
}
+26 -1
View File
@@ -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": {
+26 -1
View File
@@ -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": {