mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor(navigation): separate page section and compact tabs
This commit is contained in:
1 parent
7120d56851
commit
12c7fc2105
22 files changed
+234
-273
No files matched your search
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { SubTabs } from '@/components/UI'
|
||||
import { SectionTabs } from '@/components/navigation'
|
||||
import TableBrowser from './TableBrowser.vue'
|
||||
import ToolTestTab from './ToolTestTab.vue'
|
||||
|
||||
@@ -21,7 +21,7 @@ const activeSubTab = ref('sql-tables')
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<SubTabs v-model="activeSubTab" :items="subTabs" persist-key="debugTab" />
|
||||
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="debugTab" />
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<Transition name="fade" mode="out-in">
|
||||
|
||||
@@ -11,7 +11,6 @@ export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as LoadingState } from './LoadingState.vue'
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as UITabs } from './Tabs.vue'
|
||||
export { default as SubTabs } from './SubTabs.vue'
|
||||
export { default as PageAnchorsNav } from './PageAnchorsNav.vue'
|
||||
export { default as FileDropZone } from './FileDropZone.vue'
|
||||
export { default as DatePicker } from './DatePicker.vue'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { SubTabs } from '@/components/UI'
|
||||
import { SectionTabs } from '@/components/navigation'
|
||||
import { ChatExplorer } from '../AIChat'
|
||||
import SQLLabTab from './SQLLabTab.vue'
|
||||
import { KeywordAnalysis } from './quotes'
|
||||
@@ -68,7 +68,7 @@ defineExpose({
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- 子 Tab 导航 -->
|
||||
<SubTabs v-model="activeSubTab" :items="subTabs" persist-key="aiTab" />
|
||||
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="aiTab" />
|
||||
|
||||
<!-- 子 Tab 内容 -->
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
@@ -8,8 +8,8 @@ import AIPreprocessTab from './AI/AIPreprocessTab.vue'
|
||||
import AIExportSettingsTab from './AI/AIExportSettingsTab.vue'
|
||||
import SessionIndexSection from './AI/SessionIndexSection.vue'
|
||||
import SemanticIndexSection from './AI/SemanticIndexSection.vue'
|
||||
import SubTabs from '@/components/UI/SubTabs.vue'
|
||||
import { useSubTabsScroll } from '@/composables/useSubTabsScroll'
|
||||
import { CompactTabs } from '@/components/navigation'
|
||||
import { useAnchorNavigation } from '@/composables/useAnchorNavigation'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -31,8 +31,8 @@ const navItems = computed(() => [
|
||||
{ id: 'export', label: t('settings.aiPrompt.exportSettings.title') },
|
||||
])
|
||||
|
||||
// 使用二级导航滚动联动 composable
|
||||
const { activeNav, scrollContainerRef, setSectionRef, handleNavChange, scrollToId } = useSubTabsScroll(navItems)
|
||||
// 使用锚点导航滚动联动 composable
|
||||
const { activeNav, scrollContainerRef, setSectionRef, handleNavChange, scrollToId } = useAnchorNavigation(navItems)
|
||||
void scrollContainerRef // 在模板中通过 ref="scrollContainerRef" 使用
|
||||
|
||||
// AI 配置变更回调
|
||||
@@ -61,7 +61,7 @@ void aiModelConfigRef.value
|
||||
<div class="flex h-full gap-6">
|
||||
<!-- 左侧锚点导航 -->
|
||||
<div class="w-36 shrink-0">
|
||||
<SubTabs v-model="activeNav" :items="navItems" orientation="vertical" @change="handleNavChange" />
|
||||
<CompactTabs v-model="activeNav" :items="navItems" orientation="vertical" @change="handleNavChange" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容区域 -->
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiServerStore, type DataSource, type ImportSession } from '@/stores/apiServer'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { SubTabs } from '@/components/UI'
|
||||
import { CompactTabs } from '@/components/navigation'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import DataSourceAddModal from './API/DataSourceAddModal.vue'
|
||||
import DataSourceEditModal from './API/DataSourceEditModal.vue'
|
||||
@@ -230,7 +230,7 @@ function subscribedRemoteIds(ds: DataSource): Set<string> {
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col -mx-6 -mt-6">
|
||||
<SubTabs v-model="activeSubTab" :items="subTabs" persist-key="apiSubTab" />
|
||||
<CompactTabs v-model="activeSubTab" :items="subTabs" persist-key="apiSubTab" />
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<div class="space-y-6 px-6 pt-4 pb-6">
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { PageTabs } from '@/components/navigation'
|
||||
import { usePromptStore } from '@/stores/prompt'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
|
||||
@@ -100,22 +101,7 @@ watch(showSettings, async (visible) => {
|
||||
/>
|
||||
</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>
|
||||
<PageTabs v-model="activeTab" class="mt-4 pb-3" :items="tabs" @change="switchTab" />
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1">
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type UpdateNoticeCache,
|
||||
type UpdateNoticeState,
|
||||
} from './sidebar/updateNotice'
|
||||
import SubTabs from '@/components/UI/SubTabs.vue'
|
||||
import { CompactTabs } from '@/components/navigation'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import { usePlatformService } from '@/services'
|
||||
@@ -481,7 +481,7 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
|
||||
<div class="flex-1 relative min-h-0 flex flex-col">
|
||||
<!-- 筛选与排序 - 固定在顶部,不随列表滚动 -->
|
||||
<div v-if="!isCollapsed && sessions.length > 0" class="mb-2">
|
||||
<SubTabs v-model="filterType" :items="filterTabItems" size="sm" :bordered="false">
|
||||
<CompactTabs v-model="filterType" :items="filterTabItems" size="sm" :bordered="false">
|
||||
<template #right>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<UTooltip :text="t('layout.tooltip.search')" :content="{ side: 'bottom' }">
|
||||
@@ -505,7 +505,7 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</SubTabs>
|
||||
</CompactTabs>
|
||||
<!-- 搜索框 -->
|
||||
<div v-if="showSearch" class="mt-2 px-4">
|
||||
<UInput
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLayoutStore } from '@/stores/layout'
|
||||
import CaptureButton from '@/components/common/CaptureButton.vue'
|
||||
import TimeSelect from '@/components/common/TimeSelect.vue'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import PageNavigation from '@/components/navigation/PageNavigation.vue'
|
||||
import { PageTabs } from '@/components/navigation'
|
||||
import type { TimeRangeValue, TimeSelectState } from '@/components/common/TimeSelect.vue'
|
||||
|
||||
interface SessionAnalysisTab {
|
||||
@@ -99,7 +99,7 @@ const navigationItems = computed(() =>
|
||||
</template>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<PageNavigation v-model="activeTab" class="min-w-0 shrink" :items="navigationItems" />
|
||||
<PageTabs v-model="activeTab" class="min-w-0 shrink" :items="navigationItems" />
|
||||
<!-- AI 对话和实验室都不使用这里的时间范围筛选,因此在这些一级 Tab 下隐藏。 -->
|
||||
<TimeSelect
|
||||
v-model="timeRangeValue"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import NavigationTabsBase from './NavigationTabsBase.vue'
|
||||
import type { NavigationTabItem } from './types'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
items: NavigationTabItem[]
|
||||
persistKey?: string
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
size?: 'sm' | 'md'
|
||||
bordered?: boolean
|
||||
}>(),
|
||||
{
|
||||
orientation: 'horizontal',
|
||||
size: 'md',
|
||||
}
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const emit = defineEmits<{
|
||||
change: [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationTabsBase
|
||||
v-model="model"
|
||||
:items="items"
|
||||
:persist-key="persistKey"
|
||||
:orientation="orientation"
|
||||
:size="size"
|
||||
:bordered="bordered"
|
||||
mode="compact"
|
||||
@change="emit('change', $event)"
|
||||
>
|
||||
<template v-if="$slots.right" #right>
|
||||
<slot name="right" />
|
||||
</template>
|
||||
</NavigationTabsBase>
|
||||
</template>
|
||||
@@ -1,32 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 轻量级子标签页组件
|
||||
* 适用于页面内部的二级导航,使用原生样式
|
||||
* 支持通过 persistKey 将选中状态同步到 URL 查询参数
|
||||
* 支持水平和垂直两种方向
|
||||
*/
|
||||
interface TabItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
}
|
||||
import type { NavigationTabItem } from './types'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
items: TabItem[]
|
||||
/** 持久化 key,设置后会将当前 tab 状态同步到 URL 查询参数 */
|
||||
items: NavigationTabItem[]
|
||||
persistKey?: string
|
||||
/** 方向:horizontal(水平)或 vertical(垂直) */
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
/** 尺寸:sm 适用于侧边栏等紧凑场景 */
|
||||
size?: 'sm' | 'md'
|
||||
/** 是否显示底部边框线 */
|
||||
bordered?: boolean
|
||||
/** 页面级二级导航使用 page,嵌入式 Tab 保持 default */
|
||||
variant?: 'default' | 'page'
|
||||
mode: 'section' | 'compact'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -37,24 +21,16 @@ interface Emits {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
orientation: 'horizontal',
|
||||
size: 'md',
|
||||
variant: 'default',
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 是否为垂直模式
|
||||
const isVertical = computed(() => props.orientation === 'vertical')
|
||||
|
||||
// Tab 按钮引用
|
||||
const tabRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 激活指示器的样式
|
||||
const indicatorStyle = ref<Record<string, string>>({})
|
||||
|
||||
// 计算内部值
|
||||
const activeTab = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
@@ -63,64 +39,45 @@ const activeTab = computed({
|
||||
},
|
||||
})
|
||||
|
||||
// 更新指示器位置
|
||||
function updateIndicator() {
|
||||
const activeButton = tabRefs.value[activeTab.value]
|
||||
if (activeButton && containerRef.value) {
|
||||
const containerRect = containerRef.value.getBoundingClientRect()
|
||||
const buttonRect = activeButton.getBoundingClientRect()
|
||||
if (!activeButton || !containerRef.value) return
|
||||
|
||||
if (isVertical.value) {
|
||||
// 垂直模式:指示器在右侧
|
||||
indicatorStyle.value = {
|
||||
const containerRect = containerRef.value.getBoundingClientRect()
|
||||
const buttonRect = activeButton.getBoundingClientRect()
|
||||
indicatorStyle.value = isVertical.value
|
||||
? {
|
||||
top: `${buttonRect.top - containerRect.top}px`,
|
||||
height: `${buttonRect.height}px`,
|
||||
right: '0px',
|
||||
width: '2px',
|
||||
}
|
||||
} else {
|
||||
// 水平模式:指示器在底部
|
||||
indicatorStyle.value = {
|
||||
: {
|
||||
left: `${buttonRect.left - containerRect.left}px`,
|
||||
width: `${buttonRect.width}px`,
|
||||
bottom: '0px',
|
||||
height: '2px',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点击标签
|
||||
const handleTabClick = (tabId: string) => {
|
||||
activeTab.value = tabId
|
||||
}
|
||||
|
||||
// 设置 tab 引用
|
||||
function setTabRef(id: string, el: HTMLElement | null) {
|
||||
tabRefs.value[id] = el
|
||||
}
|
||||
|
||||
// 从 URL 查询参数恢复 tab 状态
|
||||
onMounted(() => {
|
||||
if (props.persistKey) {
|
||||
const savedTab = route.query[props.persistKey] as string
|
||||
// 验证 savedTab 是否在 items 中存在
|
||||
if (savedTab && props.items.some((item) => item.id === savedTab)) {
|
||||
activeTab.value = savedTab
|
||||
}
|
||||
}
|
||||
// 初始更新指示器位置
|
||||
nextTick(() => {
|
||||
updateIndicator()
|
||||
})
|
||||
nextTick(updateIndicator)
|
||||
})
|
||||
|
||||
// 监听 tab 变化,同步到 URL 查询参数并更新指示器
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (props.persistKey && newValue) {
|
||||
// 使用 replace 而不是 push,避免产生大量历史记录
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
@@ -128,21 +85,13 @@ watch(
|
||||
},
|
||||
})
|
||||
}
|
||||
// 更新指示器位置
|
||||
nextTick(() => {
|
||||
updateIndicator()
|
||||
})
|
||||
nextTick(updateIndicator)
|
||||
}
|
||||
)
|
||||
|
||||
// 监听 items 变化,更新指示器位置
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
updateIndicator()
|
||||
})
|
||||
},
|
||||
() => nextTick(updateIndicator),
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
@@ -155,7 +104,7 @@ watch(
|
||||
: [
|
||||
'flex justify-between overflow-x-auto',
|
||||
bordered !== false ? 'border-b border-gray-200/50 dark:border-gray-800/50' : '',
|
||||
variant === 'page'
|
||||
mode === 'section'
|
||||
? 'flex-col items-stretch gap-2 pl-7 pr-6 pt-3 xl:flex-row xl:items-center'
|
||||
: ['items-center', size === 'sm' ? 'px-3' : 'px-6'],
|
||||
],
|
||||
@@ -166,6 +115,7 @@ watch(
|
||||
v-for="tab in items"
|
||||
:key="tab.id"
|
||||
:ref="(el) => setTabRef(tab.id, el as HTMLElement)"
|
||||
type="button"
|
||||
class="flex shrink-0 items-center whitespace-nowrap font-medium transition-colors"
|
||||
:class="[
|
||||
isVertical
|
||||
@@ -177,17 +127,16 @@ watch(
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300',
|
||||
]"
|
||||
@click="handleTabClick(tab.id)"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<UIcon v-if="tab.icon" :name="tab.icon" :class="size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
<!-- 滑动指示器 -->
|
||||
<div class="absolute bg-primary-500 transition-all duration-300 ease-out" :style="indicatorStyle" />
|
||||
</div>
|
||||
<!-- 右侧插槽(水平模式可用) -->
|
||||
<template v-if="!isVertical">
|
||||
<div v-if="variant === 'page'" class="flex w-full min-w-0 items-center xl:w-auto xl:justify-end">
|
||||
|
||||
<template v-if="!isVertical && $slots.right">
|
||||
<div v-if="mode === 'section'" class="flex w-full min-w-0 items-center xl:w-auto xl:justify-end">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
<slot v-else name="right" />
|
||||
+4
-6
@@ -1,17 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, type RouteLocationRaw } from 'vue-router'
|
||||
import type { NavigationTabItem } from './types'
|
||||
|
||||
interface PageNavItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
interface PageTabItem extends NavigationTabItem {
|
||||
to?: RouteLocationRaw
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
items: PageNavItem[]
|
||||
items: PageTabItem[]
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{
|
||||
@@ -24,7 +22,7 @@ const emit = defineEmits<{
|
||||
change: [value: string]
|
||||
}>()
|
||||
|
||||
function selectItem(item: PageNavItem): void {
|
||||
function selectItem(item: PageTabItem): void {
|
||||
if (item.to || item.id === props.modelValue) return
|
||||
emit('update:modelValue', item.id)
|
||||
emit('change', item.id)
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import NavigationTabsBase from './NavigationTabsBase.vue'
|
||||
import type { NavigationTabItem } from './types'
|
||||
|
||||
defineProps<{
|
||||
items: NavigationTabItem[]
|
||||
persistKey?: string
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const emit = defineEmits<{
|
||||
change: [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationTabsBase
|
||||
v-model="model"
|
||||
:items="items"
|
||||
:persist-key="persistKey"
|
||||
mode="section"
|
||||
size="sm"
|
||||
:bordered="false"
|
||||
@change="emit('change', $event)"
|
||||
>
|
||||
<template v-if="$slots.right" #right>
|
||||
<slot name="right" />
|
||||
</template>
|
||||
</NavigationTabsBase>
|
||||
</template>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as PageTabs } from './PageTabs.vue'
|
||||
export { default as SectionTabs } from './SectionTabs.vue'
|
||||
export { default as CompactTabs } from './CompactTabs.vue'
|
||||
export type { NavigationTabItem } from './types'
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface NavigationTabItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ref, onMounted, onUnmounted, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
export interface AnchorNavigationItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface AnchorNavigationOptions {
|
||||
scrollBehavior?: ScrollBehavior
|
||||
scrollLockDuration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 实现锚点导航与内容区域的滚动联动。
|
||||
*/
|
||||
export function useAnchorNavigation(
|
||||
navItems: ComputedRef<AnchorNavigationItem[]> | Ref<AnchorNavigationItem[]>,
|
||||
options: AnchorNavigationOptions = {}
|
||||
) {
|
||||
const scrollBehavior = options.scrollBehavior ?? 'smooth'
|
||||
const scrollLockDuration = options.scrollLockDuration ?? 500
|
||||
const activeNav = ref(navItems.value[0]?.id || '')
|
||||
const isUserClick = ref(false)
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
const sectionRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
|
||||
function setSectionRef(id: string, el: HTMLElement | null) {
|
||||
sectionRefs.value[id] = el
|
||||
}
|
||||
|
||||
function handleNavChange(id: string) {
|
||||
const section = sectionRefs.value[id]
|
||||
if (!section || !scrollContainerRef.value) return
|
||||
|
||||
isUserClick.value = true
|
||||
section.scrollIntoView({ behavior: scrollBehavior, block: 'start' })
|
||||
setTimeout(() => {
|
||||
isUserClick.value = false
|
||||
}, scrollLockDuration)
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (isUserClick.value || !scrollContainerRef.value) return
|
||||
|
||||
const container = scrollContainerRef.value
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const offset = 50
|
||||
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
|
||||
}
|
||||
|
||||
for (const item of navItems.value) {
|
||||
const section = sectionRefs.value[item.id]
|
||||
if (!section) continue
|
||||
|
||||
const rect = section.getBoundingClientRect()
|
||||
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)
|
||||
})
|
||||
|
||||
function scrollToId(id: string) {
|
||||
const section = sectionRefs.value[id]
|
||||
if (!section || !scrollContainerRef.value) return
|
||||
|
||||
isUserClick.value = true
|
||||
section.scrollIntoView({ behavior: scrollBehavior, block: 'start' })
|
||||
activeNav.value = id
|
||||
setTimeout(() => {
|
||||
isUserClick.value = false
|
||||
}, scrollLockDuration)
|
||||
}
|
||||
|
||||
return {
|
||||
activeNav,
|
||||
scrollContainerRef,
|
||||
sectionRefs,
|
||||
setSectionRef,
|
||||
handleNavChange,
|
||||
scrollToId,
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { ref, onMounted, onUnmounted, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
/**
|
||||
* 导航项配置
|
||||
*/
|
||||
export interface SubTabNavItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface SubTabsScrollOptions {
|
||||
scrollBehavior?: ScrollBehavior
|
||||
scrollLockDuration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级导航滚动联动 composable
|
||||
* 实现左侧锚点导航与右侧内容区域的滚动联动
|
||||
*/
|
||||
export function useSubTabsScroll(
|
||||
navItems: ComputedRef<SubTabNavItem[]> | Ref<SubTabNavItem[]>,
|
||||
options: SubTabsScrollOptions = {}
|
||||
) {
|
||||
const scrollBehavior = options.scrollBehavior ?? 'smooth'
|
||||
const scrollLockDuration = options.scrollLockDuration ?? 500
|
||||
|
||||
// 当前激活的导航项
|
||||
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: scrollBehavior, block: 'start' })
|
||||
// 滚动动画结束后恢复
|
||||
setTimeout(() => {
|
||||
isUserClick.value = false
|
||||
}, scrollLockDuration)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听滚动更新当前激活项
|
||||
*/
|
||||
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)
|
||||
})
|
||||
|
||||
/**
|
||||
* 程序化滚动到指定 section(供外部调用)
|
||||
*/
|
||||
function scrollToId(id: string) {
|
||||
const section = sectionRefs.value[id]
|
||||
if (section && scrollContainerRef.value) {
|
||||
isUserClick.value = true
|
||||
section.scrollIntoView({ behavior: scrollBehavior, block: 'start' })
|
||||
activeNav.value = id
|
||||
setTimeout(() => {
|
||||
isUserClick.value = false
|
||||
}, scrollLockDuration)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeNav,
|
||||
scrollContainerRef,
|
||||
sectionRefs,
|
||||
setSectionRef,
|
||||
handleNavChange,
|
||||
scrollToId,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { SubTabs } from '@/components/UI'
|
||||
import { SectionTabs } from '@/components/navigation'
|
||||
import { CatchphraseTab, HotRepeatTab } from '@/components/analysis/quotes'
|
||||
import type { TimeFilter } from '@openchatlab/shared-types'
|
||||
|
||||
@@ -28,7 +28,7 @@ const activeSubTab = ref('hot-repeat')
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- 子 Tab 导航 -->
|
||||
<SubTabs v-model="activeSubTab" :items="subTabs" persist-key="quotesTab" />
|
||||
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="quotesTab" />
|
||||
|
||||
<!-- 子 Tab 内容 -->
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { SubTabs } from '@/components/UI'
|
||||
import { SectionTabs } from '@/components/navigation'
|
||||
import UserSelect from '@/components/common/UserSelect.vue'
|
||||
import TypeAnalysisView from '@/components/analysis/message/TypeAnalysisView.vue'
|
||||
import TimeAnalysisView from '@/components/analysis/message/TimeAnalysisView.vue'
|
||||
@@ -50,18 +50,11 @@ const viewTimeFilter = computed(() => ({
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<SubTabs
|
||||
v-model="activeSubTab"
|
||||
:items="subTabs"
|
||||
persist-key="groupViewTab"
|
||||
size="sm"
|
||||
variant="page"
|
||||
:bordered="false"
|
||||
>
|
||||
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="groupViewTab">
|
||||
<template #right>
|
||||
<UserSelect v-if="activeSubTab !== 'topic'" v-model="selectedMemberId" :session-id="props.sessionId" />
|
||||
</template>
|
||||
</SubTabs>
|
||||
</SectionTabs>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<Transition name="fade" mode="out-in">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { SubTabs } from '@/components/UI'
|
||||
import { SectionTabs } from '@/components/navigation'
|
||||
import InteractionView from '@/components/analysis/relationships/InteractionView.vue'
|
||||
import Relationships from './Relationships.vue'
|
||||
import ClusterView from '@/components/analysis/relationships/ClusterView.vue'
|
||||
@@ -33,7 +33,7 @@ const subTabs = computed(() => [
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<SubTabs v-model="activeSubTab" :items="subTabs" persist-key="groupRelationshipsTab" size="sm" />
|
||||
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="groupRelationshipsTab" />
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<Transition name="fade" mode="out-in">
|
||||
|
||||
@@ -14,7 +14,8 @@ import type {
|
||||
import { useDataService } from '@/services'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import { LoadingState, SubTabs, ThemeCard } from '@/components/UI'
|
||||
import { LoadingState, ThemeCard } from '@/components/UI'
|
||||
import { SectionTabs } from '@/components/navigation'
|
||||
import { usePeoplePageHeader } from '../people-page-header'
|
||||
import ContactDetailPanel from './components/ContactDetailPanel.vue'
|
||||
import ContactsStatusBlocks from './components/ContactsStatusBlocks.vue'
|
||||
@@ -848,13 +849,10 @@ function getGroupSectionScrollTop(): number | null {
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col text-gray-900 dark:bg-page-dark dark:text-gray-100">
|
||||
<SubTabs
|
||||
<SectionTabs
|
||||
v-model="activeContactSection"
|
||||
:items="contactTabs"
|
||||
persist-key="contactsTab"
|
||||
size="sm"
|
||||
variant="page"
|
||||
:bordered="false"
|
||||
@change="handleContactTabChange"
|
||||
>
|
||||
<template #right>
|
||||
@@ -887,11 +885,11 @@ function getGroupSectionScrollTop(): number | null {
|
||||
</UInput>
|
||||
</div>
|
||||
</template>
|
||||
</SubTabs>
|
||||
</SectionTabs>
|
||||
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<main class="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
<div class="flex h-full min-h-0 w-full flex-col gap-6 px-6 pb-6 pt-4">
|
||||
<div class="flex h-full min-h-0 w-full flex-col gap-6 px-6 pb-3 pt-4">
|
||||
<ContactsStatusBlocks
|
||||
:show-disabled-notice="showDisabledNotice"
|
||||
:active-private-session-count="diagnostics?.activePrivateSessionCount ?? 0"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import PageNavigation from '@/components/navigation/PageNavigation.vue'
|
||||
import { PageTabs } from '@/components/navigation'
|
||||
import { providePeoplePageHeader, type PeoplePageHeaderConfig, type PeopleSubpage } from './people-page-header'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -75,7 +75,7 @@ const { header } = providePeoplePageHeader(defaultHeader)
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<PageNavigation
|
||||
<PageTabs
|
||||
class="mt-3 pb-1.5"
|
||||
:model-value="activeSubpage"
|
||||
:items="navigationItems"
|
||||
@@ -92,7 +92,7 @@ const { header } = providePeoplePageHeader(defaultHeader)
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</PageNavigation>
|
||||
</PageTabs>
|
||||
</PageHeader>
|
||||
|
||||
<RouterView v-slot="{ Component }">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { SubTabs } from '@/components/UI'
|
||||
import { SectionTabs } from '@/components/navigation'
|
||||
import UserSelect from '@/components/common/UserSelect.vue'
|
||||
import TypeAnalysisView from '@/components/analysis/message/TypeAnalysisView.vue'
|
||||
import TimeAnalysisView from '@/components/analysis/message/TimeAnalysisView.vue'
|
||||
@@ -38,14 +38,7 @@ const viewTimeFilter = computed(() => ({
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<SubTabs
|
||||
v-model="activeSubTab"
|
||||
:items="subTabs"
|
||||
persist-key="privateViewTab"
|
||||
size="sm"
|
||||
variant="page"
|
||||
:bordered="false"
|
||||
>
|
||||
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="privateViewTab">
|
||||
<template #right>
|
||||
<UserSelect
|
||||
v-if="activeSubTab === 'type-analysis' || activeSubTab === 'time-analysis'"
|
||||
@@ -53,7 +46,7 @@ const viewTimeFilter = computed(() => ({
|
||||
:session-id="props.sessionId"
|
||||
/>
|
||||
</template>
|
||||
</SubTabs>
|
||||
</SectionTabs>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<Transition name="fade" mode="out-in">
|
||||
|
||||
Reference in new issue
Block a user