feat: 优化设置弹窗弹出方式

This commit is contained in:
digua
2026-04-24 21:14:15 +08:00
committed by digua
parent c3e165409a
commit ad62f9e727
29 changed files with 243 additions and 34 deletions
+21 -1
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import TitleBar from '@/components/common/TitleBar.vue'
import Sidebar from '@/components/common/Sidebar.vue'
import ScreenCaptureModal from '@/components/common/ScreenCaptureModal.vue'
import SettingsModal from '@/components/common/SettingsModal.vue'
import { ChatRecordDrawer } from '@/components/common/ChatRecord'
import GlobalTaskBar from '@/components/AIChat/GlobalTaskBar.vue'
import { useSessionStore } from '@/stores/session'
@@ -32,8 +33,21 @@ const toaster = {
duration: 2000,
}
// Cmd+, (macOS) / Ctrl+, (Windows/Linux) 打开设置
function handleGlobalKeydown(e: KeyboardEvent) {
const isMeta = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
if (isMeta && e.key === ',') {
e.preventDefault()
e.stopPropagation()
if (!layoutStore.showSettings) {
layoutStore.openSettings()
}
}
}
// 应用启动时初始化
onMounted(async () => {
window.addEventListener('keydown', handleGlobalKeydown)
// 平台检测 - 设置 CSS 类名以驱动平台差异化样式(如标题栏安全区域高度)
const platform = navigator.platform.toLowerCase()
if (platform.includes('win')) {
@@ -49,6 +63,10 @@ onMounted(async () => {
// 从数据库加载会话列表
await sessionStore.loadSessions()
})
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown)
})
</script>
<template>
@@ -81,6 +99,8 @@ onMounted(async () => {
:image-data="layoutStore.screenCaptureImage"
@update:open="(v) => (v ? null : layoutStore.closeScreenCaptureModal())"
/>
<!-- 全局设置弹窗 -->
<SettingsModal />
<!-- 全局聊天记录查看器 -->
<ChatRecordDrawer />
<!-- 全局 AI 后台任务条允许用户离开当前页面后仍然快速返回进行中的对话 -->
+4 -4
View File
@@ -3,15 +3,15 @@ import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useToast } from '@/composables/useToast'
import { useRouter } from 'vue-router'
import { usePromptStore } from '@/stores/prompt'
import { useLayoutStore } from '@/stores/layout'
import { useLLMStore } from '@/stores/llm'
import { exportConversation, type ExportFormat } from '@/utils/conversationExport'
import type { AgentRuntimeStatus } from '@electron/shared/types'
const { t } = useI18n()
const toast = useToast()
const router = useRouter()
const layoutStore = useLayoutStore()
// Props
const props = defineProps<{
@@ -86,7 +86,7 @@ const agentCompactTitle = computed(() => {
})
function openChatSettings() {
router.push({ name: 'settings', query: { tab: 'ai', subTab: 'chat' } })
layoutStore.openSettings('ai', 'chat')
}
// 切换 AI 模型配置
@@ -101,7 +101,7 @@ async function switchModelConfig(configId: string) {
function openModelSettings() {
isModelPopoverOpen.value = false
router.push({ name: 'settings', query: { tab: 'ai', subTab: 'model' } })
layoutStore.openSettings('ai', 'model')
}
// 导出当前对话
@@ -67,7 +67,11 @@ function closeModal() {
</script>
<template>
<UModal :open="open" :ui="{ content: 'max-w-2xl' }" @update:open="emit('update:open', $event)">
<UModal
:open="open"
:ui="{ content: 'max-w-2xl z-[101]', overlay: 'z-[100]' }"
@update:open="emit('update:open', $event)"
>
<template #content>
<div class="flex min-h-[min(640px,80vh)] max-h-[80vh] flex-col p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ modalTitle }}</h3>
@@ -419,7 +423,11 @@ function closeModal() {
</UModal>
<!-- 验证失败确认弹窗 -->
<UModal :open="showValidationFailConfirm" @update:open="showValidationFailConfirm = $event">
<UModal
:open="showValidationFailConfirm"
:ui="{ content: 'z-[102]', overlay: 'z-[101]' }"
@update:open="showValidationFailConfirm = $event"
>
<template #content>
<div class="p-6">
<div class="mb-4 flex items-start gap-3">
@@ -445,7 +453,11 @@ function closeModal() {
</UModal>
<!-- 添加自定义模型小弹窗 -->
<UModal :open="showAddModelDialog" @update:open="showAddModelDialog = $event">
<UModal
:open="showAddModelDialog"
:ui="{ content: 'z-[102]', overlay: 'z-[101]' }"
@update:open="showAddModelDialog = $event"
>
<template #content>
<div class="p-5">
<h4 class="mb-4 text-base font-semibold text-gray-900 dark:text-white">
@@ -133,7 +133,7 @@ function formatMessageCount(count?: number): string {
</script>
<template>
<UModal :open="open" @update:open="emit('update:open', $event)">
<UModal :open="open" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }" @update:open="emit('update:open', $event)">
<template #content>
<div class="p-6" style="min-width: 480px; max-height: 80vh; overflow-y: auto">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
@@ -48,7 +48,7 @@ function save() {
</script>
<template>
<UModal :open="open" @update:open="emit('update:open', $event)">
<UModal :open="open" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }" @update:open="emit('update:open', $event)">
<template #content>
<div class="p-6" style="min-width: 420px">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
@@ -468,7 +468,7 @@ function subscribedRemoteIds(ds: DataSource): Set<string> {
/>
<!-- Delete confirmation modal -->
<UModal v-model:open="showDeleteModal">
<UModal v-model:open="showDeleteModal" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }">
<template #content>
<div class="p-4">
<h3 class="mb-3 font-semibold text-gray-900 dark:text-white">
@@ -710,7 +710,7 @@ onMounted(() => {
</div>
<!-- 合并确认弹窗 -->
<UModal v-model:open="showMergeModal" :ui="{ content: 'z-100' }">
<UModal v-model:open="showMergeModal" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }">
<template #content>
<div class="p-4">
<div class="mb-4 flex items-center gap-3">
@@ -781,7 +781,7 @@ onMounted(() => {
</UModal>
<!-- 删除确认弹窗 -->
<UModal v-model:open="showDeleteModal">
<UModal v-model:open="showDeleteModal" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }">
<template #content>
<div class="p-4">
<div class="mb-4 flex items-center gap-3">
@@ -2,4 +2,3 @@ export { default as AISettingsTab } from './AISettingsTab.vue'
export { default as AIModelConfigTab } from './AI/AIModelConfigTab.vue'
export { default as AIModelEditModal } from './AI/AIModelEditModal.vue'
export { default as AIPromptConfigTab } from './AI/AIPromptConfigTab.vue'
export { default as CacheManageTab } from './CacheManageTab.vue'
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ref, watch, computed, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import AISettingsTab from './Settings/AISettingsTab.vue'
import BasicSettingsTab from './Settings/BasicSettingsTab.vue'
import BatchManageTab from './Settings/BatchManageTab.vue'
import StorageTab from './Settings/StorageTab.vue'
import AboutTab from './Settings/AboutTab.vue'
import ApiSettingsTab from './Settings/ApiSettingsTab.vue'
import { usePromptStore } from '@/stores/prompt'
import { useLayoutStore } from '@/stores/layout'
const { t } = useI18n()
const promptStore = usePromptStore()
const layoutStore = useLayoutStore()
const { showSettings, settingsTab, settingsSubTab } = storeToRefs(layoutStore)
interface ScrollableTab {
scrollToSection?: (sectionId: string) => void
refresh?: () => void
}
const tabs = computed(() => [
{ id: 'settings', label: t('settings.tabs.basic'), icon: 'i-heroicons-cog-6-tooth' },
{ id: 'ai', label: t('settings.tabs.ai'), icon: 'i-heroicons-sparkles' },
{ id: 'data', label: t('settings.tabs.dataManage'), icon: 'i-heroicons-rectangle-stack' },
{ id: 'api', label: t('settings.tabs.api'), icon: 'i-heroicons-server-stack' },
{ id: 'storage', label: t('settings.tabs.storage'), icon: 'i-heroicons-folder-open' },
{ id: 'about', label: t('settings.tabs.about'), icon: 'i-heroicons-information-circle' },
])
const activeTab = ref('settings')
const tabRefs = ref<Record<string, ScrollableTab | null>>({})
function setTabRef(tabId: string, el: unknown) {
tabRefs.value[tabId] = el as ScrollableTab | null
}
function handleAIConfigChanged() {
promptStore.notifyAIConfigChanged()
}
function switchTab(tabId: string) {
activeTab.value = tabId
nextTick(() => {
tabRefs.value[tabId]?.refresh?.()
})
}
function scrollToSubTab(subTab: string) {
const tabRef = tabRefs.value[activeTab.value]
if (tabRef?.scrollToSection) {
tabRef.scrollToSection(subTab)
}
}
watch(showSettings, async (visible) => {
if (visible) {
activeTab.value = settingsTab.value || 'settings'
if (settingsSubTab.value) {
await nextTick()
setTimeout(() => scrollToSubTab(settingsSubTab.value!), 100)
}
nextTick(() => {
tabRefs.value[activeTab.value]?.refresh?.()
})
}
})
</script>
<template>
<UModal v-model:open="showSettings" :ui="{ content: 'sm:max-w-[900px] z-[100]', overlay: 'backdrop-blur-sm z-[99]' }">
<template #content>
<div class="flex min-h-[650px] h-[85vh] flex-col overflow-hidden">
<!-- Header -->
<div class="shrink-0 border-b border-gray-200 px-6 pt-5 pb-0 dark:border-gray-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600 dark:bg-primary-500">
<UIcon name="i-heroicons-cog-6-tooth" class="h-4 w-4 text-white" />
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('settings.title') }}
</h2>
</div>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="sm"
@click="layoutStore.closeSettings()"
/>
</div>
<!-- Tabs -->
<div class="mt-4 flex items-center gap-1 overflow-x-auto pb-3 scrollbar-hide">
<button
v-for="tab in tabs"
:key="tab.id"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-all"
:class="[
activeTab === tab.id
? 'bg-pink-500 text-white dark:bg-pink-900/30 dark:text-pink-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800',
]"
@click="switchTab(tab.id)"
>
<UIcon :name="tab.icon" class="h-4 w-4" />
<span class="whitespace-nowrap">{{ tab.label }}</span>
</button>
</div>
</div>
<div class="relative flex-1">
<div class="absolute inset-0 p-6">
<Transition name="tab-slide" mode="out-in">
<div v-if="activeTab === 'settings'" key="settings" class="h-full overflow-y-auto">
<BasicSettingsTab />
</div>
<AISettingsTab
v-else-if="activeTab === 'ai'"
key="ai"
:ref="(el: unknown) => setTabRef('ai', el)"
@config-changed="handleAIConfigChanged"
/>
<BatchManageTab v-else-if="activeTab === 'data'" key="data" />
<ApiSettingsTab v-else-if="activeTab === 'api'" key="api" />
<StorageTab
v-else-if="activeTab === 'storage'"
key="storage"
:ref="(el: unknown) => setTabRef('storage', el)"
/>
<div v-else-if="activeTab === 'about'" key="about" class="h-full overflow-y-auto">
<AboutTab />
</div>
</Transition>
</div>
</div>
</div>
</template>
</UModal>
</template>
<style scoped>
.tab-slide-enter-active,
.tab-slide-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.tab-slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.tab-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
+1 -1
View File
@@ -259,7 +259,7 @@ function getSessionAvatar(session: AnalysisSession): string | null {
color="neutral"
variant="ghost"
size="xs"
@click="router.push({ name: 'settings', query: { tab: 'data' } })"
@click="layoutStore.openSettings('data')"
/>
</UTooltip>
</div>
@@ -1,14 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter, useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useLayoutStore } from '@/stores/layout'
import SidebarButton from './SidebarButton.vue'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const isSettingsPage = computed(() => route.name === 'settings')
const layoutStore = useLayoutStore()
const { showSettings } = storeToRefs(layoutStore)
</script>
<template>
@@ -17,8 +15,8 @@ const isSettingsPage = computed(() => route.name === 'settings')
<SidebarButton
icon="i-heroicons-cog-6-tooth"
:title="t('layout.footer.settings')"
:active="isSettingsPage"
@click="router.push({ name: 'settings' })"
:active="showSettings"
@click="layoutStore.openSettings()"
/>
</div>
</template>
+6 -6
View File
@@ -3,12 +3,12 @@ import { ref, watch, onMounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import PageHeader from '@/components/layout/PageHeader.vue'
import AISettingsTab from './components/AISettingsTab.vue'
import BasicSettingsTab from './components/BasicSettingsTab.vue'
import BatchManageTab from './components/BatchManageTab.vue'
import StorageTab from './components/StorageTab.vue'
import AboutTab from './components/AboutTab.vue'
import ApiSettingsTab from './components/ApiSettingsTab.vue'
import AISettingsTab from '@/components/common/Settings/AISettingsTab.vue'
import BasicSettingsTab from '@/components/common/Settings/BasicSettingsTab.vue'
import BatchManageTab from '@/components/common/Settings/BatchManageTab.vue'
import StorageTab from '@/components/common/Settings/StorageTab.vue'
import AboutTab from '@/components/common/Settings/AboutTab.vue'
import ApiSettingsTab from '@/components/common/Settings/ApiSettingsTab.vue'
import { usePromptStore } from '@/stores/prompt'
const { t } = useI18n()
-5
View File
@@ -17,11 +17,6 @@ export const router = createRouter({
name: 'private-chat',
component: () => import('@/pages/private-chat/index.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('@/pages/settings/index.vue'),
},
],
history: createWebHashHistory(),
})
+23
View File
@@ -20,6 +20,11 @@ export const useLayoutStore = defineStore(
// 截图设置
const screenshotMobileAdapt = ref(false) // 截图时开启移动端适配,默认关闭
// 设置弹窗
const showSettings = ref(false)
const settingsTab = ref<string>('settings')
const settingsSubTab = ref<string | null>(null)
/**
* 切换侧边栏展开/折叠状态
*/
@@ -67,6 +72,19 @@ export const useLayoutStore = defineStore(
isToolsPanelLocked.value = !isToolsPanelLocked.value
}
/**
* 打开设置弹窗,可选指定 Tab 和 SubTab
*/
function openSettings(tab?: string, subTab?: string) {
settingsTab.value = tab || 'settings'
settingsSubTab.value = subTab || null
showSettings.value = true
}
function closeSettings() {
showSettings.value = false
}
function toggleToolsPanelMini() {
isToolsPanelMini.value = !isToolsPanelMini.value
if (isToolsPanelMini.value) {
@@ -83,6 +101,9 @@ export const useLayoutStore = defineStore(
showChatRecordDrawer,
chatRecordQuery,
screenshotMobileAdapt,
showSettings,
settingsTab,
settingsSubTab,
toggleSidebar,
toggleToolsPanelLock,
toggleToolsPanelMini,
@@ -90,6 +111,8 @@ export const useLayoutStore = defineStore(
closeScreenCaptureModal,
openChatRecordDrawer,
closeChatRecordDrawer,
openSettings,
closeSettings,
}
},
{