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
+134
View File
@@ -0,0 +1,134 @@
/**
* 聊天记录查询 IPC 处理器
* 提供通用的消息查询功能:搜索、筛选、上下文、无限滚动等
*/
import { ipcMain } from 'electron'
import type { IpcContext } from './types'
import * as worker from '../worker/workerManager'
export function registerMessagesHandlers({ win }: IpcContext): void {
console.log('[IPC] Registering Messages handlers...')
/**
* 关键词搜索消息
*/
ipcMain.handle(
'ai:searchMessages',
async (
_,
sessionId: string,
keywords: string[],
filter?: { startTs?: number; endTs?: number },
limit?: number,
offset?: number,
senderId?: number
) => {
try {
return await worker.searchMessages(sessionId, keywords, filter, limit, offset, senderId)
} catch (error) {
console.error('搜索消息失败:', error)
return { messages: [], total: 0 }
}
}
)
/**
* 获取消息上下文
*/
ipcMain.handle(
'ai:getMessageContext',
async (_, sessionId: string, messageIds: number | number[], contextSize?: number) => {
try {
return await worker.getMessageContext(sessionId, messageIds, contextSize)
} catch (error) {
console.error('获取消息上下文失败:', error)
return []
}
}
)
/**
* 获取最近消息
*/
ipcMain.handle(
'ai:getRecentMessages',
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) => {
try {
return await worker.getRecentMessages(sessionId, filter, limit)
} catch (error) {
console.error('获取最近消息失败:', error)
return { messages: [], total: 0 }
}
}
)
/**
* 获取两人之间的对话
*/
ipcMain.handle(
'ai:getConversationBetween',
async (
_,
sessionId: string,
memberId1: number,
memberId2: number,
filter?: { startTs?: number; endTs?: number },
limit?: number
) => {
try {
return await worker.getConversationBetween(sessionId, memberId1, memberId2, filter, limit)
} catch (error) {
console.error('获取对话失败:', error)
return { messages: [], total: 0, member1Name: '', member2Name: '' }
}
}
)
/**
* 获取指定消息之前的 N 条(用于向上无限滚动)
*/
ipcMain.handle(
'ai:getMessagesBefore',
async (
_,
sessionId: string,
beforeId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => {
try {
return await worker.getMessagesBefore(sessionId, beforeId, limit, filter, senderId, keywords)
} catch (error) {
console.error('获取之前消息失败:', error)
return { messages: [], hasMore: false }
}
}
)
/**
* 获取指定消息之后的 N 条(用于向下无限滚动)
*/
ipcMain.handle(
'ai:getMessagesAfter',
async (
_,
sessionId: string,
afterId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => {
try {
return await worker.getMessagesAfter(sessionId, afterId, limit, filter, senderId, keywords)
} catch (error) {
console.error('获取之后消息失败:', error)
return { messages: [], hasMore: false }
}
}
)
}
+2
View File
@@ -10,6 +10,7 @@ import { registerWindowHandlers } from './ipc/window'
import { registerChatHandlers } from './ipc/chat'
import { registerMergeHandlers, initMergeModule } from './ipc/merge'
import { registerAIHandlers } from './ipc/ai'
import { registerMessagesHandlers } from './ipc/messages'
import { registerCacheHandlers } from './ipc/cache'
// 导入 Worker 模块(用于异步分析查询和流式导入)
import * as worker from './worker/workerManager'
@@ -39,6 +40,7 @@ const mainIpcMain = (win: BrowserWindow) => {
registerChatHandlers(context)
registerMergeHandlers(context)
registerAIHandlers(context)
registerMessagesHandlers(context)
registerCacheHandlers(context)
console.log('[IpcMain] All IPC handlers registered successfully')
+4
View File
@@ -37,6 +37,8 @@ import {
getMessageContext,
getRecentMessages,
getConversationBetween,
getMessagesBefore,
getMessagesAfter,
// 成员管理
getMembers,
updateMemberAliases,
@@ -105,6 +107,8 @@ const syncHandlers: Record<string, (payload: any) => any> = {
getMessageContext: (p) => getMessageContext(p.sessionId, p.messageIds, p.contextSize),
getRecentMessages: (p) => getRecentMessages(p.sessionId, p.filter, p.limit),
getConversationBetween: (p) => getConversationBetween(p.sessionId, p.memberId1, p.memberId2, p.filter, p.limit),
getMessagesBefore: (p) => getMessagesBefore(p.sessionId, p.beforeId, p.limit, p.filter, p.senderId, p.keywords),
getMessagesAfter: (p) => getMessagesAfter(p.sessionId, p.afterId, p.limit, p.filter, p.senderId, p.keywords),
// SQL 实验室
executeRawSQL: (p) => executeRawSQL(p.sessionId, p.sql),
@@ -70,17 +70,17 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any {
const chainLengthCount = new Map<number, number>()
const contentStats = new Map<
string,
{ count: number; maxChainLength: number; originatorId: number; lastTs: number }
{ count: number; maxChainLength: number; originatorId: number; lastTs: number; firstMessageId: number }
>()
let currentContent: string | null = null
let repeatChain: Array<{ senderId: number; content: string; ts: number }> = []
let repeatChain: Array<{ id: number; senderId: number; content: string; ts: number }> = []
let totalRepeatChains = 0
let totalChainLength = 0
const fastestRepeaterStats = new Map<number, { totalDiff: number; count: number }>()
const processRepeatChain = (chain: Array<{ senderId: number; content: string; ts: number }>, breakerId?: number) => {
const processRepeatChain = (chain: Array<{ id: number; senderId: number; content: string; ts: number }>, breakerId?: number) => {
if (chain.length < 3) return
totalRepeatChains++
@@ -101,6 +101,7 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any {
const content = chain[0].content
const chainTs = chain[0].ts
const firstMsgId = chain[0].id
const existing = contentStats.get(content)
if (existing) {
existing.count++
@@ -108,9 +109,10 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any {
if (chainLength > existing.maxChainLength) {
existing.maxChainLength = chainLength
existing.originatorId = originatorId
existing.firstMessageId = firstMsgId
}
} else {
contentStats.set(content, { count: 1, maxChainLength: chainLength, originatorId, lastTs: chainTs })
contentStats.set(content, { count: 1, maxChainLength: chainLength, originatorId, lastTs: chainTs, firstMessageId: firstMsgId })
}
// 计算反应时间 (Fastest Follower)
@@ -144,13 +146,13 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any {
if (content === currentContent) {
const lastSender = repeatChain[repeatChain.length - 1]?.senderId
if (lastSender !== msg.senderId) {
repeatChain.push({ senderId: msg.senderId, content, ts: msg.ts })
repeatChain.push({ id: msg.id, senderId: msg.senderId, content, ts: msg.ts })
}
} else {
processRepeatChain(repeatChain, msg.senderId)
currentContent = content
repeatChain = [{ senderId: msg.senderId, content, ts: msg.ts }]
repeatChain = [{ id: msg.id, senderId: msg.senderId, content, ts: msg.ts }]
}
}
@@ -227,6 +229,7 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any {
maxChainLength: stats.maxChainLength,
originatorName: originatorInfo?.name || '未知',
lastTs: stats.lastTs,
firstMessageId: stats.firstMessageId,
})
}
hotContents.sort((a, b) => b.maxChainLength - a.maxChainLength)
+12 -2
View File
@@ -36,8 +36,18 @@ export {
getLaughAnalysis,
} from './advanced'
// AI 查询
export { searchMessages, getMessageContext, getRecentMessages, getConversationBetween } from './ai'
// 聊天记录查询
export {
searchMessages,
getMessageContext,
getRecentMessages,
getConversationBetween,
getMessagesBefore,
getMessagesAfter,
} from './messages'
// 聊天记录查询类型
export type { MessageResult, PaginatedMessages, MessagesWithTotal } from './messages'
// SQL 实验室
export { executeRawSQL, getSchema } from './sql'
@@ -1,16 +1,17 @@
/**
* AI
* Worker 线
*
*
* Worker 线
*/
import { openDatabase, buildTimeFilter, type TimeFilter } from '../core'
// ==================== 消息搜索 ====================
// ==================== 类型定义 ====================
/**
*
*
*/
export interface SearchMessageResult {
export interface MessageResult {
id: number
senderName: string
senderPlatformId: string
@@ -20,7 +21,70 @@ export interface SearchMessageResult {
}
/**
*
*
*/
export interface PaginatedMessages {
messages: MessageResult[]
hasMore: boolean
}
/**
*
*/
export interface MessagesWithTotal {
messages: MessageResult[]
total: number
}
// ==================== 工具函数 ====================
/**
* MessageResult
* BigInt IPC
*/
function sanitizeMessageRow(row: MessageResult): MessageResult {
return {
id: Number(row.id),
senderName: String(row.senderName || ''),
senderPlatformId: String(row.senderPlatformId || ''),
content: row.content != null ? String(row.content) : '',
timestamp: Number(row.timestamp),
type: Number(row.type),
}
}
/**
*
*/
function buildSenderCondition(senderId?: number): { condition: string; params: number[] } {
if (senderId === undefined) {
return { condition: '', params: [] }
}
return { condition: 'AND msg.sender_id = ?', params: [senderId] }
}
/**
* OR
*/
function buildKeywordCondition(keywords?: string[]): { condition: string; params: string[] } {
if (!keywords || keywords.length === 0) {
return { condition: '', params: [] }
}
const condition = `AND (${keywords.map(() => `msg.content LIKE ?`).join(' OR ')})`
const params = keywords.map((k) => `%${k}%`)
return { condition, params }
}
// 排除系统消息的通用过滤条件
const SYSTEM_FILTER = "AND COALESCE(m.account_name, '') != '系统消息'"
// 只获取文本消息的过滤条件
const TEXT_ONLY_FILTER = 'AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content != \'\''
// ==================== 查询函数 ====================
/**
*
* @param sessionId ID
* @param filter
* @param limit
@@ -29,7 +93,7 @@ export function getRecentMessages(
sessionId: string,
filter?: TimeFilter,
limit: number = 100
): { messages: SearchMessageResult[]; total: number } {
): MessagesWithTotal {
const db = openDatabase(sessionId)
if (!db) return { messages: [], total: 0 }
@@ -37,9 +101,6 @@ export function getRecentMessages(
const { clause: timeClause, params: timeParams } = buildTimeFilter(filter)
const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : ''
// 排除系统消息,只获取文本消息(type=0)
const systemFilter = "AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content != ''"
// 查询总数
const countSql = `
SELECT COUNT(*) as total
@@ -47,7 +108,8 @@ export function getRecentMessages(
JOIN member m ON msg.sender_id = m.id
WHERE 1=1
${timeCondition}
${systemFilter}
${SYSTEM_FILTER}
${TEXT_ONLY_FILTER}
`
const totalRow = db.prepare(countSql).get(...timeParams) as { total: number }
const total = totalRow?.total || 0
@@ -65,15 +127,19 @@ export function getRecentMessages(
JOIN member m ON msg.sender_id = m.id
WHERE 1=1
${timeCondition}
${systemFilter}
${SYSTEM_FILTER}
${TEXT_ONLY_FILTER}
ORDER BY msg.ts DESC
LIMIT ?
`
const rows = db.prepare(sql).all(...timeParams, limit) as SearchMessageResult[]
const rows = db.prepare(sql).all(...timeParams, limit) as MessageResult[]
// 返回时按时间正序排列(便于阅读)
return { messages: rows.reverse(), total }
return {
messages: rows.map(sanitizeMessageRow).reverse(),
total,
}
}
/**
@@ -83,7 +149,7 @@ export function getRecentMessages(
* @param filter
* @param limit
* @param offset
* @param senderId ID
* @param senderId ID
*/
export function searchMessages(
sessionId: string,
@@ -92,7 +158,7 @@ export function searchMessages(
limit: number = 20,
offset: number = 0,
senderId?: number
): { messages: SearchMessageResult[]; total: number } {
): MessagesWithTotal {
const db = openDatabase(sessionId)
if (!db) return { messages: [], total: 0 }
@@ -108,16 +174,8 @@ export function searchMessages(
const { clause: timeClause, params: timeParams } = buildTimeFilter(filter)
const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : ''
// 排除系统消息
const systemFilter = "AND COALESCE(m.account_name, '') != '系统消息'"
// 构建发送者筛选条件
let senderCondition = ''
const senderParams: number[] = []
if (senderId !== undefined) {
senderCondition = 'AND msg.sender_id = ?'
senderParams.push(senderId)
}
const { condition: senderCondition, params: senderParams } = buildSenderCondition(senderId)
// 查询总数
const countSql = `
@@ -126,7 +184,7 @@ export function searchMessages(
JOIN member m ON msg.sender_id = m.id
WHERE ${keywordCondition}
${timeCondition}
${systemFilter}
${SYSTEM_FILTER}
${senderCondition}
`
const totalRow = db.prepare(countSql).get(...keywordParams, ...timeParams, ...senderParams) as { total: number }
@@ -145,15 +203,18 @@ export function searchMessages(
JOIN member m ON msg.sender_id = m.id
WHERE ${keywordCondition}
${timeCondition}
${systemFilter}
${SYSTEM_FILTER}
${senderCondition}
ORDER BY msg.ts DESC
LIMIT ? OFFSET ?
`
const rows = db.prepare(sql).all(...keywordParams, ...timeParams, ...senderParams, limit, offset) as SearchMessageResult[]
const rows = db.prepare(sql).all(...keywordParams, ...timeParams, ...senderParams, limit, offset) as MessageResult[]
return { messages: rows, total }
return {
messages: rows.map(sanitizeMessageRow),
total,
}
}
/**
@@ -168,7 +229,7 @@ export function getMessageContext(
sessionId: string,
messageIds: number | number[],
contextSize: number = 20
): SearchMessageResult[] {
): MessageResult[] {
const db = openDatabase(sessionId)
if (!db) return []
@@ -225,9 +286,130 @@ export function getMessageContext(
ORDER BY msg.id ASC
`
const rows = db.prepare(sql).all(...idList) as SearchMessageResult[]
const rows = db.prepare(sql).all(...idList) as MessageResult[]
return rows
return rows.map(sanitizeMessageRow)
}
/**
* N
* @param sessionId ID
* @param beforeId ID
* @param limit
* @param filter
* @param senderId
* @param keywords
*/
export function getMessagesBefore(
sessionId: string,
beforeId: number,
limit: number = 50,
filter?: TimeFilter,
senderId?: number,
keywords?: string[]
): PaginatedMessages {
const db = openDatabase(sessionId)
if (!db) return { messages: [], hasMore: false }
// 构建时间过滤条件
const { clause: timeClause, params: timeParams } = buildTimeFilter(filter)
const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : ''
// 构建关键词条件
const { condition: keywordCondition, params: keywordParams } = buildKeywordCondition(keywords)
// 构建发送者筛选条件
const { condition: senderCondition, params: senderParams } = buildSenderCondition(senderId)
const sql = `
SELECT
msg.id,
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
msg.content,
msg.ts as timestamp,
msg.type
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE msg.id < ?
${timeCondition}
${keywordCondition}
${senderCondition}
${SYSTEM_FILTER}
ORDER BY msg.id DESC
LIMIT ?
`
const rows = db.prepare(sql).all(beforeId, ...timeParams, ...keywordParams, ...senderParams, limit + 1) as MessageResult[]
const hasMore = rows.length > limit
const resultRows = hasMore ? rows.slice(0, limit) : rows
// 返回时按 ID 升序排列
return {
messages: resultRows.map(sanitizeMessageRow).reverse(),
hasMore,
}
}
/**
* N
* @param sessionId ID
* @param afterId ID
* @param limit
* @param filter
* @param senderId
* @param keywords
*/
export function getMessagesAfter(
sessionId: string,
afterId: number,
limit: number = 50,
filter?: TimeFilter,
senderId?: number,
keywords?: string[]
): PaginatedMessages {
const db = openDatabase(sessionId)
if (!db) return { messages: [], hasMore: false }
// 构建时间过滤条件
const { clause: timeClause, params: timeParams } = buildTimeFilter(filter)
const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : ''
// 构建关键词条件
const { condition: keywordCondition, params: keywordParams } = buildKeywordCondition(keywords)
// 构建发送者筛选条件
const { condition: senderCondition, params: senderParams } = buildSenderCondition(senderId)
const sql = `
SELECT
msg.id,
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
msg.content,
msg.ts as timestamp,
msg.type
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE msg.id > ?
${timeCondition}
${keywordCondition}
${senderCondition}
${SYSTEM_FILTER}
ORDER BY msg.id ASC
LIMIT ?
`
const rows = db.prepare(sql).all(afterId, ...timeParams, ...keywordParams, ...senderParams, limit + 1) as MessageResult[]
const hasMore = rows.length > limit
const resultRows = hasMore ? rows.slice(0, limit) : rows
return {
messages: resultRows.map(sanitizeMessageRow),
hasMore,
}
}
/**
@@ -245,7 +427,7 @@ export function getConversationBetween(
memberId2: number,
filter?: TimeFilter,
limit: number = 100
): { messages: SearchMessageResult[]; total: number; member1Name: string; member2Name: string } {
): MessagesWithTotal & { member1Name: string; member2Name: string } {
const db = openDatabase(sessionId)
if (!db) return { messages: [], total: 0, member1Name: '', member2Name: '' }
@@ -298,11 +480,11 @@ export function getConversationBetween(
LIMIT ?
`
const rows = db.prepare(sql).all(memberId1, memberId2, ...timeParams, limit) as SearchMessageResult[]
const rows = db.prepare(sql).all(memberId1, memberId2, ...timeParams, limit) as MessageResult[]
// 返回时按时间正序排列(便于阅读对话)
return {
messages: rows.reverse(),
messages: rows.map(sanitizeMessageRow).reverse(),
total,
member1Name: member1.name,
member2Name: member2.name,
+28
View File
@@ -436,6 +436,34 @@ export async function getConversationBetween(
return sendToWorker('getConversationBetween', { sessionId, memberId1, memberId2, filter, limit })
}
/**
* 获取指定消息之前的 N 条消息(用于向上无限滚动)
*/
export async function getMessagesBefore(
sessionId: string,
beforeId: number,
limit?: number,
filter?: any,
senderId?: number,
keywords?: string[]
): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> {
return sendToWorker('getMessagesBefore', { sessionId, beforeId, limit, filter, senderId, keywords })
}
/**
* 获取指定消息之后的 N 条消息(用于向下无限滚动)
*/
export async function getMessagesAfter(
sessionId: string,
afterId: number,
limit?: number,
filter?: any,
senderId?: number,
keywords?: string[]
): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> {
return sendToWorker('getMessagesAfter', { sessionId, afterId, limit, filter, senderId, keywords })
}
// ==================== SQL 实验室 API ====================
export interface SQLResult {
+63 -4
View File
@@ -445,22 +445,81 @@ interface AIMessage {
const aiApi = {
/**
* 搜索消息(关键词搜索)
* @param senderId 可选的发送者成员 ID,用于筛选特定成员的消息
*/
searchMessages: (
sessionId: string,
keywords: string[],
filter?: { startTs?: number; endTs?: number },
limit?: number,
offset?: number
offset?: number,
senderId?: number
): Promise<{ messages: SearchMessageResult[]; total: number }> => {
return ipcRenderer.invoke('ai:searchMessages', sessionId, keywords, filter, limit, offset)
return ipcRenderer.invoke('ai:searchMessages', sessionId, keywords, filter, limit, offset, senderId)
},
/**
* 获取消息上下文
* @param messageIds 支持单个或批量消息 ID
*/
getMessageContext: (sessionId: string, messageId: number, contextSize?: number): Promise<SearchMessageResult[]> => {
return ipcRenderer.invoke('ai:getMessageContext', sessionId, messageId, contextSize)
getMessageContext: (
sessionId: string,
messageIds: number | number[],
contextSize?: number
): Promise<SearchMessageResult[]> => {
return ipcRenderer.invoke('ai:getMessageContext', sessionId, messageIds, contextSize)
},
/**
* 获取最近消息
*/
getRecentMessages: (
sessionId: string,
filter?: { startTs?: number; endTs?: number },
limit?: number
): Promise<{ messages: SearchMessageResult[]; total: number }> => {
return ipcRenderer.invoke('ai:getRecentMessages', sessionId, filter, limit)
},
/**
* 获取两人之间的对话
*/
getConversationBetween: (
sessionId: string,
memberId1: number,
memberId2: number,
filter?: { startTs?: number; endTs?: number },
limit?: number
): Promise<{ messages: SearchMessageResult[]; total: number; member1Name: string; member2Name: string }> => {
return ipcRenderer.invoke('ai:getConversationBetween', sessionId, memberId1, memberId2, filter, limit)
},
/**
* 获取指定消息之前的 N 条(用于向上无限滚动)
*/
getMessagesBefore: (
sessionId: string,
beforeId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> => {
return ipcRenderer.invoke('ai:getMessagesBefore', sessionId, beforeId, limit, filter, senderId, keywords)
},
/**
* 获取指定消息之后的 N 条(用于向下无限滚动)
*/
getMessagesAfter: (
sessionId: string,
afterId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> => {
return ipcRenderer.invoke('ai:getMessagesAfter', sessionId, afterId, limit, filter, senderId, keywords)
},
/**
+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
}