mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-27 01:01:51 +08:00
feat: 新增聊天记录查看器
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Vendored
+1
@@ -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'
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user