feat: 新增聊天记录查看器

This commit is contained in:
digua
2025-12-15 10:12:52 +08:00
parent 84af222b25
commit 266f8644e1
20 changed files with 1385 additions and 48 deletions
+3
View File
@@ -6,6 +6,7 @@ import { useRoute } from 'vue-router'
import Sidebar from '@/components/common/Sidebar.vue'
import SettingModal from '@/components/common/SettingModal.vue'
import ScreenCaptureModal from '@/components/common/ScreenCaptureModal.vue'
import { ChatRecordDrawer } from '@/components/common/ChatRecord'
const chatStore = useChatStore()
const { isInitialized } = storeToRefs(chatStore)
@@ -49,6 +50,8 @@ onMounted(async () => {
:image-data="chatStore.screenCaptureImage"
@update:open="(v) => (v ? null : chatStore.closeScreenCaptureModal())"
/>
<!-- 全局聊天记录查看器 -->
<ChatRecordDrawer />
</UApp>
</template>
+1
View File
@@ -21,6 +21,7 @@ declare module 'vue' {
UChatPrompt: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/ChatPrompt.vue')['default']
UChatPromptSubmit: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/ChatPromptSubmit.vue')['default']
UContextMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/ContextMenu.vue')['default']
UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
UInputTags: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/InputTags.vue')['default']
@@ -4,6 +4,7 @@ import type { RepeatAnalysis } from '@/types/chat'
import { ListPro } from '@/components/charts'
import { LoadingState, EmptyState, SectionCard } from '@/components/UI'
import { formatDate, getRankBadgeClass } from '@/utils'
import { useChatStore } from '@/stores/chat'
interface TimeFilter {
startTs?: number
@@ -15,6 +16,8 @@ const props = defineProps<{
timeFilter?: TimeFilter
}>()
const chatStore = useChatStore()
// ==================== 最火复读内容 ====================
const repeatAnalysis = ref<RepeatAnalysis | null>(null)
const isLoading = ref(false)
@@ -36,6 +39,16 @@ function truncateContent(content: string, maxLength = 30): string {
return content.slice(0, maxLength) + '...'
}
/**
* 查看复读内容的聊天记录上下文
*/
function viewRepeatContext(item: { content: string; firstMessageId: number }) {
chatStore.openChatRecordDrawer({
scrollToMessageId: item.firstMessageId,
highlightKeywords: [item.content],
})
}
// 监听 sessionId 和 timeFilter 变化
watch(
() => [props.sessionId, props.timeFilter],
@@ -81,6 +94,14 @@ watch(
<span>{{ item.count }} </span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>{{ formatDate(item.lastTs) }}</span>
<UButton
icon="i-heroicons-chat-bubble-left-right"
color="neutral"
variant="ghost"
size="xs"
title="查看聊天记录"
@click.stop="viewRepeatContext(item)"
/>
</div>
</div>
</template>
@@ -0,0 +1,103 @@
<script setup lang="ts">
/**
* 当前激活的筛选条件显示
* 以标签形式展示,支持单个删除和全部清除
*/
import dayjs from 'dayjs'
import type { ChatRecordQuery } from './types'
const props = defineProps<{
/** 当前查询条件 */
query: ChatRecordQuery
}>()
const emit = defineEmits<{
/** 移除单个筛选条件 */
(e: 'remove', key: keyof ChatRecordQuery): void
/** 清除所有筛选条件 */
(e: 'clear-all'): void
}>()
// 格式化时间
function formatDate(ts?: number): string {
if (!ts) return ''
return dayjs.unix(ts).format('MM-DD')
}
</script>
<template>
<div class="flex flex-wrap items-center gap-2 border-b border-gray-200 px-4 py-2 dark:border-gray-800">
<span class="text-xs text-gray-500">当前筛选:</span>
<!-- 定位消息 -->
<span
v-if="query.scrollToMessageId"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
<UIcon name="i-heroicons-hashtag" class="h-3 w-3" />
消息 #{{ query.scrollToMessageId }}
<button
class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"
@click="emit('remove', 'scrollToMessageId')"
>
<UIcon name="i-heroicons-x-mark" class="h-3 w-3" />
</button>
</span>
<!-- 成员筛选 -->
<span
v-if="query.memberId || query.memberName"
class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
<UIcon name="i-heroicons-user" class="h-3 w-3" />
{{ query.memberName || `ID: ${query.memberId}` }}
<button
class="ml-0.5 hover:text-green-900 dark:hover:text-green-200"
@click="emit('remove', 'memberId')"
>
<UIcon name="i-heroicons-x-mark" class="h-3 w-3" />
</button>
</span>
<!-- 时间范围 -->
<span
v-if="query.startTs || query.endTs"
class="inline-flex items-center gap-1 rounded-full bg-orange-100 px-2 py-0.5 text-xs text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
>
<UIcon name="i-heroicons-calendar" class="h-3 w-3" />
{{ formatDate(query.startTs) || '开始' }} ~ {{ formatDate(query.endTs) || '现在' }}
<button
class="ml-0.5 hover:text-orange-900 dark:hover:text-orange-200"
@click="(emit('remove', 'startTs'), emit('remove', 'endTs'))"
>
<UIcon name="i-heroicons-x-mark" class="h-3 w-3" />
</button>
</span>
<!-- 关键词 -->
<span
v-for="kw in query.keywords"
:key="kw"
class="inline-flex items-center gap-1 rounded-full bg-violet-100 px-2 py-0.5 text-xs text-violet-700 dark:bg-violet-900/30 dark:text-violet-400"
>
<UIcon name="i-heroicons-magnifying-glass" class="h-3 w-3" />
{{ kw }}
</span>
<button
v-if="query.keywords?.length"
class="text-xs text-gray-400 hover:text-gray-600"
@click="emit('remove', 'keywords')"
>
清除关键词
</button>
<!-- 清除全部 -->
<button
class="ml-auto text-xs text-gray-400 hover:text-red-500"
@click="emit('clear-all')"
>
清除全部
</button>
</div>
</template>
@@ -0,0 +1,144 @@
<script setup lang="ts">
/**
* 聊天记录查看器 Drawer
* 主组件,组合筛选面板、消息列表等子组件
*/
import { ref, watch, computed, toRaw, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import FilterPanel from './FilterPanel.vue'
import ActiveFilters from './ActiveFilters.vue'
import MessageList from './MessageList.vue'
import type { ChatRecordQuery } from './types'
const chatStore = useChatStore()
// 消息列表组件引用
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
// 本地查询条件(可编辑的副本)
const localQuery = ref<ChatRecordQuery>({})
// 筛选面板是否展开
const filterExpanded = ref(false)
// 消息数量
const messageCount = ref(0)
// 计算是否有任何筛选条件
const hasActiveFilters = computed(() => {
const q = localQuery.value
return !!(q.scrollToMessageId || q.memberId || q.memberName || q.startTs || q.endTs || q.keywords?.length)
})
// 应用筛选
function handleApplyFilter(query: ChatRecordQuery) {
localQuery.value = query
filterExpanded.value = false
}
// 重置筛选
function handleResetFilter() {
localQuery.value = {}
filterExpanded.value = false
}
// 移除单个筛选条件
function handleRemoveFilter(key: keyof ChatRecordQuery) {
const newQuery = { ...localQuery.value }
delete newQuery[key]
if (key === 'keywords') {
delete newQuery.highlightKeywords
}
if (key === 'memberId') {
delete newQuery.memberName
}
localQuery.value = newQuery
}
// 清除所有筛选
function handleClearAll() {
localQuery.value = {}
}
// 切换筛选面板
function toggleFilterPanel() {
filterExpanded.value = !filterExpanded.value
}
// 处理消息数量变化
function handleCountChange(count: number) {
messageCount.value = count
}
// 监听 Drawer 打开
watch(
() => chatStore.showChatRecordDrawer,
async (isOpen) => {
if (isOpen) {
// 复制查询参数到本地
const query = toRaw(chatStore.chatRecordQuery)
localQuery.value = query ? { ...query } : {}
// 如果有外部传入的筛选条件,默认不展开筛选面板
filterExpanded.value = false
// 等待 DOM 更新后主动触发加载
await nextTick()
messageListRef.value?.refresh()
} else {
// 关闭时清理
localQuery.value = {}
filterExpanded.value = false
messageCount.value = 0
}
}
)
</script>
<template>
<UDrawer v-model:open="chatStore.showChatRecordDrawer" direction="right" :handle="false">
<template #content>
<div class="flex h-full w-[580px] flex-col bg-white dark:bg-gray-900">
<!-- 头部 -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">聊天记录查看器</h3>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="sm"
@click="chatStore.closeChatRecordDrawer()"
/>
</div>
<!-- 筛选面板 -->
<FilterPanel
:query="localQuery"
:expanded="filterExpanded"
@apply="handleApplyFilter"
@reset="handleResetFilter"
@toggle="toggleFilterPanel"
/>
<!-- 当前激活的筛选条件 -->
<ActiveFilters
v-if="hasActiveFilters && !filterExpanded"
:query="localQuery"
@remove="handleRemoveFilter"
@clear-all="handleClearAll"
/>
<!-- 消息列表 -->
<MessageList
ref="messageListRef"
:query="localQuery"
@count-change="handleCountChange"
/>
<!-- 底部统计 -->
<div v-if="messageCount > 0" class="border-t border-gray-200 px-4 py-2 dark:border-gray-800">
<span class="text-xs text-gray-500">已加载 {{ messageCount }} 条消息</span>
</div>
</div>
</template>
</UDrawer>
</template>
@@ -0,0 +1,195 @@
<script setup lang="ts">
/**
* 聊天记录筛选面板
* 支持消息ID、成员、时间范围、关键词的组合筛选
*/
import { ref, watch, computed } from 'vue'
import dayjs from 'dayjs'
import type { ChatRecordQuery, FilterFormData } from './types'
const props = defineProps<{
/** 当前查询条件 */
query: ChatRecordQuery
/** 是否展开 */
expanded?: boolean
}>()
const emit = defineEmits<{
/** 应用筛选 */
(e: 'apply', query: ChatRecordQuery): void
/** 重置筛选 */
(e: 'reset'): void
/** 切换展开状态 */
(e: 'toggle'): void
}>()
// 本地表单数据
const formData = ref<FilterFormData>({
messageId: '',
memberName: '',
keywords: '',
startDate: '',
endDate: '',
})
// 是否有输入
const hasInput = computed(() => {
const f = formData.value
return !!(f.messageId || f.memberName || f.keywords || f.startDate || f.endDate)
})
// 同步外部 query 到表单
watch(
() => props.query,
(query) => {
if (query) {
formData.value = {
messageId: query.scrollToMessageId?.toString() || '',
memberName: query.memberName || '',
keywords: query.keywords?.join(', ') || '',
startDate: query.startTs ? dayjs.unix(query.startTs).format('YYYY-MM-DD') : '',
endDate: query.endTs ? dayjs.unix(query.endTs).format('YYYY-MM-DD') : '',
}
}
},
{ immediate: true }
)
// 应用筛选
function applyFilter() {
const f = formData.value
const query: ChatRecordQuery = {}
// 消息 ID
if (f.messageId) {
const id = parseInt(f.messageId, 10)
if (!isNaN(id)) {
query.scrollToMessageId = id
}
}
// 成员名称(需要后续通过 API 获取成员 ID)
if (f.memberName) {
query.memberName = f.memberName
// TODO: 这里可以添加成员搜索功能
}
// 关键词
if (f.keywords) {
const keywords = f.keywords
.split(/[,]/)
.map((k) => k.trim())
.filter((k) => k)
if (keywords.length > 0) {
query.keywords = keywords
query.highlightKeywords = keywords
}
}
// 时间范围
if (f.startDate) {
query.startTs = dayjs(f.startDate).startOf('day').unix()
}
if (f.endDate) {
query.endTs = dayjs(f.endDate).endOf('day').unix()
}
emit('apply', query)
}
// 重置筛选
function resetFilter() {
formData.value = {
messageId: '',
memberName: '',
keywords: '',
startDate: '',
endDate: '',
}
emit('reset')
}
</script>
<template>
<div class="border-b border-gray-200 dark:border-gray-800">
<!-- 折叠头部 -->
<button
class="flex w-full items-center justify-between px-4 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800"
@click="emit('toggle')"
>
<span class="flex items-center gap-2">
<UIcon name="i-heroicons-funnel" class="h-4 w-4" />
<span>筛选条件</span>
<span
v-if="hasInput"
class="rounded-full bg-blue-500 px-1.5 py-0.5 text-xs font-medium text-white"
>
已设置
</span>
</span>
<UIcon
:name="expanded ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
class="h-4 w-4 transition-transform"
/>
</button>
<!-- 展开的筛选表单 -->
<div v-if="expanded" class="space-y-3 px-4 pb-4">
<!-- 消息 ID -->
<div class="flex items-center gap-2">
<label class="w-16 shrink-0 text-xs text-gray-500">消息 ID</label>
<UInput
v-model="formData.messageId"
type="number"
placeholder="输入消息 ID 定位"
size="sm"
class="flex-1"
/>
</div>
<!-- 成员 -->
<div class="flex items-center gap-2">
<label class="w-16 shrink-0 text-xs text-gray-500">成员</label>
<UInput
v-model="formData.memberName"
placeholder="成员名称(暂不支持)"
size="sm"
class="flex-1"
disabled
/>
</div>
<!-- 关键词 -->
<div class="flex items-center gap-2">
<label class="w-16 shrink-0 text-xs text-gray-500">关键词</label>
<UInput
v-model="formData.keywords"
placeholder="多个用逗号分隔"
size="sm"
class="flex-1"
/>
</div>
<!-- 时间范围 -->
<div class="flex items-center gap-2">
<label class="w-16 shrink-0 text-xs text-gray-500">时间</label>
<div class="flex flex-1 items-center gap-2">
<UInput v-model="formData.startDate" type="date" size="sm" class="flex-1" />
<span class="text-gray-400">~</span>
<UInput v-model="formData.endDate" type="date" size="sm" class="flex-1" />
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-2 pt-2">
<UButton color="neutral" variant="ghost" size="sm" @click="resetFilter">
重置
</UButton>
<UButton color="primary" size="sm" @click="applyFilter">
应用筛选
</UButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
/**
* 单条消息展示组件
*/
import dayjs from 'dayjs'
import type { ChatRecordMessage } from './types'
const props = defineProps<{
/** 消息数据 */
message: ChatRecordMessage
/** 是否为目标消息(需要高亮) */
isTarget?: boolean
/** 高亮关键词 */
highlightKeywords?: string[]
}>()
// 格式化时间
function formatTime(timestamp: number): string {
return dayjs.unix(timestamp).format('MM-DD HH:mm:ss')
}
function formatFullTime(timestamp: number): string {
return dayjs.unix(timestamp).format('YYYY-MM-DD HH:mm:ss')
}
// 高亮关键词
function highlightContent(content: string): string {
if (!props.highlightKeywords?.length || !content) return content
const pattern = props.highlightKeywords.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
const regex = new RegExp(`(${pattern})`, 'gi')
return content.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800/50 px-0.5 rounded">$1</mark>')
}
</script>
<template>
<div
class="px-4 py-3 transition-colors"
:class="{
'bg-yellow-50 ring-2 ring-inset ring-yellow-400 dark:bg-yellow-900/20 dark:ring-yellow-600': isTarget,
}"
>
<!-- 消息头部 -->
<div class="mb-1 flex items-center justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ message.senderName }}
</span>
<span class="text-xs text-gray-400" :title="formatFullTime(message.timestamp)">
{{ formatTime(message.timestamp) }}
</span>
</div>
<!-- 消息内容 -->
<p
class="whitespace-pre-wrap break-words text-sm text-gray-600 dark:text-gray-400"
v-html="highlightContent(message.content || '')"
/>
</div>
</template>
@@ -0,0 +1,272 @@
<script setup lang="ts">
/**
* 消息列表组件
* 支持无限滚动加载
*/
import { ref, watch, nextTick, toRaw } from 'vue'
import { useChatStore } from '@/stores/chat'
import MessageItem from './MessageItem.vue'
import type { ChatRecordMessage, ChatRecordQuery } from './types'
const props = defineProps<{
/** 当前查询条件 */
query: ChatRecordQuery
}>()
const emit = defineEmits<{
/** 消息数量变化 */
(e: 'count-change', count: number): void
}>()
const chatStore = useChatStore()
// 消息列表
const messages = ref<ChatRecordMessage[]>([])
const isLoading = ref(false)
const isLoadingMore = ref(false)
const hasMoreBefore = ref(false)
const hasMoreAfter = ref(false)
// 滚动容器引用
const scrollContainerRef = ref<HTMLElement | null>(null)
// 构建筛选参数
function buildFilterParams(query: ChatRecordQuery) {
return {
filter: query.startTs || query.endTs ? { startTs: query.startTs, endTs: query.endTs } : undefined,
senderId: query.memberId,
keywords: query.keywords ? [...toRaw(query.keywords)] : undefined,
}
}
// 初始加载消息
async function loadInitialMessages() {
const sessionId = chatStore.currentSessionId
if (!sessionId) {
messages.value = []
emit('count-change', 0)
return
}
isLoading.value = true
messages.value = []
try {
const query = toRaw(props.query)
const { filter, senderId, keywords } = buildFilterParams(query)
const targetId = query.scrollToMessageId
if (targetId) {
// 以目标消息为中心,加载前后各 50 条
const [beforeResult, afterResult] = await Promise.all([
window.aiApi.getMessagesBefore(sessionId, targetId, 50, filter, senderId, keywords),
window.aiApi.getMessagesAfter(sessionId, targetId, 50, filter, senderId, keywords),
])
// 获取目标消息本身
const targetMessages = await window.aiApi.getMessageContext(sessionId, targetId, 0)
// 合并消息列表
messages.value = [...beforeResult.messages, ...targetMessages, ...afterResult.messages]
hasMoreBefore.value = beforeResult.hasMore
hasMoreAfter.value = afterResult.hasMore
// 滚动到目标消息(延时确保 DOM 完全渲染)
await nextTick()
setTimeout(() => {
scrollToMessage(targetId)
}, 100)
} else {
// 没有目标消息,加载最新的 100 条
const result = await window.aiApi.getRecentMessages(sessionId, filter, 100)
messages.value = result.messages
hasMoreBefore.value = result.messages.length >= 100
hasMoreAfter.value = false
}
emit('count-change', messages.value.length)
} catch (e) {
console.error('加载消息失败:', e)
messages.value = []
emit('count-change', 0)
} finally {
isLoading.value = false
}
}
// 加载更早的消息(向上滚动)
async function loadMoreBefore() {
if (isLoadingMore.value || !hasMoreBefore.value || messages.value.length === 0) return
const sessionId = chatStore.currentSessionId
if (!sessionId) return
const firstMessage = messages.value[0]
if (!firstMessage) return
isLoadingMore.value = true
try {
const query = toRaw(props.query)
const { filter, senderId, keywords } = buildFilterParams(query)
const result = await window.aiApi.getMessagesBefore(sessionId, firstMessage.id, 50, filter, senderId, keywords)
if (result.messages.length > 0) {
// 记录当前滚动位置
const container = scrollContainerRef.value
const oldScrollHeight = container?.scrollHeight || 0
// prepend 消息
messages.value = [...result.messages, ...messages.value]
// 恢复滚动位置
await nextTick()
if (container) {
const newScrollHeight = container.scrollHeight
container.scrollTop = newScrollHeight - oldScrollHeight
}
emit('count-change', messages.value.length)
}
hasMoreBefore.value = result.hasMore
} catch (e) {
console.error('加载更早消息失败:', e)
} finally {
isLoadingMore.value = false
}
}
// 加载更新的消息(向下滚动)
async function loadMoreAfter() {
if (isLoadingMore.value || !hasMoreAfter.value || messages.value.length === 0) return
const sessionId = chatStore.currentSessionId
if (!sessionId) return
const lastMessage = messages.value[messages.value.length - 1]
if (!lastMessage) return
isLoadingMore.value = true
try {
const query = toRaw(props.query)
const { filter, senderId, keywords } = buildFilterParams(query)
const result = await window.aiApi.getMessagesAfter(sessionId, lastMessage.id, 50, filter, senderId, keywords)
if (result.messages.length > 0) {
messages.value = [...messages.value, ...result.messages]
emit('count-change', messages.value.length)
}
hasMoreAfter.value = result.hasMore
} catch (e) {
console.error('加载更新消息失败:', e)
} finally {
isLoadingMore.value = false
}
}
// 滚动到指定消息
function scrollToMessage(messageId: number) {
const container = scrollContainerRef.value
if (!container) return
const messageEl = container.querySelector(`[data-message-id="${messageId}"]`)
if (messageEl) {
messageEl.scrollIntoView({ block: 'center', behavior: 'auto' })
}
}
// 处理滚动事件(检测边界)
function handleScroll() {
const container = scrollContainerRef.value
if (!container || isLoadingMore.value) return
// 接近顶部时加载更多
if (container.scrollTop < 100 && hasMoreBefore.value) {
loadMoreBefore()
}
// 接近底部时加载更多
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
if (distanceFromBottom < 100 && hasMoreAfter.value) {
loadMoreAfter()
}
}
// 判断是否是目标消息
function isTargetMessage(msgId: number): boolean {
return msgId === props.query.scrollToMessageId
}
// 监听查询变化
watch(
() => props.query,
() => {
loadInitialMessages()
},
{ deep: true }
)
// 暴露刷新方法
defineExpose({
refresh: loadInitialMessages,
})
</script>
<template>
<div class="flex-1 overflow-hidden">
<!-- 加载中 -->
<div v-if="isLoading" class="flex h-full items-center justify-center">
<div class="text-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-gray-400" />
<p class="mt-2 text-sm text-gray-500">加载中...</p>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="messages.length === 0" class="flex h-full items-center justify-center">
<div class="text-center">
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-12 w-12 text-gray-300 dark:text-gray-600" />
<p class="mt-2 text-sm text-gray-500">暂无消息</p>
<p class="mt-1 text-xs text-gray-400">尝试调整筛选条件</p>
</div>
</div>
<!-- 消息滚动容器 -->
<div v-else ref="scrollContainerRef" class="h-full overflow-y-auto" @scroll="handleScroll">
<!-- 顶部加载指示器 -->
<div v-if="hasMoreBefore" class="flex justify-center py-2">
<span v-if="isLoadingMore" class="text-xs text-gray-400">
<UIcon name="i-heroicons-arrow-path" class="mr-1 inline h-3 w-3 animate-spin" />
加载更多...
</span>
<span v-else class="text-xs text-gray-400"> 向上滚动加载更多</span>
</div>
<!-- 消息列表 -->
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<MessageItem
v-for="msg in messages"
:key="msg.id"
:data-message-id="msg.id"
:message="msg"
:is-target="isTargetMessage(msg.id)"
:highlight-keywords="query.highlightKeywords"
/>
</div>
<!-- 底部加载指示器 -->
<div v-if="hasMoreAfter" class="flex justify-center py-2">
<span v-if="isLoadingMore" class="text-xs text-gray-400">
<UIcon name="i-heroicons-arrow-path" class="mr-1 inline h-3 w-3 animate-spin" />
加载更多...
</span>
<span v-else class="text-xs text-gray-400"> 向下滚动加载更多</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,8 @@
/**
* 聊天记录查看器组件
* 导出主 Drawer 组件和相关类型
*/
export { default as ChatRecordDrawer } from './ChatRecordDrawer.vue'
export * from './types'
+33
View File
@@ -0,0 +1,33 @@
/**
* 聊天记录查看器类型定义
*/
import type { ChatRecordQuery, ChatRecordMessage } from '@/types/chat'
// 重新导出类型
export type { ChatRecordQuery, ChatRecordMessage }
/**
* 筛选表单数据
*/
export interface FilterFormData {
/** 消息 ID */
messageId: string
/** 成员名称 */
memberName: string
/** 关键词(逗号分隔) */
keywords: string
/** 开始日期 */
startDate: string
/** 结束日期 */
endDate: string
}
/**
* 筛选器更新事件
*/
export interface FilterUpdateEvent {
query: ChatRecordQuery
shouldReload: boolean
}
+36 -1
View File
@@ -1,6 +1,13 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { AnalysisSession, ImportProgress, KeywordTemplate, PromptPreset, AIPromptSettings } from '@/types/chat'
import type {
AnalysisSession,
ImportProgress,
KeywordTemplate,
PromptPreset,
AIPromptSettings,
ChatRecordQuery,
} from '@/types/chat'
import {
BUILTIN_PRESETS,
DEFAULT_GROUP_PRESET_ID,
@@ -257,6 +264,30 @@ export const useChatStore = defineStore(
}, 300)
}
// ==================== 聊天记录查看器 Drawer ====================
const showChatRecordDrawer = ref(false)
const chatRecordQuery = ref<ChatRecordQuery | null>(null)
/**
* 打开聊天记录查看器
* @param query 查询参数,支持组合查询
*/
function openChatRecordDrawer(query: ChatRecordQuery) {
chatRecordQuery.value = query
showChatRecordDrawer.value = true
}
/**
* 关闭聊天记录查看器
*/
function closeChatRecordDrawer() {
showChatRecordDrawer.value = false
// 延迟清除查询参数,避免关闭动画时内容闪烁
setTimeout(() => {
chatRecordQuery.value = null
}, 300)
}
// AI 配置更新计数器(用于触发其他组件刷新)
const aiConfigVersion = ref(0)
@@ -483,6 +514,8 @@ export const useChatStore = defineStore(
showSettingModal,
showScreenCaptureModal,
screenCaptureImage,
showChatRecordDrawer,
chatRecordQuery,
aiConfigVersion,
aiGlobalSettings,
customKeywordTemplates,
@@ -508,6 +541,8 @@ export const useChatStore = defineStore(
toggleSidebar,
openScreenCaptureModal,
closeScreenCaptureModal,
openChatRecordDrawer,
closeChatRecordDrawer,
notifyAIConfigChanged,
updateAIGlobalSettings,
addCustomKeywordTemplate,
+40
View File
@@ -471,6 +471,7 @@ export interface HotRepeatContent {
maxChainLength: number // 最长复读链长度
originatorName: string // 最长链的原创者名称
lastTs: number // 最近一次发生的时间戳(秒)
firstMessageId: number // 最长链的第一条消息 ID(用于跳转查看上下文)
}
/**
@@ -899,3 +900,42 @@ export interface MergeResult {
sessionId?: string // 如果选择了分析,返回会话ID
error?: string
}
// ==================== 聊天记录查看器类型 ====================
/**
* 聊天记录查看器查询参数
* 支持组合查询:多个条件可同时生效
*/
export interface ChatRecordQuery {
/** 定位到指定消息(初始加载时以此消息为中心) */
scrollToMessageId?: number
/** 成员筛选:只显示该成员的消息 */
memberId?: number
/** 成员名称(用于显示) */
memberName?: string
/** 时间范围筛选:开始时间戳(秒) */
startTs?: number
/** 时间范围筛选:结束时间戳(秒) */
endTs?: number
/** 关键词搜索(OR 逻辑) */
keywords?: string[]
/** 高亮关键词(用于 UI 高亮显示) */
highlightKeywords?: string[]
}
/**
* 聊天记录查看器中的消息项
*/
export interface ChatRecordMessage {
id: number
senderName: string
senderPlatformId: string
content: string
timestamp: number
type: number
}