feat(ui): optimize page header and toolbar layout

This commit is contained in:
digua committed 2026-05-31 16:56:08 +08:00
1 parent 066564c64e
commit d1637b41b8
14 files changed
+197 -160

No files matched your search

+5 -1
View File
@@ -26,10 +26,13 @@ const props = withDefaults(
targetSelector?: string
/** 是否应用 Markdown 列表渲染兼容修复(仅截取 Markdown 内容时传 true) */
markdownFix?: boolean
/** 按钮颜色,默认 primary */
color?: string
}>(),
{
size: 'sm',
type: 'page',
color: 'primary',
}
)
@@ -77,7 +80,8 @@ async function handleCapture(event: Event) {
:id="buttonId"
icon="i-heroicons-camera"
variant="ghost"
color="primary"
:color="color"
:class="color !== 'primary' ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
:size="size"
:loading="isCapturing"
@click="handleCapture"
+14 -9
View File
@@ -240,7 +240,7 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
class="flex h-full flex-col border-r border-gray-200/50 transition-all duration-300 ease-in-out dark:border-gray-800/50"
:class="[isCollapsed ? 'w-20' : 'w-72', isHomePage ? '' : 'bg-gray-50 dark:bg-gray-900']"
>
<div class="flex flex-col p-4 pt-5">
<div class="flex flex-col pt-5" :class="[isCollapsed ? 'px-3 pb-4' : 'p-4']">
<!-- Header -->
<div
class="mb-2 flex items-center"
@@ -256,12 +256,12 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
</div>
<div
v-else
class="group relative flex h-12 w-12 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-gray-800"
class="group relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-gray-800"
style="-webkit-app-region: no-drag"
@click="toggleSidebar"
>
<img :src="logoSvg" alt="ChatLab" class="size-6 select-none pointer-events-none group-hover:hidden" />
<UIcon name="i-lucide-panel-right-open" class="size-5 hidden group-hover:block scale-x-[-1]" />
<img :src="logoSvg" alt="ChatLab" class="size-5 select-none pointer-events-none group-hover:hidden" />
<UIcon name="i-lucide-panel-right-open" class="size-4 hidden group-hover:block scale-x-[-1]" />
</div>
<UTooltip
v-if="!isCollapsed"
@@ -272,18 +272,23 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
<UButton
color="gray"
variant="ghost"
size="md"
class="group flex h-12 w-12 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-gray-800"
size="sm"
class="group flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-gray-800"
@click="toggleSidebar"
>
<UIcon name="i-lucide-panel-right" class="size-5 group-hover:hidden scale-x-[-1]" />
<UIcon name="i-lucide-panel-right-close" class="size-5 hidden group-hover:block scale-x-[-1]" />
<UIcon name="i-lucide-panel-right" class="size-4 group-hover:hidden scale-x-[-1]" />
<UIcon name="i-lucide-panel-right-close" class="size-4 hidden group-hover:block scale-x-[-1]" />
</UButton>
</UTooltip>
</div>
<!-- 新建分析 -->
<SidebarButton icon="i-heroicons-plus" :title="t('layout.newAnalysis')" @click="handleImport" />
<SidebarButton
icon="i-heroicons-plus"
:title="t('layout.newAnalysis')"
:active="isHomePage"
@click="handleImport"
/>
</div>
<!-- Session List -->
+1 -1
View File
@@ -82,7 +82,7 @@ function close() {
height: 32px;
display: flex;
align-items: center;
z-index: 9999;
z-index: 30;
-webkit-app-region: drag;
}
@@ -20,11 +20,23 @@ const { isSidebarCollapsed: isCollapsed } = storeToRefs(layoutStore)
<template>
<UTooltip :text="isCollapsed ? tooltip || title : ''" :popper="{ placement: 'right' }">
<!-- 收起状态:用原生 div 承载背景和尺寸,与会话列表项结构一致,避免 UButton 内部撑大 -->
<div
v-if="isCollapsed"
class="flex h-9 w-9 cursor-pointer items-center justify-center rounded-xl mx-auto transition-all duration-200"
:class="[
active
? 'bg-gray-200/50 dark:bg-gray-800/80 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-200/40 dark:hover:bg-gray-800/40',
]"
>
<UIcon :name="icon" class="h-5 w-5 shrink-0" />
</div>
<!-- 展开状态:保持原有 UButton 样式 -->
<UButton
:block="!isCollapsed"
class="transition-all duration-200 rounded-xl hover:bg-gray-200/40 dark:hover:bg-gray-800/40 h-10 cursor-pointer"
v-else
class="transition-all duration-200 rounded-xl hover:bg-gray-200/40 dark:hover:bg-gray-800/40 h-10 cursor-pointer justify-start pl-1.5 w-[calc(100%-8px)]"
:class="[
isCollapsed ? 'flex w-10 items-center justify-center px-0 mx-auto' : 'justify-start pl-1.5',
active
? 'bg-gray-200/50 dark:bg-gray-800/80 text-gray-900 dark:text-white font-medium'
: 'text-gray-600 dark:text-gray-300',
@@ -32,8 +44,8 @@ const { isSidebarCollapsed: isCollapsed } = storeToRefs(layoutStore)
color="gray"
variant="ghost"
>
<UIcon :name="icon" class="h-5 w-5 shrink-0" :class="[isCollapsed ? '' : 'mr-2.5']" />
<span v-if="!isCollapsed" class="truncate text-xs font-medium">{{ title }}</span>
<UIcon :name="icon" class="mr-2.5 h-5 w-5 shrink-0" />
<span class="truncate text-xs font-medium">{{ title }}</span>
</UButton>
</UTooltip>
</template>
+38 -90
View File
@@ -1,50 +1,15 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useLayoutStore } from '@/stores/layout'
const { t } = useI18n()
const layoutStore = useLayoutStore()
const { isToolsPanelLocked, isToolsPanelMini, toolsPanelPosition, isToolsPanelOpen } = storeToRefs(layoutStore)
const { isToolsPanelMini, toolsPanelPosition, isToolsPanelOpen } = storeToRefs(layoutStore)
const isHeaderMode = computed(() => toolsPanelPosition.value === 'header')
const isHovered = ref(false)
let hideTimer: ReturnType<typeof setTimeout> | null = null
const isVisible = computed(() => {
if (isHeaderMode.value) return isToolsPanelOpen.value
return isToolsPanelLocked.value || isHovered.value
})
function onMouseEnter() {
if (hideTimer) {
clearTimeout(hideTimer)
hideTimer = null
}
if (!isHeaderMode.value) {
isHovered.value = true
}
}
function onMouseLeave() {
if (isHeaderMode.value) {
hideTimer = setTimeout(() => {
isToolsPanelOpen.value = false
}, 200)
return
}
if (isToolsPanelLocked.value) return
hideTimer = setTimeout(() => {
isHovered.value = false
}, 200)
}
watch(toolsPanelPosition, () => {
isToolsPanelOpen.value = false
isHovered.value = false
})
const isPanelVisible = computed(() => !isHeaderMode.value || isToolsPanelOpen.value)
type ToolEvent =
| 'openIncrementalImport'
@@ -61,8 +26,6 @@ function handleToolClick(event: ToolEvent) {
emit(event)
if (isHeaderMode.value) {
isToolsPanelOpen.value = false
} else if (!isToolsPanelLocked.value) {
isHovered.value = false
}
}
@@ -103,6 +66,29 @@ const tools = [
labelKey: 'analysis.messageExport.title',
},
]
const headerTools = [
{
event: 'openSessionIndex' as const,
icon: 'i-heroicons-clock',
hoverColor: 'group-hover:text-blue-500',
miniHoverBg: 'hover:text-blue-500',
labelKey: 'analysis.tooltip.sessionIndex',
},
{
event: 'openMessageExport' as const,
icon: 'i-heroicons-document-arrow-down',
hoverColor: 'group-hover:text-green-500',
miniHoverBg: 'hover:text-green-500',
labelKey: 'analysis.messageExport.title',
},
]
const visibleTools = computed(() => (isHeaderMode.value ? headerTools : tools))
watch(toolsPanelPosition, () => {
isToolsPanelOpen.value = false
})
</script>
<template>
@@ -136,27 +122,11 @@ const tools = [
</div>
</div>
<!-- 普通/header 模式:面板从右侧滑入 -->
<div
v-else
class="fixed right-0 z-40"
:class="isHeaderMode ? 'top-14' : 'top-1/3'"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<!-- Trigger 标签(仅 side 模式) -->
<div
v-if="!isHeaderMode"
class="h-10 w-6 cursor-pointer items-center justify-center rounded-l-lg border border-r-0 border-primary-200 bg-primary-50 text-primary-500 shadow-sm transition-opacity duration-200 hover:bg-primary-100 hover:text-primary-600 dark:border-primary-800 dark:bg-primary-950 dark:text-primary-400 dark:hover:bg-primary-900 dark:hover:text-primary-300"
:class="isVisible ? 'pointer-events-none flex opacity-0' : 'flex opacity-100'"
>
<UIcon name="i-heroicons-wrench-screwdriver" class="h-3.5 w-3.5" />
</div>
<!-- 面板(从右侧滑入) -->
<!-- header 模式:点击更多后从右侧滑入;side 模式:面板常驻 -->
<div v-else class="fixed right-0 z-40" :class="isHeaderMode ? 'top-14' : 'top-1/3'">
<div
class="absolute right-0 top-0 transition-all duration-250 ease-in-out"
:class="isVisible ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-full opacity-0'"
:class="isPanelVisible ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-full opacity-0'"
>
<div
class="no-capture flex w-40 flex-col rounded-l-xl border border-r-0 border-gray-200/60 bg-white p-3 shadow-lg dark:border-white/5 dark:bg-gray-900"
@@ -165,42 +135,20 @@ const tools = [
<span class="px-0.5 text-[9px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-500">
{{ t('analysis.overview.tools') }}
</span>
<div v-if="!isHeaderMode" class="flex items-center gap-0.5">
<!-- Mini 模式按钮 -->
<UTooltip :text="t('analysis.toolsPanel.miniMode')" :popper="{ placement: 'left' }">
<button
class="flex h-5 w-5 items-center justify-center rounded text-gray-300 transition-colors hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-400"
@click="layoutStore.toggleToolsPanelMini()"
>
<UIcon name="i-heroicons-arrows-pointing-in" class="h-3 w-3" />
</button>
</UTooltip>
<!-- 锁定按钮 -->
<UTooltip
:text="isToolsPanelLocked ? t('analysis.toolsPanel.unlock') : t('analysis.toolsPanel.lock')"
:popper="{ placement: 'left' }"
<!-- Mini 模式按钮(仅 side 模式) -->
<UTooltip v-if="!isHeaderMode" :text="t('analysis.toolsPanel.miniMode')" :popper="{ placement: 'left' }">
<button
class="flex h-5 w-5 items-center justify-center rounded text-gray-300 transition-colors hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-400"
@click="layoutStore.toggleToolsPanelMini()"
>
<button
class="flex h-5 w-5 items-center justify-center rounded transition-colors"
:class="
isToolsPanelLocked
? 'text-pink-500 hover:text-pink-600 dark:text-pink-400'
: 'text-gray-300 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-400'
"
@click="layoutStore.toggleToolsPanelLock()"
>
<UIcon
:name="isToolsPanelLocked ? 'i-heroicons-lock-closed-solid' : 'i-heroicons-lock-open'"
class="h-3 w-3"
/>
</button>
</UTooltip>
</div>
<UIcon name="i-heroicons-arrows-pointing-in" class="h-3 w-3" />
</button>
</UTooltip>
</div>
<div class="flex flex-col gap-1.5">
<button
v-for="tool in tools"
v-for="tool in visibleTools"
:key="tool.event"
class="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
@click="handleToolClick(tool.event)"
+20 -7
View File
@@ -37,17 +37,17 @@ const props = withDefaults(
:src="avatar"
:alt="title"
class="object-cover"
:class="props.size === 'compact' ? 'h-8 w-8 rounded-lg' : 'h-10 w-10 rounded-xl'"
:class="props.size === 'compact' ? 'h-6 w-6 rounded-md' : 'h-10 w-10 rounded-xl'"
/>
<!-- 可选图标(fallback) -->
<div
v-else-if="icon"
class="flex items-center justify-center"
:class="[iconClass, props.size === 'compact' ? 'h-8 w-8 rounded-lg' : 'h-10 w-10 rounded-xl']"
:class="[iconClass, props.size === 'compact' ? 'h-6 w-6 rounded-md' : 'h-10 w-10 rounded-xl']"
>
<UIcon :name="icon" class="text-white" :class="props.size === 'compact' ? 'h-4 w-4' : 'h-5 w-5'" />
<UIcon :name="icon" class="text-white" :class="props.size === 'compact' ? 'h-3 w-3' : 'h-5 w-5'" />
</div>
<div>
<div class="group/title flex items-baseline gap-2">
<h1
class="font-semibold text-gray-900 dark:text-white"
:class="props.size === 'compact' ? 'text-base' : 'text-lg'"
@@ -56,7 +56,7 @@ const props = withDefaults(
</h1>
<p
v-if="description"
class="text-gray-500 dark:text-gray-400"
class="pointer-events-none whitespace-nowrap text-gray-500 opacity-0 transition-opacity duration-200 group-hover/title:opacity-100 dark:text-gray-400"
:class="props.size === 'compact' ? 'text-[11px]' : 'text-xs'"
>
{{ description }}
@@ -68,7 +68,7 @@ const props = withDefaults(
<div class="flex-1 self-stretch mx-4" style="-webkit-app-region: drag" />
<!-- 右侧操作区域 -->
<div class="flex items-center gap-2">
<div class="header-actions relative z-[40] flex items-center gap-2">
<slot name="actions" />
</div>
</div>
@@ -84,9 +84,22 @@ const props = withDefaults(
position: absolute;
left: 0;
right: 0;
z-index: 50;
z-index: 30;
top: calc(-1 * var(--titlebar-area-height));
height: calc(var(--titlebar-area-height) + 1rem);
-webkit-app-region: drag;
}
.header-actions {
-webkit-app-region: drag;
}
.header-actions :deep(button),
.header-actions :deep(button *),
.header-actions :deep(a),
.header-actions :deep(a *),
.header-actions :deep([role='button']),
.header-actions :deep([role='button'] *) {
-webkit-app-region: no-drag;
}
</style>
+2 -1
View File
@@ -66,7 +66,8 @@
"viewChatRecord": "View Chat History",
"sessionIndex": "Session Index",
"incrementalImport": "Import More",
"memberManagement": "Manage Member"
"memberManagement": "Manage Member",
"more": "More"
},
"incremental": {
"title": "Append Chat Records",
+2 -1
View File
@@ -66,7 +66,8 @@
"viewChatRecord": "チャット履歴を見る",
"sessionIndex": "セッション索引",
"incrementalImport": "追加インポート",
"memberManagement": "メンバー管理"
"memberManagement": "メンバー管理",
"more": "その他"
},
"incremental": {
"title": "チャット記録を追加",
+2 -1
View File
@@ -66,7 +66,8 @@
"viewChatRecord": "查看聊天记录",
"sessionIndex": "会话索引",
"incrementalImport": "增量导入",
"memberManagement": "成员管理"
"memberManagement": "成员管理",
"more": "更多"
},
"incremental": {
"title": "追加聊天记录",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"brand": "ChatLab",
"newAnalysis": "分析新聊天",
"newAnalysis": "分析新对话",
"tools": "实用工具",
"chatHistory": "聊天对话",
"manage": "管理",
+2 -1
View File
@@ -66,7 +66,8 @@
"viewChatRecord": "查看聊天紀錄",
"sessionIndex": "會話索引",
"incrementalImport": "追加匯入",
"memberManagement": "成員管理"
"memberManagement": "成員管理",
"more": "更多"
},
"incremental": {
"title": "新增聊天紀錄",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"brand": "ChatLab",
"newAnalysis": "分析新聊天",
"newAnalysis": "分析新對話",
"tools": "實用工具",
"chatHistory": "聊天紀錄",
"manage": "管理",
+47 -21
View File
@@ -23,7 +23,7 @@ import LoadingState from '@/components/UI/LoadingState.vue'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useSettingsStore } from '@/stores/settings'
import { useSessionAnalysisPageBase, useSessionHeaderDescription } from '@/composables'
import { useSessionAnalysisPageBase } from '@/composables'
const { t } = useI18n()
@@ -111,13 +111,6 @@ const filteredMessageCount = computed(() => {
const filteredMemberCount = computed(() => {
return memberActivity.value.filter((m) => m.messageCount > 0).length
})
const { headerDescription } = useSessionHeaderDescription({
session,
fullTimeRange,
timeRangeValue,
descriptionKey: 'analysis.groupChat.description',
})
</script>
<template>
@@ -130,25 +123,59 @@ const { headerDescription } = useSessionHeaderDescription({
<!-- Header -->
<PageHeader
:title="session.name"
:description="headerDescription"
:avatar="session.groupAvatar"
size="compact"
icon="i-heroicons-chat-bubble-left-right"
icon-class="bg-primary-600 text-white dark:bg-primary-500 dark:text-white"
>
<template #actions>
<UTooltip v-if="layoutStore.toolsPanelPosition === 'header'" :text="t('analysis.overview.tools')">
<UButton
icon="i-heroicons-wrench-screwdriver"
variant="ghost"
color="primary"
size="sm"
@click="layoutStore.toggleToolsPanelOpen()"
/>
</UTooltip>
<CaptureButton />
<template v-if="layoutStore.toolsPanelPosition === 'header'">
<UTooltip :text="t('analysis.tooltip.incrementalImport')">
<UButton
icon="i-heroicons-plus-circle"
variant="ghost"
color="gray"
size="sm"
class="hover:bg-gray-100 dark:hover:bg-gray-800"
@click="showIncrementalImportModal = true"
/>
</UTooltip>
<UTooltip :text="t('analysis.tooltip.memberManagement')">
<UButton
icon="i-heroicons-user-group"
variant="ghost"
color="gray"
size="sm"
class="hover:bg-gray-100 dark:hover:bg-gray-800"
@click="showMemberManagementModal = true"
/>
</UTooltip>
<UTooltip :text="t('analysis.tooltip.viewChatRecord')">
<UButton
icon="i-heroicons-chat-bubble-bottom-center-text"
variant="ghost"
color="gray"
size="sm"
class="hover:bg-gray-100 dark:hover:bg-gray-800"
@click="openChatRecordViewer"
/>
</UTooltip>
<CaptureButton color="gray" />
<UTooltip :text="t('analysis.tooltip.more')">
<UButton
icon="i-heroicons-ellipsis-horizontal"
variant="ghost"
color="gray"
size="sm"
class="hover:bg-gray-100 dark:hover:bg-gray-800"
@click="layoutStore.toggleToolsPanelOpen()"
/>
</UTooltip>
</template>
<CaptureButton v-else color="gray" />
</template>
<!-- Tabs -->
<div class="mt-2 flex items-center justify-between gap-3">
<div class="mt-3 flex items-center justify-between gap-3">
<div class="flex shrink-0 items-center gap-0.5 overflow-x-auto scrollbar-hide">
<button
v-for="tab in tabs"
@@ -238,7 +265,6 @@ const { headerDescription } = useSessionHeaderDescription({
</div>
</div>
<!-- 右侧工具面板(fixed 定位,不占用页面空间) -->
<ActionToolsPanel
@open-incremental-import="showIncrementalImportModal = true"
@open-session-index="showSessionIndexModal = true"
+45 -20
View File
@@ -22,7 +22,7 @@ import LoadingState from '@/components/UI/LoadingState.vue'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useSettingsStore } from '@/stores/settings'
import { useSessionAnalysisPageBase, useSessionHeaderDescription } from '@/composables'
import { useSessionAnalysisPageBase } from '@/composables'
const { t } = useI18n()
@@ -101,13 +101,6 @@ const filteredMemberCount = computed(() => {
return memberActivity.value.filter((m) => m.messageCount > 0).length
})
const { headerDescription } = useSessionHeaderDescription({
session,
fullTimeRange,
timeRangeValue,
descriptionKey: 'analysis.privateChat.description',
})
// 获取对方头像
const otherMemberAvatar = computed(() => {
if (!session.value || memberActivity.value.length === 0) return null
@@ -143,23 +136,56 @@ const otherMemberAvatar = computed(() => {
<!-- Header -->
<PageHeader
:title="session.name"
:description="headerDescription"
:avatar="otherMemberAvatar"
size="compact"
icon="i-heroicons-user"
icon-class="bg-pink-600 text-white dark:bg-pink-500 dark:text-white"
>
<template #actions>
<UTooltip v-if="layoutStore.toolsPanelPosition === 'header'" :text="t('analysis.overview.tools')">
<UButton
icon="i-heroicons-wrench-screwdriver"
variant="ghost"
color="primary"
size="sm"
@click="layoutStore.toggleToolsPanelOpen()"
/>
</UTooltip>
<CaptureButton />
<template v-if="layoutStore.toolsPanelPosition === 'header'">
<UTooltip :text="t('analysis.tooltip.incrementalImport')">
<UButton
icon="i-heroicons-plus-circle"
variant="ghost"
color="gray"
size="sm"
class="hover:bg-gray-100 dark:hover:bg-gray-800"
@click="showIncrementalImportModal = true"
/>
</UTooltip>
<UTooltip :text="t('analysis.tooltip.memberManagement')">
<UButton
icon="i-heroicons-user-group"
variant="ghost"
color="gray"
size="sm"
class="hover:bg-gray-100 dark:hover:bg-gray-800"
@click="showMemberManagementModal = true"
/>
</UTooltip>
<UTooltip :text="t('analysis.tooltip.viewChatRecord')">
<UButton
icon="i-heroicons-chat-bubble-bottom-center-text"
variant="ghost"
color="gray"
size="sm"
class="hover:bg-gray-100 dark:hover:bg-gray-800"
@click="openChatRecordViewer"
/>
</UTooltip>
<CaptureButton color="gray" />
<UTooltip :text="t('analysis.tooltip.more')">
<UButton
icon="i-heroicons-ellipsis-horizontal"
variant="ghost"
color="gray"
size="sm"
class="hover:bg-gray-100 dark:hover:bg-gray-800"
@click="layoutStore.toggleToolsPanelOpen()"
/>
</UTooltip>
</template>
<CaptureButton v-else color="gray" />
</template>
<!-- Tabs -->
<div class="mt-3 flex items-center justify-between gap-3">
@@ -249,7 +275,6 @@ const otherMemberAvatar = computed(() => {
</div>
</div>
<!-- 右侧工具面板(fixed 定位,不占用页面空间) -->
<ActionToolsPanel
@open-incremental-import="showIncrementalImportModal = true"
@open-session-index="showSessionIndexModal = true"