mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(sidebar): unify chat records and contact detail panels
This commit is contained in:
1 parent
3e0e0fbbfb
commit
5e34b7d255
26 files changed
+775
-589
No files matched your search
+3
-6
@@ -41,12 +41,11 @@ const LockScreen = IS_ELECTRON ? defineAsyncComponent(() => import('@/components
|
||||
const DataDirCleanupNotice = defineAsyncComponent(() => import('@/components/common/DataDirCleanupNotice.vue'))
|
||||
const loadScreenCaptureModal = () => import('@/components/common/ScreenCaptureModal.vue')
|
||||
const loadSettingsModal = () => import('@/components/common/SettingsModal.vue')
|
||||
const loadChatRecordDrawer = () => import('@/components/common/ChatRecord/ChatRecordDrawer.vue')
|
||||
const loadChatRecordWorkspace = () => import('@/components/common/ChatRecord/ChatRecordWorkspace.vue')
|
||||
const loadGlobalTaskBar = () => import('@/components/AIChat/GlobalTaskBar.vue')
|
||||
const loadDebugToolsPanel = () => import('@/components/layout/DebugToolsPanel.vue')
|
||||
const ScreenCaptureModal = defineAsyncComponent(loadScreenCaptureModal)
|
||||
const SettingsModal = defineAsyncComponent(loadSettingsModal)
|
||||
const ChatRecordDrawer = defineAsyncComponent(loadChatRecordDrawer)
|
||||
const GlobalTaskBar = defineAsyncComponent(loadGlobalTaskBar)
|
||||
const DebugToolsPanel = defineAsyncComponent(loadDebugToolsPanel)
|
||||
|
||||
@@ -120,7 +119,7 @@ function scheduleNonCriticalUiPrefetch() {
|
||||
cancelNonCriticalUiPrefetch = null
|
||||
void Promise.allSettled([
|
||||
loadSettingsModal(),
|
||||
loadChatRecordDrawer(),
|
||||
loadChatRecordWorkspace(),
|
||||
loadScreenCaptureModal(),
|
||||
loadDebugToolsPanel(),
|
||||
])
|
||||
@@ -359,7 +358,7 @@ useWindowsTitleBarOverlay([
|
||||
() => route.fullPath,
|
||||
() => layoutStore.showSettings,
|
||||
() => layoutStore.showScreenCaptureModal,
|
||||
() => layoutStore.showChatRecordDrawer,
|
||||
() => layoutStore.sidePanelOpen,
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -484,8 +483,6 @@ onUnmounted(() => {
|
||||
/>
|
||||
<!-- 全局设置弹窗 -->
|
||||
<SettingsModal v-if="settingsModalMounted" />
|
||||
<!-- 全局聊天记录查看器 -->
|
||||
<ChatRecordDrawer v-if="layoutStore.showChatRecordDrawer || layoutStore.chatRecordQuery" />
|
||||
<!-- 全局 AI 后台任务条:允许用户离开当前页面后仍然快速返回进行中的对话。 -->
|
||||
<GlobalTaskBar />
|
||||
<!-- Desktop 与 CLI Web 迁移后都提醒人工清理。 -->
|
||||
|
||||
@@ -31,7 +31,7 @@ const sessionGroups = computed(() => {
|
||||
})
|
||||
|
||||
function viewSource(source: CrossChatEvidenceSource): void {
|
||||
layoutStore.openChatRecordDrawer({
|
||||
layoutStore.openChatRecords({
|
||||
sessionId: source.sessionId,
|
||||
scrollToMessageId: source.messageId,
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ const statusDotClass: Record<EvidenceStatus, string> = {
|
||||
}
|
||||
|
||||
function viewSource(messageId: number): void {
|
||||
layoutStore.openChatRecordDrawer({ scrollToMessageId: messageId })
|
||||
layoutStore.openChatRecords({ scrollToMessageId: messageId })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ function getMessageIdColumnIndex(columns: string[]): number {
|
||||
* 查看消息上下文
|
||||
*/
|
||||
function viewMessageContext(messageId: number) {
|
||||
layoutStore.openChatRecordDrawer({
|
||||
layoutStore.openChatRecords({
|
||||
scrollToMessageId: messageId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ async function loadCatchphraseAnalysis() {
|
||||
}
|
||||
|
||||
function handlePhraseClick(content: string) {
|
||||
layoutStore.openChatRecordDrawer(buildCatchphraseRecordQuery(content, props.timeFilter))
|
||||
layoutStore.openChatRecords(buildCatchphraseRecordQuery(content, props.timeFilter))
|
||||
}
|
||||
|
||||
function getOverviewPhrases(member: MemberCatchphrase) {
|
||||
|
||||
@@ -48,7 +48,7 @@ async function loadRepeatAnalysis() {
|
||||
* 查看复读内容的聊天记录上下文
|
||||
*/
|
||||
function viewRepeatContext(item: { content: string; firstMessageId: number }) {
|
||||
layoutStore.openChatRecordDrawer({
|
||||
layoutStore.openChatRecords({
|
||||
scrollToMessageId: item.firstMessageId,
|
||||
highlightKeywords: [item.content],
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ async function loadData() {
|
||||
|
||||
function handleWordClick(word: string) {
|
||||
if (!props.enableRecordNavigation) return
|
||||
layoutStore.openChatRecordDrawer({ keywords: [word] })
|
||||
layoutStore.openChatRecords({ keywords: [word] })
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -430,7 +430,7 @@ watch(locale, () => {
|
||||
// 点击词语,打开聊天记录查看器
|
||||
function handleWordClick(word: string) {
|
||||
if (!props.enableRecordNavigation) return
|
||||
layoutStore.openChatRecordDrawer({
|
||||
layoutStore.openChatRecords({
|
||||
keywords: [word],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Chat record viewer drawer.
|
||||
* Keeps the quick-view shell while ChatRecordWorkspace owns shared orchestration.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ChatRecordWorkspace from './ChatRecordWorkspace.vue'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
|
||||
const { t } = useI18n()
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
// 平台检测
|
||||
const isWindows = ref(false)
|
||||
const MIN_DRAWER_WIDTH = 480
|
||||
const VIEWPORT_MARGIN = 32
|
||||
let dragStartX = 0
|
||||
let dragStartWidth = 0
|
||||
let resizeHandle: HTMLElement | null = null
|
||||
let resizePointerId: number | null = null
|
||||
|
||||
const maxDrawerWidth = ref(1280 - VIEWPORT_MARGIN)
|
||||
const minDrawerWidth = computed(() => Math.min(MIN_DRAWER_WIDTH, maxDrawerWidth.value))
|
||||
const effectiveDrawerWidth = computed(() => clampDrawerWidth(layoutStore.chatRecordDrawerWidth))
|
||||
|
||||
function updateViewportLimit() {
|
||||
maxDrawerWidth.value = Math.max(0, window.innerWidth - VIEWPORT_MARGIN)
|
||||
}
|
||||
|
||||
function clampDrawerWidth(width: number) {
|
||||
return Math.min(maxDrawerWidth.value, Math.max(minDrawerWidth.value, width))
|
||||
}
|
||||
|
||||
function stopResize(event?: Event) {
|
||||
if (event instanceof PointerEvent && event.pointerId !== resizePointerId) return
|
||||
window.removeEventListener('pointermove', handleResize)
|
||||
window.removeEventListener('pointerup', stopResize)
|
||||
window.removeEventListener('pointercancel', stopResize)
|
||||
window.removeEventListener('blur', stopResize)
|
||||
resizeHandle?.removeEventListener('lostpointercapture', stopResize)
|
||||
if (resizeHandle && resizePointerId !== null && resizeHandle.hasPointerCapture(resizePointerId)) {
|
||||
resizeHandle.releasePointerCapture(resizePointerId)
|
||||
}
|
||||
resizeHandle = null
|
||||
resizePointerId = null
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
function handleResize(event: PointerEvent) {
|
||||
if (event.pointerId !== resizePointerId) return
|
||||
layoutStore.chatRecordDrawerWidth = clampDrawerWidth(dragStartWidth + dragStartX - event.clientX)
|
||||
}
|
||||
|
||||
function startResize(event: PointerEvent) {
|
||||
if (event.button !== 0 || resizePointerId !== null) return
|
||||
resizeHandle = event.currentTarget as HTMLElement
|
||||
resizePointerId = event.pointerId
|
||||
resizeHandle.setPointerCapture(resizePointerId)
|
||||
resizeHandle.addEventListener('lostpointercapture', stopResize)
|
||||
dragStartX = event.clientX
|
||||
dragStartWidth = effectiveDrawerWidth.value
|
||||
window.addEventListener('pointermove', handleResize)
|
||||
window.addEventListener('pointerup', stopResize)
|
||||
window.addEventListener('pointercancel', stopResize)
|
||||
window.addEventListener('blur', stopResize)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
function resizeWithKeyboard(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
event.preventDefault()
|
||||
const delta = event.key === 'ArrowLeft' ? 24 : -24
|
||||
layoutStore.chatRecordDrawerWidth = clampDrawerWidth(effectiveDrawerWidth.value + delta)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isWindows.value = navigator.platform.toLowerCase().includes('win')
|
||||
updateViewportLimit()
|
||||
window.addEventListener('resize', updateViewportLimit)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopResize()
|
||||
window.removeEventListener('resize', updateViewportLimit)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDrawer
|
||||
v-model:open="layoutStore.showChatRecordDrawer"
|
||||
direction="right"
|
||||
:handle="false"
|
||||
handle-only
|
||||
:ui="{ content: 'z-50' }"
|
||||
>
|
||||
<template #content>
|
||||
<div
|
||||
data-vaul-no-drag
|
||||
class="chat-record-drawer-content relative flex h-full flex-col bg-white dark:bg-page-dark"
|
||||
:style="{ width: `${effectiveDrawerWidth}px`, maxWidth: `calc(100vw - ${VIEWPORT_MARGIN}px)` }"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
<div
|
||||
class="group absolute inset-y-0 left-0 z-10 w-2 -translate-x-1/2 cursor-col-resize touch-none"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
:aria-label="t('records.drawer.resize')"
|
||||
:aria-valuemin="minDrawerWidth"
|
||||
:aria-valuemax="maxDrawerWidth"
|
||||
:aria-valuenow="effectiveDrawerWidth"
|
||||
tabindex="0"
|
||||
@pointerdown.prevent="startResize"
|
||||
@keydown="resizeWithKeyboard"
|
||||
>
|
||||
<div
|
||||
class="mx-auto h-full w-px bg-transparent transition-colors group-hover:bg-primary-400 group-focus:bg-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<!-- 头部 -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-200 px-4 dark:border-gray-800"
|
||||
:class="isWindows ? 'pt-10 pb-3' : 'py-3'"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('records.drawer.title') }}</h3>
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="layoutStore.closeChatRecordDrawer()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatRecordWorkspace
|
||||
class="min-h-0 flex-1"
|
||||
:initial-query="layoutStore.chatRecordQuery"
|
||||
:active="layoutStore.showChatRecordDrawer"
|
||||
mode="drawer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UDrawer>
|
||||
</template>
|
||||
@@ -124,8 +124,8 @@ function resetFilter() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-x-auto border-b border-gray-200 px-4 py-2.5 dark:border-gray-800">
|
||||
<div class="flex min-w-[686px] items-center gap-2">
|
||||
<div class="shrink-0 border-b border-gray-200 px-4 py-2.5 dark:border-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UInput
|
||||
v-model="formData.messageId"
|
||||
type="number"
|
||||
@@ -154,7 +154,7 @@ function resetFilter() {
|
||||
class="min-w-24 max-w-[260px] flex-1"
|
||||
@keydown="handleKeywordsKeydown"
|
||||
/>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<div class="ml-auto flex shrink-0 gap-1">
|
||||
<UButton color="neutral" variant="ghost" size="sm" @click="resetFilter">
|
||||
{{ t('records.filter.reset') }}
|
||||
</UButton>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* 聊天记录查看器组件
|
||||
* 导出主 Drawer 组件和相关类型
|
||||
* Shared workspace and query types.
|
||||
*/
|
||||
|
||||
export { default as ChatRecordDrawer } from './ChatRecordDrawer.vue'
|
||||
export { default as ChatRecordWorkspace } from './ChatRecordWorkspace.vue'
|
||||
export * from './types'
|
||||
@@ -33,6 +33,7 @@ const props = withDefaults(
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'data-changed': []
|
||||
'view-records': []
|
||||
}>()
|
||||
|
||||
// 成员列表
|
||||
@@ -84,11 +85,12 @@ function getFirstChar(member: MemberWithStats): string {
|
||||
}
|
||||
|
||||
function viewMemberChatRecords(member: MemberWithStats) {
|
||||
layoutStore.openChatRecordDrawer({
|
||||
layoutStore.openChatRecords({
|
||||
sessionId: props.sessionId,
|
||||
memberId: member.id,
|
||||
memberName: getDisplayName(member),
|
||||
})
|
||||
emit('view-records')
|
||||
}
|
||||
|
||||
const filteredSortedMembers = computed(() => filterAndSortMembers(members.value, searchQuery.value, sortState.value))
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
*/
|
||||
|
||||
import { IS_ELECTRON } from '@/utils/platform'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
|
||||
const { t } = useI18n()
|
||||
const layout = useLayoutStore()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -77,6 +82,19 @@ const props = withDefaults(
|
||||
<!-- 右侧操作区域 -->
|
||||
<div class="header-actions relative z-[40] flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
<UButton
|
||||
v-if="layout.canOpenSidePanel"
|
||||
data-side-panel-toggle
|
||||
icon="i-lucide-panel-right"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="layout.sidePanelOpen ? 'bg-gray-100 dark:bg-white/10' : ''"
|
||||
:aria-label="t(layout.sidePanelOpen ? 'common.sidePanel.collapse' : 'common.sidePanel.expand')"
|
||||
:aria-expanded="layout.sidePanelOpen"
|
||||
aria-controls="app-side-panel"
|
||||
@click="layout.toggleSidePanel()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
|
||||
const props = defineProps<{ sessionId?: string | null; contextKey?: string | null }>()
|
||||
const ChatRecordWorkspace = defineAsyncComponent(() => import('@/components/common/ChatRecord/ChatRecordWorkspace.vue'))
|
||||
const layout = useLayoutStore()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const containerWidth = ref(1200)
|
||||
const resizing = ref(false)
|
||||
const records = computed(() => layout.sidePanelView === 'records')
|
||||
const title = computed(() => t(records.value ? 'records.drawer.title' : 'contacts.detail.title'))
|
||||
const preferredWidth = computed({
|
||||
get: () => (records.value ? layout.chatRecordDrawerWidth : layout.contactPanelWidth),
|
||||
set: (value) => {
|
||||
if (records.value) layout.chatRecordDrawerWidth = value
|
||||
else layout.contactPanelWidth = value
|
||||
},
|
||||
})
|
||||
const minimumWidth = computed(() => (records.value ? 480 : 360))
|
||||
// Measure the available page area, not the window: the main navigation also takes space.
|
||||
const overlay = computed(() => containerWidth.value < minimumWidth.value + 520)
|
||||
const maximumWidth = computed(() => Math.max(0, containerWidth.value - (overlay.value ? 16 : 520)))
|
||||
const width = computed(() => Math.min(maximumWidth.value, Math.max(minimumWidth.value, preferredWidth.value)))
|
||||
const canGoBack = computed(() => records.value && layout.sidePanelHasContact)
|
||||
let previousFocus: HTMLElement | null = null
|
||||
let dragStartX = 0
|
||||
let dragStartWidth = 0
|
||||
let resizeHandle: HTMLElement | null = null
|
||||
let resizePointerId: number | null = null
|
||||
|
||||
useResizeObserver(root, ([entry]) => {
|
||||
if (entry) containerWidth.value = entry.contentRect.width
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => route.path, () => props.sessionId, () => props.contextKey],
|
||||
() => layout.setSidePanelContext(props.sessionId ?? null),
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => layout.sidePanelOpen, overlay],
|
||||
async ([open, isOverlay], [wasOpen]) => {
|
||||
if (open) {
|
||||
if (!wasOpen) previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
if (isOverlay) {
|
||||
await nextTick()
|
||||
if (layout.sidePanelOpen) panel.value?.focus({ preventScroll: true })
|
||||
}
|
||||
} else {
|
||||
stopResize()
|
||||
if (panel.value?.contains(document.activeElement)) {
|
||||
const toggle = document.querySelector<HTMLElement>('[data-side-panel-toggle]')
|
||||
;(toggle || previousFocus)?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'pre' }
|
||||
)
|
||||
|
||||
function clampWidth(value: number) {
|
||||
return Math.min(maximumWidth.value, Math.max(minimumWidth.value, value))
|
||||
}
|
||||
|
||||
function stopResize(event?: Event) {
|
||||
if (event instanceof PointerEvent && event.pointerId !== resizePointerId) return
|
||||
window.removeEventListener('pointermove', handleResize)
|
||||
window.removeEventListener('pointerup', stopResize)
|
||||
window.removeEventListener('pointercancel', stopResize)
|
||||
window.removeEventListener('blur', stopResize)
|
||||
resizeHandle?.removeEventListener('lostpointercapture', stopResize)
|
||||
if (resizeHandle && resizePointerId !== null && resizeHandle.hasPointerCapture(resizePointerId)) {
|
||||
resizeHandle.releasePointerCapture(resizePointerId)
|
||||
}
|
||||
resizeHandle = null
|
||||
resizePointerId = null
|
||||
if (resizing.value) {
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
resizing.value = false
|
||||
}
|
||||
|
||||
function handleResize(event: PointerEvent) {
|
||||
if (event.pointerId !== resizePointerId) return
|
||||
preferredWidth.value = clampWidth(dragStartWidth + dragStartX - event.clientX)
|
||||
}
|
||||
|
||||
function startResize(event: PointerEvent) {
|
||||
if (event.button !== 0 || resizePointerId !== null) return
|
||||
resizeHandle = event.currentTarget as HTMLElement
|
||||
resizePointerId = event.pointerId
|
||||
resizeHandle.setPointerCapture(resizePointerId)
|
||||
resizeHandle.addEventListener('lostpointercapture', stopResize)
|
||||
dragStartX = event.clientX
|
||||
dragStartWidth = width.value
|
||||
resizing.value = true
|
||||
window.addEventListener('pointermove', handleResize)
|
||||
window.addEventListener('pointerup', stopResize)
|
||||
window.addEventListener('pointercancel', stopResize)
|
||||
window.addEventListener('blur', stopResize)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
function resizeWithKeyboard(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
event.preventDefault()
|
||||
preferredWidth.value = clampWidth(width.value + (event.key === 'ArrowLeft' ? 24 : -24))
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopResize()
|
||||
layout.setSidePanelContext(null)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="side-panel-layout relative flex min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" :inert="overlay && layout.sidePanelOpen">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Transition name="side-panel-backdrop">
|
||||
<div
|
||||
v-if="overlay && layout.sidePanelOpen"
|
||||
class="absolute inset-0 z-40 bg-black/15 dark:bg-black/35"
|
||||
aria-hidden="true"
|
||||
@click="layout.closeSidePanel()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
id="app-side-panel"
|
||||
ref="panel"
|
||||
tabindex="-1"
|
||||
:aria-label="title"
|
||||
:aria-hidden="!layout.sidePanelOpen"
|
||||
:inert="!layout.sidePanelOpen"
|
||||
class="side-panel relative z-[41] h-full shrink-0 overflow-hidden outline-none"
|
||||
:class="{ 'side-panel-overlay': overlay, 'side-panel-resizing': resizing }"
|
||||
:style="{ width: layout.sidePanelOpen ? `${width}px` : '0px' }"
|
||||
@keydown.esc.stop="layout.closeSidePanel()"
|
||||
>
|
||||
<!-- Keep the inner width stable while the outer shell collapses. -->
|
||||
<div class="h-full" :style="{ width: `${width}px` }">
|
||||
<div
|
||||
class="relative flex h-full min-h-0 flex-col border-l border-gray-200 bg-white dark:border-white/5 dark:bg-page-dark"
|
||||
>
|
||||
<div
|
||||
class="group absolute inset-y-0 left-0 z-10 w-1.5 cursor-col-resize touch-none"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
:aria-label="t('common.sidePanel.resize')"
|
||||
:aria-valuemin="Math.min(minimumWidth, maximumWidth)"
|
||||
:aria-valuemax="maximumWidth"
|
||||
:aria-valuenow="width"
|
||||
tabindex="0"
|
||||
@pointerdown.prevent="startResize"
|
||||
@keydown="resizeWithKeyboard"
|
||||
>
|
||||
<div class="h-full w-px transition-colors group-hover:bg-primary-400 group-focus:bg-primary-500" />
|
||||
</div>
|
||||
|
||||
<header class="flex h-12 shrink-0 items-center gap-2 border-b border-gray-100 px-3 dark:border-white/5">
|
||||
<UButton
|
||||
v-if="canGoBack"
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="t('common.back')"
|
||||
@click="layout.returnToContactPanel()"
|
||||
/>
|
||||
<h2 class="min-w-0 flex-1 truncate text-sm font-medium text-gray-900 dark:text-gray-100">{{ title }}</h2>
|
||||
<UButton
|
||||
icon="i-lucide-panel-right-close"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="t('common.sidePanel.collapse')"
|
||||
@click="layout.closeSidePanel()"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatRecordWorkspace
|
||||
v-if="layout.chatRecordQuery"
|
||||
v-show="records"
|
||||
:initial-query="layout.chatRecordQuery"
|
||||
class="min-h-0 flex-1"
|
||||
mode="drawer"
|
||||
/>
|
||||
<div
|
||||
v-if="layout.sidePanelHasContact"
|
||||
v-show="!records"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<slot name="contact" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.side-panel {
|
||||
transition: width 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.side-panel-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.side-panel-resizing {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.side-panel-backdrop-enter-active,
|
||||
.side-panel-backdrop-leave-active {
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.side-panel-backdrop-enter-from,
|
||||
.side-panel-backdrop-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.side-panel,
|
||||
.side-panel-backdrop-enter-active,
|
||||
.side-panel-backdrop-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -36,7 +36,6 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
(e: 'openIncrementalImport'): void
|
||||
(e: 'openMemberManagement'): void
|
||||
(e: 'openChatRecord'): void
|
||||
(e: 'update:fullRange', value: { start: number; end: number } | null): void
|
||||
(e: 'update:availableYears', value: number[]): void
|
||||
(e: 'timeRangeInitialized', hasRange: boolean): void
|
||||
@@ -59,16 +58,6 @@ const navigationItems = computed(() =>
|
||||
<PageHeader :title="title" :avatar="avatar" size="compact" :icon="icon" :icon-class="iconClass">
|
||||
<template #actions>
|
||||
<template v-if="showSessionActions && layoutStore.effectiveToolsPanelPosition === 'header'">
|
||||
<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="emit('openChatRecord')"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip :text="t('analysis.tooltip.incrementalImport')">
|
||||
<UButton
|
||||
icon="i-heroicons-plus-circle"
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"back": "Back",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
"expandSidebar": "Expand sidebar",
|
||||
"sidePanel": {
|
||||
"expand": "Expand side panel",
|
||||
"collapse": "Collapse side panel",
|
||||
"resize": "Resize side panel"
|
||||
},
|
||||
"done": "Done",
|
||||
"retry": "Retry",
|
||||
"initFailed": "App initialization failed",
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"back": "戻る",
|
||||
"collapseSidebar": "サイドバーを折りたたむ",
|
||||
"expandSidebar": "サイドバーを展開",
|
||||
"sidePanel": {
|
||||
"expand": "右パネルを開く",
|
||||
"collapse": "右パネルを閉じる",
|
||||
"resize": "パネルの幅を調整"
|
||||
},
|
||||
"done": "完了",
|
||||
"retry": "リトライ",
|
||||
"initFailed": "アプリの初期化に失敗しました",
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"back": "返回",
|
||||
"collapseSidebar": "收起侧栏",
|
||||
"expandSidebar": "展开侧栏",
|
||||
"sidePanel": {
|
||||
"expand": "展开右侧面板",
|
||||
"collapse": "收起右侧面板",
|
||||
"resize": "调整面板宽度"
|
||||
},
|
||||
"done": "完成",
|
||||
"retry": "重试",
|
||||
"initFailed": "应用初始化失败",
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"back": "返回上一頁",
|
||||
"collapseSidebar": "收起側邊欄",
|
||||
"expandSidebar": "展開側邊欄",
|
||||
"sidePanel": {
|
||||
"expand": "展開右側面板",
|
||||
"collapse": "收起右側面板",
|
||||
"resize": "調整面板寬度"
|
||||
},
|
||||
"done": "完成",
|
||||
"retry": "重試",
|
||||
"initFailed": "應用程式初始化失敗",
|
||||
|
||||
+121
-118
@@ -15,6 +15,7 @@ import { useChatScroll } from '@/components/AIChat/composables/useChatScroll'
|
||||
import { useProgressiveChatHistory } from '@/components/AIChat/composables/useProgressiveChatHistory'
|
||||
import { groupMessagesToQAPairs } from '@/components/AIChat/utils/chatMessages'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import SidePanelLayout from '@/components/layout/SidePanelLayout.vue'
|
||||
import { useAIService } from '@/services'
|
||||
import type { AIChat } from '@/services/ai/types'
|
||||
import { useAIChatStore } from '@/stores/aiChat'
|
||||
@@ -297,134 +298,136 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="main-content flex min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationList
|
||||
session-id=""
|
||||
:active-id="currentAIChatId"
|
||||
:conversations="conversations"
|
||||
:loading="conversationsLoading"
|
||||
:disabled="isAIThinking"
|
||||
@select="selectConversation"
|
||||
@create="startNewConversation"
|
||||
@rename="renameConversation"
|
||||
@delete="deleteConversation"
|
||||
/>
|
||||
<SidePanelLayout :context-key="currentAIChatId">
|
||||
<div class="main-content flex min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationList
|
||||
session-id=""
|
||||
:active-id="currentAIChatId"
|
||||
:conversations="conversations"
|
||||
:loading="conversationsLoading"
|
||||
:disabled="isAIThinking"
|
||||
@select="selectConversation"
|
||||
@create="startNewConversation"
|
||||
@rename="renameConversation"
|
||||
@delete="deleteConversation"
|
||||
/>
|
||||
|
||||
<section class="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div ref="messagesContainer" class="absolute inset-0 overflow-y-auto px-5 py-6">
|
||||
<div v-if="initializing" class="flex h-full items-center justify-center">
|
||||
<UIcon name="i-lucide-loader-2" class="h-5 w-5 animate-spin text-gray-400" />
|
||||
</div>
|
||||
<section class="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div ref="messagesContainer" class="absolute inset-0 overflow-y-auto px-5 py-6">
|
||||
<div v-if="initializing" class="flex h-full items-center justify-center">
|
||||
<UIcon name="i-lucide-loader-2" class="h-5 w-5 animate-spin text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="messages.length === 0"
|
||||
class="mx-auto flex h-full max-w-lg flex-col items-center justify-center pb-16 text-center"
|
||||
>
|
||||
<div
|
||||
class="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary-50 text-primary-500 dark:bg-primary-500/10 dark:text-primary-300"
|
||||
v-else-if="messages.length === 0"
|
||||
class="mx-auto flex h-full max-w-lg flex-col items-center justify-center pb-16 text-center"
|
||||
>
|
||||
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-6 w-6" />
|
||||
</div>
|
||||
<h2 class="text-base text-gray-800 dark:text-gray-100">{{ t('ai.global.empty.title') }}</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-gray-500 dark:text-gray-400">
|
||||
{{ t('ai.global.empty.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="mx-auto max-w-3xl space-y-4">
|
||||
<div v-if="hasOlderPairs" class="flex justify-center pb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 items-center gap-1.5 rounded-full px-3 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
@click="loadOlderPairs"
|
||||
<div
|
||||
class="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary-50 text-primary-500 dark:bg-primary-500/10 dark:text-primary-300"
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-up" class="h-3.5 w-3.5" />
|
||||
{{ t('ai.chat.history.loadEarlier') }}
|
||||
</button>
|
||||
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-6 w-6" />
|
||||
</div>
|
||||
<h2 class="text-base text-gray-800 dark:text-gray-100">{{ t('ai.global.empty.title') }}</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-gray-500 dark:text-gray-400">
|
||||
{{ t('ai.global.empty.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-for="pair in visiblePairs" :key="pair.id">
|
||||
<ChatMessage
|
||||
v-if="pair.standalone"
|
||||
:role="pair.standalone.role"
|
||||
:content="pair.standalone.content"
|
||||
:timestamp="pair.standalone.timestamp"
|
||||
/>
|
||||
<div v-else class="qa-pair space-y-6 pb-4">
|
||||
<ChatMessage
|
||||
v-if="pair.user"
|
||||
:role="pair.user.role"
|
||||
:message-id="pair.user.id"
|
||||
:content="pair.user.content"
|
||||
:timestamp="pair.user.timestamp"
|
||||
:entity-refs="pair.user.entityRefs"
|
||||
:highlighted="highlightedSourceMessageId === pair.user.id"
|
||||
/>
|
||||
<ChatMessage
|
||||
v-if="
|
||||
pair.assistant &&
|
||||
(pair.assistant.content || pair.assistant.contentBlocks?.length || !pair.assistant.isStreaming)
|
||||
"
|
||||
:role="pair.assistant.role"
|
||||
:message-id="pair.assistant.id"
|
||||
:content="pair.assistant.content"
|
||||
:timestamp="pair.assistant.timestamp"
|
||||
:is-streaming="pair.assistant.isStreaming"
|
||||
:process-duration-ms="pair.assistant.processDurationMs"
|
||||
:content-blocks="pair.assistant.contentBlocks"
|
||||
:show-capture-button="!pair.assistant.isStreaming"
|
||||
:active-tool="pair.assistant.isStreaming ? state.currentToolStatus : null"
|
||||
:highlighted="highlightedSourceMessageId === pair.assistant.id"
|
||||
/>
|
||||
<AIThinkingIndicator
|
||||
v-else-if="pair.assistant?.isStreaming"
|
||||
:current-tool-status="state.currentToolStatus"
|
||||
:agent-status="state.agentStatus"
|
||||
/>
|
||||
<div v-else class="mx-auto max-w-3xl space-y-4">
|
||||
<div v-if="hasOlderPairs" class="flex justify-center pb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 items-center gap-1.5 rounded-full px-3 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
@click="loadOlderPairs"
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-up" class="h-3.5 w-3.5" />
|
||||
{{ t('ai.chat.history.loadEarlier') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-for="pair in visiblePairs" :key="pair.id">
|
||||
<ChatMessage
|
||||
v-if="pair.standalone"
|
||||
:role="pair.standalone.role"
|
||||
:content="pair.standalone.content"
|
||||
:timestamp="pair.standalone.timestamp"
|
||||
/>
|
||||
<div v-else class="qa-pair space-y-6 pb-4">
|
||||
<ChatMessage
|
||||
v-if="pair.user"
|
||||
:role="pair.user.role"
|
||||
:message-id="pair.user.id"
|
||||
:content="pair.user.content"
|
||||
:timestamp="pair.user.timestamp"
|
||||
:entity-refs="pair.user.entityRefs"
|
||||
:highlighted="highlightedSourceMessageId === pair.user.id"
|
||||
/>
|
||||
<ChatMessage
|
||||
v-if="
|
||||
pair.assistant &&
|
||||
(pair.assistant.content || pair.assistant.contentBlocks?.length || !pair.assistant.isStreaming)
|
||||
"
|
||||
:role="pair.assistant.role"
|
||||
:message-id="pair.assistant.id"
|
||||
:content="pair.assistant.content"
|
||||
:timestamp="pair.assistant.timestamp"
|
||||
:is-streaming="pair.assistant.isStreaming"
|
||||
:process-duration-ms="pair.assistant.processDurationMs"
|
||||
:content-blocks="pair.assistant.contentBlocks"
|
||||
:show-capture-button="!pair.assistant.isStreaming"
|
||||
:active-tool="pair.assistant.isStreaming ? state.currentToolStatus : null"
|
||||
:highlighted="highlightedSourceMessageId === pair.assistant.id"
|
||||
/>
|
||||
<AIThinkingIndicator
|
||||
v-else-if="pair.assistant?.isStreaming"
|
||||
:current-tool-status="state.currentToolStatus"
|
||||
:agent-status="state.agentStatus"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="fade-up">
|
||||
<button
|
||||
v-if="showScrollToBottom"
|
||||
type="button"
|
||||
class="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-gray-800/90 px-3 py-1.5 text-xs text-white shadow-lg backdrop-blur-sm hover:bg-gray-700 dark:bg-gray-700/90"
|
||||
@click="handleScrollToBottom"
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-down" class="h-3.5 w-3.5" />
|
||||
{{ t('ai.chat.scrollToBottom') }}
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 px-4 pb-4">
|
||||
<div
|
||||
class="relative mx-auto max-w-3xl overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5"
|
||||
>
|
||||
<AIChatInput
|
||||
ref="chatInputRef"
|
||||
embedded
|
||||
:disabled="isAIThinking"
|
||||
:status="isAIThinking ? 'streaming' : 'ready'"
|
||||
mention-scope="global"
|
||||
:skills-enabled="false"
|
||||
:placeholder="t('ai.global.input.placeholder')"
|
||||
@send="handleSend"
|
||||
@stop="aiChatStore.stopGeneration(chatKey)"
|
||||
/>
|
||||
<ChatStatusBar
|
||||
class="pb-1.5 pl-2 pr-[52px] pt-0.5"
|
||||
:session-token-usage="state.sessionTokenUsage"
|
||||
:agent-status="state.agentStatus"
|
||||
:estimated-context-tokens="estimatedContextTokens"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="fade-up">
|
||||
<button
|
||||
v-if="showScrollToBottom"
|
||||
type="button"
|
||||
class="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-gray-800/90 px-3 py-1.5 text-xs text-white shadow-lg backdrop-blur-sm hover:bg-gray-700 dark:bg-gray-700/90"
|
||||
@click="handleScrollToBottom"
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-down" class="h-3.5 w-3.5" />
|
||||
{{ t('ai.chat.scrollToBottom') }}
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 px-4 pb-4">
|
||||
<div
|
||||
class="relative mx-auto max-w-3xl overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5"
|
||||
>
|
||||
<AIChatInput
|
||||
ref="chatInputRef"
|
||||
embedded
|
||||
:disabled="isAIThinking"
|
||||
:status="isAIThinking ? 'streaming' : 'ready'"
|
||||
mention-scope="global"
|
||||
:skills-enabled="false"
|
||||
:placeholder="t('ai.global.input.placeholder')"
|
||||
@send="handleSend"
|
||||
@stop="aiChatStore.stopGeneration(chatKey)"
|
||||
/>
|
||||
<ChatStatusBar
|
||||
class="pb-1.5 pl-2 pr-[52px] pt-0.5"
|
||||
:session-token-usage="state.sessionTokenUsage"
|
||||
:agent-status="state.agentStatus"
|
||||
:estimated-context-tokens="estimatedContextTokens"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SidePanelLayout>
|
||||
|
||||
<AIMemoryManagerModal v-model:open="showMemoryManager" @view-source="handleViewMemorySource" />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import RankingView from '@/components/analysis/ranking/RankingView.vue'
|
||||
import MemberList from '@/components/common/member/MemberList.vue'
|
||||
import NicknameHistoryEntry from './components/member/NicknameHistoryEntry.vue'
|
||||
import SessionAnalysisHeader from '@/components/layout/session/SessionAnalysisHeader.vue'
|
||||
import SidePanelLayout from '@/components/layout/SidePanelLayout.vue'
|
||||
import SemanticIndexSessionModal from '@/components/analysis/SemanticIndexSessionModal.vue'
|
||||
import OwnerPromptModal from '@/components/analysis/member/OwnerPromptModal.vue'
|
||||
import IncrementalImportModal from '@/components/analysis/IncrementalImportModal.vue'
|
||||
@@ -48,7 +49,7 @@ const showMemberManagementModal = ref(false)
|
||||
|
||||
// 打开聊天记录查看器
|
||||
function openChatRecordViewer() {
|
||||
layoutStore.openChatRecordDrawer({})
|
||||
layoutStore.openChatRecords({})
|
||||
}
|
||||
|
||||
// Tab 配置
|
||||
@@ -135,63 +136,64 @@ const filteredMemberCount = computed(() => {
|
||||
:initial-time-state="initialTimeState"
|
||||
@open-incremental-import="showIncrementalImportModal = true"
|
||||
@open-member-management="showMemberManagementModal = true"
|
||||
@open-chat-record="openChatRecordViewer"
|
||||
@update:full-range="fullTimeRange = $event"
|
||||
@update:available-years="availableYears = $event"
|
||||
@time-range-initialized="handleTimeRangeInitialized"
|
||||
/>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="relative flex-1 overflow-y-auto">
|
||||
<!-- Loading Overlay -->
|
||||
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
|
||||
<SidePanelLayout :session-id="currentSessionId">
|
||||
<!-- Tab Content -->
|
||||
<div class="relative flex-1 overflow-y-auto">
|
||||
<!-- Loading Overlay -->
|
||||
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
|
||||
|
||||
<div class="h-full">
|
||||
<Transition name="tab-slide" mode="out-in">
|
||||
<GroupChatInsights
|
||||
v-if="activeTab === 'insights'"
|
||||
:key="'insights-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session="session"
|
||||
:member-activity="memberActivity"
|
||||
:message-types="messageTypes"
|
||||
:hourly-activity="hourlyActivity"
|
||||
:daily-activity="dailyActivity"
|
||||
:time-range="fullTimeRange"
|
||||
:filtered-message-count="filteredMessageCount"
|
||||
:filtered-member-count="filteredMemberCount"
|
||||
:time-filter="timeFilter"
|
||||
@select-tab="trackInsightTab"
|
||||
/>
|
||||
<RankingView
|
||||
v-else-if="activeTab === 'ranking'"
|
||||
:key="'ranking-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
<ChatExplorer
|
||||
v-else-if="activeTab === 'ai-chat'"
|
||||
:key="'ai-chat-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
chat-type="group"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
<MemoryTab
|
||||
v-else-if="activeTab === 'memory'"
|
||||
:key="'memory-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
/>
|
||||
<MoreTab
|
||||
v-else-if="activeTab === 'more'"
|
||||
:key="'more-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
chat-type="group"
|
||||
/>
|
||||
</Transition>
|
||||
<div class="h-full">
|
||||
<Transition name="tab-slide" mode="out-in">
|
||||
<GroupChatInsights
|
||||
v-if="activeTab === 'insights'"
|
||||
:key="'insights-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session="session"
|
||||
:member-activity="memberActivity"
|
||||
:message-types="messageTypes"
|
||||
:hourly-activity="hourlyActivity"
|
||||
:daily-activity="dailyActivity"
|
||||
:time-range="fullTimeRange"
|
||||
:filtered-message-count="filteredMessageCount"
|
||||
:filtered-member-count="filteredMemberCount"
|
||||
:time-filter="timeFilter"
|
||||
@select-tab="trackInsightTab"
|
||||
/>
|
||||
<RankingView
|
||||
v-else-if="activeTab === 'ranking'"
|
||||
:key="'ranking-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
<ChatExplorer
|
||||
v-else-if="activeTab === 'ai-chat'"
|
||||
:key="'ai-chat-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
chat-type="group"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
<MemoryTab
|
||||
v-else-if="activeTab === 'memory'"
|
||||
:key="'memory-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
/>
|
||||
<MoreTab
|
||||
v-else-if="activeTab === 'more'"
|
||||
:key="'more-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
chat-type="group"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanelLayout>
|
||||
|
||||
<ActionToolsPanel
|
||||
@open-incremental-import="showIncrementalImportModal = true"
|
||||
@@ -267,6 +269,7 @@ const filteredMemberCount = computed(() => {
|
||||
:session-id="currentSessionId"
|
||||
:show-header="false"
|
||||
chat-type="group"
|
||||
@view-records="showMemberManagementModal = false"
|
||||
@data-changed="
|
||||
() => {
|
||||
loadData()
|
||||
|
||||
@@ -4,14 +4,12 @@ import { useI18n } from 'vue-i18n'
|
||||
import type { ContactItem } from '@openchatlab/shared-types'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedKey: string | null
|
||||
contact: ContactItem | null
|
||||
isLoading: boolean
|
||||
isFriendActionLoading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
openSource: [source: ContactItem['sourceSessions'][number]]
|
||||
viewSourceRecords: [source: ContactItem['sourceSessions'][number]]
|
||||
markFriend: []
|
||||
@@ -37,175 +35,132 @@ function avatarText(contact: ContactItem): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="contact-detail-panel">
|
||||
<aside
|
||||
v-if="selectedKey"
|
||||
class="flex h-full w-[420px] max-w-[80vw] shrink-0 flex-col border-l border-gray-200 bg-white dark:border-gray-800 dark:bg-page-dark"
|
||||
style="-webkit-app-region: no-drag"
|
||||
<div class="flex min-h-0 flex-1 flex-col" data-testid="contact-detail-content">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('contacts.detail.title') }}</h3>
|
||||
<UButton
|
||||
:aria-label="t('contacts.actions.clearSelection')"
|
||||
icon="i-heroicons-x-mark"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="emit('clear')"
|
||||
/>
|
||||
</div>
|
||||
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
|
||||
<span>{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
|
||||
<span>{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="contact" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<section class="px-5 py-5">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="relative shrink-0">
|
||||
<img
|
||||
v-if="contact.avatar"
|
||||
:src="contact.avatar"
|
||||
:alt="contact.displayName"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-16 w-16 rounded-2xl object-cover shadow-sm ring-2 ring-white dark:ring-page-dark"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-gray-155 to-gray-200 text-lg font-bold text-gray-700 dark:from-gray-800 dark:to-page-dark dark:text-gray-200"
|
||||
>
|
||||
{{ avatarText(contact) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 pt-1">
|
||||
<h2 class="truncate text-lg font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{{ contact.displayName }}
|
||||
</h2>
|
||||
<p class="mt-1 truncate font-mono text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ contact.platform }} · {{ contact.platformId }}
|
||||
</p>
|
||||
<div v-if="visibleAliases.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="alias in visibleAliases.slice(0, 4)"
|
||||
:key="alias"
|
||||
class="max-w-full truncate rounded-md bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/5 dark:text-gray-400"
|
||||
>
|
||||
{{ alias }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showFriendActions" class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
v-if="canMarkFriend"
|
||||
icon="i-lucide-user-plus"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
:loading="isFriendActionLoading"
|
||||
@click="emit('markFriend')"
|
||||
>
|
||||
{{ t('contacts.actions.markFriend') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else-if="canUnmarkFriend"
|
||||
icon="i-lucide-user-minus"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
:loading="isFriendActionLoading"
|
||||
@click="emit('unmarkFriend')"
|
||||
>
|
||||
{{ t('contacts.actions.unmarkFriend') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-gray-100 px-5 py-4 dark:border-white/5">
|
||||
<h3 class="mb-3 text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{{ t('contacts.detail.sources') }}
|
||||
</h3>
|
||||
<div class="space-y-2.5">
|
||||
<div v-else-if="contact" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<section class="px-5 py-5">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="relative shrink-0">
|
||||
<img
|
||||
v-if="contact.avatar"
|
||||
:src="contact.avatar"
|
||||
:alt="contact.displayName"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-16 w-16 rounded-2xl object-cover shadow-sm ring-2 ring-white dark:ring-page-dark"
|
||||
/>
|
||||
<div
|
||||
v-for="source in contact.sourceSessions"
|
||||
:key="source.id"
|
||||
class="w-full rounded-2xl border border-gray-100 bg-white/40 px-3.5 py-3 text-left transition duration-300 hover:border-gray-200 dark:border-white/5 dark:bg-page-dark/10 dark:hover:border-white/10"
|
||||
v-else
|
||||
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-gray-155 to-gray-200 text-lg font-bold text-gray-700 dark:from-gray-800 dark:to-page-dark dark:text-gray-200"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ source.name }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-[10px] font-bold text-gray-400 transition group-hover/item:text-pink-500 dark:text-gray-500"
|
||||
>
|
||||
{{
|
||||
source.type === 'private'
|
||||
? t('contacts.detail.sourceType.private')
|
||||
: t('contacts.detail.sourceType.group')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1.5 truncate text-[11px] font-medium text-gray-400 dark:text-gray-500">
|
||||
{{
|
||||
source.privateMessageCount != null
|
||||
? t('contacts.metrics.privateMessages', { count: source.privateMessageCount })
|
||||
: t('contacts.metrics.groupSignals', { count: source.coOccurrenceCount ?? 0 })
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<UButton
|
||||
icon="i-lucide-arrow-up-right"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('openSource', source)"
|
||||
>
|
||||
{{ t('contacts.actions.openSourceSession') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-lucide-messages-square"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('viewSourceRecords', source)"
|
||||
>
|
||||
{{ t('contacts.actions.viewSourceRecords') }}
|
||||
</UButton>
|
||||
</div>
|
||||
{{ avatarText(contact) }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
<div class="min-w-0 flex-1 pt-1">
|
||||
<h2 class="truncate text-lg font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{{ contact.displayName }}
|
||||
</h2>
|
||||
<p class="mt-1 truncate font-mono text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ contact.platform }} · {{ contact.platformId }}
|
||||
</p>
|
||||
<div v-if="visibleAliases.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="alias in visibleAliases.slice(0, 4)"
|
||||
:key="alias"
|
||||
class="max-w-full truncate rounded-md bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/5 dark:text-gray-400"
|
||||
>
|
||||
{{ alias }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showFriendActions" class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
v-if="canMarkFriend"
|
||||
icon="i-lucide-user-plus"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
:loading="isFriendActionLoading"
|
||||
@click="emit('markFriend')"
|
||||
>
|
||||
{{ t('contacts.actions.markFriend') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else-if="canUnmarkFriend"
|
||||
icon="i-lucide-user-minus"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
:loading="isFriendActionLoading"
|
||||
@click="emit('unmarkFriend')"
|
||||
>
|
||||
{{ t('contacts.actions.unmarkFriend') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-gray-100 px-5 py-4 dark:border-white/5">
|
||||
<h3 class="mb-3 text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{{ t('contacts.detail.sources') }}
|
||||
</h3>
|
||||
<div class="space-y-2.5">
|
||||
<div
|
||||
v-for="source in contact.sourceSessions"
|
||||
:key="source.id"
|
||||
class="w-full rounded-2xl border border-gray-100 bg-white/40 px-3.5 py-3 text-left transition duration-300 hover:border-gray-200 dark:border-white/5 dark:bg-page-dark/10 dark:hover:border-white/10"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ source.name }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-[10px] font-bold text-gray-400 transition group-hover/item:text-pink-500 dark:text-gray-500"
|
||||
>
|
||||
{{
|
||||
source.type === 'private'
|
||||
? t('contacts.detail.sourceType.private')
|
||||
: t('contacts.detail.sourceType.group')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1.5 truncate text-[11px] font-medium text-gray-400 dark:text-gray-500">
|
||||
{{
|
||||
source.privateMessageCount != null
|
||||
? t('contacts.metrics.privateMessages', { count: source.privateMessageCount })
|
||||
: t('contacts.metrics.groupSignals', { count: source.coOccurrenceCount ?? 0 })
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<UButton
|
||||
icon="i-lucide-arrow-up-right"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('openSource', source)"
|
||||
>
|
||||
{{ t('contacts.actions.openSourceSession') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-lucide-messages-square"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('viewSourceRecords', source)"
|
||||
>
|
||||
{{ t('contacts.actions.viewSourceRecords') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-detail-panel-enter-active,
|
||||
.contact-detail-panel-leave-active {
|
||||
overflow: hidden;
|
||||
transition:
|
||||
width 0.22s ease,
|
||||
opacity 0.18s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.contact-detail-panel-enter-from,
|
||||
.contact-detail-panel-leave-to {
|
||||
width: 0 !important;
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.contact-detail-panel-enter-to,
|
||||
.contact-detail-panel-leave-from {
|
||||
width: 420px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@ import { LoadingState, ThemeCard } from '@/components/UI'
|
||||
import { SectionTabs } from '@/components/navigation'
|
||||
import { usePeoplePageHeader } from '../people-page-header'
|
||||
import ContactDetailPanel from './components/ContactDetailPanel.vue'
|
||||
import SidePanelLayout from '@/components/layout/SidePanelLayout.vue'
|
||||
import ContactsStatusBlocks from './components/ContactsStatusBlocks.vue'
|
||||
import {
|
||||
resolveFriendActionScrollTop,
|
||||
@@ -616,6 +617,7 @@ function clearCompletedContactsTaskProgress() {
|
||||
|
||||
async function selectContact(contact: ContactListItem) {
|
||||
selectedKey.value = contact.key
|
||||
layoutStore.openContactPanel()
|
||||
await loadSelectedContactDetail(contact.key)
|
||||
}
|
||||
|
||||
@@ -730,6 +732,7 @@ async function refreshContactsPageMetadata() {
|
||||
function clearSelectedContact() {
|
||||
selectedKey.value = null
|
||||
selectedContact.value = null
|
||||
layoutStore.clearContactPanel()
|
||||
isDetailLoading.value = false
|
||||
}
|
||||
|
||||
@@ -801,7 +804,7 @@ function openSourceSession(source: ContactItem['sourceSessions'][number]) {
|
||||
}
|
||||
|
||||
function viewSourceSessionRecords(source: ContactItem['sourceSessions'][number]) {
|
||||
layoutStore.openChatRecordDrawer({ sessionId: source.id })
|
||||
layoutStore.openChatRecords({ sessionId: source.id })
|
||||
}
|
||||
|
||||
function handleListScroll(event: Event) {
|
||||
@@ -887,7 +890,7 @@ function getGroupSectionScrollTop(): number | null {
|
||||
</template>
|
||||
</SectionTabs>
|
||||
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<SidePanelLayout>
|
||||
<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-3 pt-4">
|
||||
<ContactsStatusBlocks
|
||||
@@ -1134,18 +1137,18 @@ function getGroupSectionScrollTop(): number | null {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ContactDetailPanel
|
||||
:selected-key="selectedKey"
|
||||
:contact="selectedContact"
|
||||
:is-loading="isDetailLoading"
|
||||
:is-friend-action-loading="isFriendActionSaving"
|
||||
@clear="clearSelectedContact"
|
||||
@open-source="openSourceSession"
|
||||
@view-source-records="viewSourceSessionRecords"
|
||||
@mark-friend="markSelectedContactAsFriend"
|
||||
@unmark-friend="unmarkSelectedContactAsFriend"
|
||||
/>
|
||||
</div>
|
||||
<template #contact>
|
||||
<ContactDetailPanel
|
||||
:contact="selectedContact"
|
||||
:is-loading="isDetailLoading"
|
||||
:is-friend-action-loading="isFriendActionSaving"
|
||||
@open-source="openSourceSession"
|
||||
@view-source-records="viewSourceSessionRecords"
|
||||
@mark-friend="markSelectedContactAsFriend"
|
||||
@unmark-friend="unmarkSelectedContactAsFriend"
|
||||
/>
|
||||
</template>
|
||||
</SidePanelLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ChatExplorer } from '@/components/AIChat'
|
||||
import PrivateChatInsights from './components/insights/PrivateChatInsights.vue'
|
||||
import MemberList from '@/components/common/member/MemberList.vue'
|
||||
import SessionAnalysisHeader from '@/components/layout/session/SessionAnalysisHeader.vue'
|
||||
import SidePanelLayout from '@/components/layout/SidePanelLayout.vue'
|
||||
import SemanticIndexSessionModal from '@/components/analysis/SemanticIndexSessionModal.vue'
|
||||
import OwnerPromptModal from '@/components/analysis/member/OwnerPromptModal.vue'
|
||||
import IncrementalImportModal from '@/components/analysis/IncrementalImportModal.vue'
|
||||
@@ -46,7 +47,7 @@ const showMemberManagementModal = ref(false)
|
||||
|
||||
// 打开聊天记录查看器
|
||||
function openChatRecordViewer() {
|
||||
layoutStore.openChatRecordDrawer({})
|
||||
layoutStore.openChatRecords({})
|
||||
}
|
||||
|
||||
// Tab 配置 - 私聊包含洞察、AI 对话、记忆和更多工具
|
||||
@@ -156,56 +157,57 @@ const otherMemberAvatar = computed(() => {
|
||||
:initial-time-state="initialTimeState"
|
||||
@open-incremental-import="showIncrementalImportModal = true"
|
||||
@open-member-management="showMemberManagementModal = true"
|
||||
@open-chat-record="openChatRecordViewer"
|
||||
@update:full-range="fullTimeRange = $event"
|
||||
@time-range-initialized="handleTimeRangeInitialized"
|
||||
/>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="relative flex-1 overflow-y-auto">
|
||||
<!-- Loading Overlay -->
|
||||
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
|
||||
<SidePanelLayout :session-id="currentSessionId">
|
||||
<!-- Tab Content -->
|
||||
<div class="relative flex-1 overflow-y-auto">
|
||||
<!-- Loading Overlay -->
|
||||
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
|
||||
|
||||
<div class="h-full">
|
||||
<Transition name="tab-slide" mode="out-in">
|
||||
<PrivateChatInsights
|
||||
v-if="activeTab === 'insights'"
|
||||
:key="'insights-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session="session"
|
||||
:member-activity="memberActivity"
|
||||
:message-types="messageTypes"
|
||||
:hourly-activity="hourlyActivity"
|
||||
:daily-activity="dailyActivity"
|
||||
:time-range="fullTimeRange"
|
||||
:filtered-message-count="filteredMessageCount"
|
||||
:filtered-member-count="filteredMemberCount"
|
||||
:time-filter="timeFilter"
|
||||
@select-tab="trackInsightTab"
|
||||
/>
|
||||
<ChatExplorer
|
||||
v-else-if="activeTab === 'ai-chat'"
|
||||
:key="'ai-chat-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
chat-type="private"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
<MemoryTab
|
||||
v-else-if="activeTab === 'memory'"
|
||||
:key="'memory-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
/>
|
||||
<MoreTab
|
||||
v-else-if="activeTab === 'more'"
|
||||
:key="'more-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
chat-type="private"
|
||||
/>
|
||||
</Transition>
|
||||
<div class="h-full">
|
||||
<Transition name="tab-slide" mode="out-in">
|
||||
<PrivateChatInsights
|
||||
v-if="activeTab === 'insights'"
|
||||
:key="'insights-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session="session"
|
||||
:member-activity="memberActivity"
|
||||
:message-types="messageTypes"
|
||||
:hourly-activity="hourlyActivity"
|
||||
:daily-activity="dailyActivity"
|
||||
:time-range="fullTimeRange"
|
||||
:filtered-message-count="filteredMessageCount"
|
||||
:filtered-member-count="filteredMemberCount"
|
||||
:time-filter="timeFilter"
|
||||
@select-tab="trackInsightTab"
|
||||
/>
|
||||
<ChatExplorer
|
||||
v-else-if="activeTab === 'ai-chat'"
|
||||
:key="'ai-chat-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
chat-type="private"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
<MemoryTab
|
||||
v-else-if="activeTab === 'memory'"
|
||||
:key="'memory-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
/>
|
||||
<MoreTab
|
||||
v-else-if="activeTab === 'more'"
|
||||
:key="'more-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
chat-type="private"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanelLayout>
|
||||
|
||||
<ActionToolsPanel
|
||||
@open-incremental-import="showIncrementalImportModal = true"
|
||||
@@ -277,6 +279,7 @@ const otherMemberAvatar = computed(() => {
|
||||
:session-id="currentSessionId"
|
||||
:show-header="false"
|
||||
chat-type="private"
|
||||
@view-records="showMemberManagementModal = false"
|
||||
@data-changed="invalidateAnalysisData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -41,3 +41,48 @@ test('layout persistence upgrades older state and restores a saved drawer width'
|
||||
|
||||
assert.equal(restoredStore.chatRecordDrawerWidth, 936)
|
||||
})
|
||||
|
||||
test('collapsing the side panel preserves the record target and reopening restores it', async () => {
|
||||
const pinia = createPersistedPinia(createMemoryStorage())
|
||||
const { useLayoutStore } = await import('./layout')
|
||||
const store = useLayoutStore(pinia)
|
||||
store.setSidePanelContext('session-a')
|
||||
store.openChatRecords({ scrollToMessageId: 42, keywords: ['hello'] })
|
||||
store.closeSidePanel()
|
||||
assert.equal(store.sidePanelOpen, false)
|
||||
assert.deepEqual(store.chatRecordQuery, { sessionId: 'session-a', scrollToMessageId: 42, keywords: ['hello'] })
|
||||
store.toggleSidePanel()
|
||||
assert.equal(store.sidePanelOpen, true)
|
||||
assert.equal(store.sidePanelView, 'records')
|
||||
})
|
||||
|
||||
test('contact source records share one panel and can return to the selected contact', async () => {
|
||||
const pinia = createPersistedPinia(createMemoryStorage())
|
||||
const { useLayoutStore } = await import('./layout')
|
||||
const store = useLayoutStore(pinia)
|
||||
store.setSidePanelContext(null)
|
||||
store.openContactPanel()
|
||||
store.openChatRecords({ sessionId: 'contact-source', scrollToMessageId: 7 })
|
||||
assert.equal(store.sidePanelView, 'records')
|
||||
assert.equal(store.sidePanelHasContact, true)
|
||||
store.returnToContactPanel()
|
||||
assert.equal(store.sidePanelView, 'contact')
|
||||
assert.equal(store.sidePanelOpen, true)
|
||||
})
|
||||
|
||||
test('changing page context clears stale records and opens only the new session', async () => {
|
||||
const pinia = createPersistedPinia(createMemoryStorage())
|
||||
const { useLayoutStore } = await import('./layout')
|
||||
const store = useLayoutStore(pinia)
|
||||
store.setSidePanelContext('session-a')
|
||||
store.openChatRecords({ scrollToMessageId: 42 })
|
||||
store.setSidePanelContext('session-b')
|
||||
assert.equal(store.sidePanelOpen, false)
|
||||
assert.equal(store.chatRecordQuery, null)
|
||||
store.toggleSidePanel()
|
||||
assert.deepEqual(store.chatRecordQuery, { sessionId: 'session-b' })
|
||||
store.setSidePanelContext(null)
|
||||
assert.equal(store.canOpenSidePanel, false)
|
||||
store.openChatRecords({ scrollToMessageId: 42 })
|
||||
assert.equal(store.sidePanelOpen, false)
|
||||
})
|
||||
+65
-18
@@ -14,9 +14,15 @@ export const useLayoutStore = defineStore(
|
||||
const isAIChatSidebarCollapsed = ref(false)
|
||||
const showScreenCaptureModal = ref(false)
|
||||
const screenCaptureImage = ref<string | null>(null)
|
||||
const showChatRecordDrawer = ref(false)
|
||||
const sidePanelOpen = ref(false)
|
||||
const sidePanelView = ref<'records' | 'contact' | null>(null)
|
||||
const sidePanelSessionId = ref<string | null>(null)
|
||||
const sidePanelHasContact = ref(false)
|
||||
const chatRecordQuery = ref<ChatRecordQuery | null>(null)
|
||||
// Keep the existing persisted preference when replacing the drawer shell.
|
||||
const chatRecordDrawerWidth = ref(750)
|
||||
const contactPanelWidth = ref(420)
|
||||
const canOpenSidePanel = computed(() => Boolean(sidePanelView.value || sidePanelSessionId.value))
|
||||
|
||||
const isToolsPanelLocked = ref(false)
|
||||
const isToolsPanelMini = ref(false)
|
||||
@@ -60,22 +66,53 @@ export const useLayoutStore = defineStore(
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开聊天记录抽屉并设置查询参数
|
||||
*/
|
||||
function openChatRecordDrawer(query: ChatRecordQuery) {
|
||||
chatRecordQuery.value = query
|
||||
showChatRecordDrawer.value = true
|
||||
function setSidePanelContext(sessionId: string | null) {
|
||||
sidePanelOpen.value = false
|
||||
sidePanelView.value = null
|
||||
sidePanelHasContact.value = false
|
||||
chatRecordQuery.value = null
|
||||
sidePanelSessionId.value = sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭聊天记录抽屉并重置查询
|
||||
*/
|
||||
function closeChatRecordDrawer() {
|
||||
showChatRecordDrawer.value = false
|
||||
setTimeout(() => {
|
||||
chatRecordQuery.value = null
|
||||
}, 300)
|
||||
function openChatRecords(query: ChatRecordQuery) {
|
||||
const sessionId = query.sessionId || sidePanelSessionId.value
|
||||
if (!sessionId) return
|
||||
chatRecordQuery.value = { ...query, sessionId }
|
||||
sidePanelView.value = 'records'
|
||||
sidePanelOpen.value = true
|
||||
}
|
||||
|
||||
function openContactPanel() {
|
||||
sidePanelHasContact.value = true
|
||||
sidePanelView.value = 'contact'
|
||||
sidePanelOpen.value = true
|
||||
chatRecordQuery.value = null
|
||||
}
|
||||
|
||||
function returnToContactPanel() {
|
||||
if (!sidePanelHasContact.value) return
|
||||
sidePanelView.value = 'contact'
|
||||
sidePanelOpen.value = true
|
||||
}
|
||||
|
||||
function clearContactPanel() {
|
||||
sidePanelHasContact.value = false
|
||||
if (sidePanelView.value !== 'contact') return
|
||||
sidePanelView.value = null
|
||||
sidePanelOpen.value = false
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
sidePanelOpen.value = false
|
||||
}
|
||||
|
||||
function toggleSidePanel() {
|
||||
if (!canOpenSidePanel.value) return
|
||||
if (!sidePanelView.value) {
|
||||
openChatRecords({})
|
||||
return
|
||||
}
|
||||
sidePanelOpen.value = !sidePanelOpen.value
|
||||
}
|
||||
|
||||
function toggleToolsPanelLock() {
|
||||
@@ -116,9 +153,13 @@ export const useLayoutStore = defineStore(
|
||||
isToolsPanelOpen,
|
||||
showScreenCaptureModal,
|
||||
screenCaptureImage,
|
||||
showChatRecordDrawer,
|
||||
sidePanelOpen,
|
||||
sidePanelView,
|
||||
sidePanelHasContact,
|
||||
canOpenSidePanel,
|
||||
chatRecordQuery,
|
||||
chatRecordDrawerWidth,
|
||||
contactPanelWidth,
|
||||
showSettings,
|
||||
settingsTab,
|
||||
settingsSubTab,
|
||||
@@ -129,8 +170,13 @@ export const useLayoutStore = defineStore(
|
||||
toggleToolsPanelMini,
|
||||
openScreenCaptureModal,
|
||||
closeScreenCaptureModal,
|
||||
openChatRecordDrawer,
|
||||
closeChatRecordDrawer,
|
||||
setSidePanelContext,
|
||||
openChatRecords,
|
||||
openContactPanel,
|
||||
returnToContactPanel,
|
||||
clearContactPanel,
|
||||
closeSidePanel,
|
||||
toggleSidePanel,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
}
|
||||
@@ -145,6 +191,7 @@ export const useLayoutStore = defineStore(
|
||||
'isToolsPanelMini',
|
||||
'toolsPanelPosition',
|
||||
'chatRecordDrawerWidth',
|
||||
'contactPanelWidth',
|
||||
],
|
||||
storage: localStorage,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user