diff --git a/electron/main/database/index.ts b/electron/main/database/index.ts index c305256..f9b59c2 100644 --- a/electron/main/database/index.ts +++ b/electron/main/database/index.ts @@ -94,6 +94,16 @@ function createDatabase(sessionId: string): Database.Database { name TEXT NOT NULL ); + -- 成员昵称历史表 + CREATE TABLE IF NOT EXISTS member_name_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_id INTEGER NOT NULL, + name TEXT NOT NULL, + start_ts INTEGER NOT NULL, + end_ts INTEGER, + FOREIGN KEY(member_id) REFERENCES member(id) + ); + -- 消息表 CREATE TABLE IF NOT EXISTS message ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -107,6 +117,7 @@ function createDatabase(sessionId: string): Database.Database { -- 索引 CREATE INDEX IF NOT EXISTS idx_message_ts ON message(ts); CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_id); + CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id); `) return db @@ -158,34 +169,84 @@ export function importData(parseResult: ParseResult): string { const insertMember = db.prepare(` INSERT OR IGNORE INTO member (platform_id, name) VALUES (?, ?) `) - const updateMemberName = db.prepare(` - UPDATE member SET name = ? WHERE platform_id = ? - `) const getMemberId = db.prepare(` SELECT id FROM member WHERE platform_id = ? `) const memberIdMap = new Map() + // 初始化成员表(使用初始昵称) for (const member of parseResult.members) { insertMember.run(member.platformId, member.name) - // 更新为最新昵称 - updateMemberName.run(member.name, member.platformId) const row = getMemberId.get(member.platformId) as { id: number } memberIdMap.set(member.platformId, row.id) } - // 批量插入消息 + // 按时间戳升序排序消息(用于追踪昵称变化) + const sortedMessages = [...parseResult.messages].sort((a, b) => a.timestamp - b.timestamp) + + // 追踪每个成员的昵称历史 + // Map + const nicknameTracker = new Map() + + // 准备 SQL 语句 const insertMessage = db.prepare(` INSERT INTO message (sender_id, ts, type, content) VALUES (?, ?, ?, ?) `) + const insertNameHistory = db.prepare(` + INSERT INTO member_name_history (member_id, name, start_ts, end_ts) + VALUES (?, ?, ?, ?) + `) + const updateMemberName = db.prepare(` + UPDATE member SET name = ? WHERE platform_id = ? + `) + const updateNameHistoryEndTs = db.prepare(` + UPDATE member_name_history + SET end_ts = ? + WHERE member_id = ? AND end_ts IS NULL + `) - for (const msg of parseResult.messages) { + // 遍历排序后的消息 + for (const msg of sortedMessages) { const senderId = memberIdMap.get(msg.senderPlatformId) - if (senderId !== undefined) { - insertMessage.run(senderId, msg.timestamp, msg.type, msg.content) + if (senderId === undefined) continue + + // 插入消息 + insertMessage.run(senderId, msg.timestamp, msg.type, msg.content) + + // 从消息中获取发送者的昵称 + const currentName = msg.senderName + const tracker = nicknameTracker.get(msg.senderPlatformId) + + if (!tracker) { + // 首次出现,记录初始昵称 + nicknameTracker.set(msg.senderPlatformId, { + currentName, + lastSeenTs: msg.timestamp, + }) + // 插入第一条昵称历史记录(end_ts 为 NULL,表示当前使用中) + insertNameHistory.run(senderId, currentName, msg.timestamp, null) + } else if (tracker.currentName !== currentName) { + // 昵称发生变化 + // 1. 关闭旧昵称的时间段(end_ts 设为当前消息时间戳) + updateNameHistoryEndTs.run(msg.timestamp, senderId) + + // 2. 插入新昵称记录 + insertNameHistory.run(senderId, currentName, msg.timestamp, null) + + // 3. 更新追踪器 + tracker.currentName = currentName + tracker.lastSeenTs = msg.timestamp + } else { + // 昵称未变化,仅更新最后见到的时间戳 + tracker.lastSeenTs = msg.timestamp } } + + // 更新 member 表中的最新昵称 + for (const [platformId, tracker] of nicknameTracker.entries()) { + updateMemberName.run(tracker.currentName, platformId) + } }) console.log('[Database] Executing transaction...') @@ -418,7 +479,7 @@ export function getAvailableYears(sessionId: string): number[] { ` SELECT DISTINCT CAST(strftime('%Y', ts, 'unixepoch', 'localtime') AS INTEGER) as year FROM message - ORDER BY year + ORDER BY year DESC ` ) .all() as Array<{ year: number }> @@ -632,3 +693,31 @@ export function getDbDirectory(): string { ensureDbDir() return getDbDir() } + +/** + * 获取成员的历史昵称记录 + */ +export function getMemberNameHistory( + sessionId: string, + memberId: number +): Array<{ name: string; startTs: number; endTs: number | null }> { + const db = openDatabase(sessionId) + if (!db) return [] + + try { + const rows = db + .prepare( + ` + SELECT name, start_ts as startTs, end_ts as endTs + FROM member_name_history + WHERE member_id = ? + ORDER BY start_ts DESC + ` + ) + .all(memberId) as Array<{ name: string; startTs: number; endTs: number | null }> + + return rows + } finally { + db.close() + } +} diff --git a/electron/main/ipcMain.ts b/electron/main/ipcMain.ts index 410c6f2..e3ab1dc 100644 --- a/electron/main/ipcMain.ts +++ b/electron/main/ipcMain.ts @@ -273,6 +273,18 @@ const mainIpcMain = (win: BrowserWindow) => { } ) + /** + * 获取成员历史昵称 + */ + ipcMain.handle('chat:getMemberNameHistory', async (_, sessionId: string, memberId: number) => { + try { + return database.getMemberNameHistory(sessionId, memberId) + } catch (error) { + console.error('获取成员历史昵称失败:', error) + return [] + } + }) + /** * 获取每小时活跃度分布 */ diff --git a/electron/main/parser/qqJsonParser.ts b/electron/main/parser/qqJsonParser.ts index cd289a1..6b410e3 100644 --- a/electron/main/parser/qqJsonParser.ts +++ b/electron/main/parser/qqJsonParser.ts @@ -10,7 +10,7 @@ import { MessageType, type ParseResult, type ParsedMember, - type ParsedMessage + type ParsedMessage, } from '../../../src/types/chat' /** @@ -157,7 +157,7 @@ export const qqJsonParser: ChatParser = { const meta = { name: data.chatInfo.name, platform: ChatPlatform.QQ, - type: data.chatInfo.type === 'group' ? ChatType.GROUP : ChatType.PRIVATE + type: data.chatInfo.type === 'group' ? ChatType.GROUP : ChatType.PRIVATE, } // 收集成员信息(使用 Map 去重,保留最新昵称) @@ -172,7 +172,7 @@ export const qqJsonParser: ChatParser = { // 更新成员信息(保留最新昵称) memberMap.set(platformId, { platformId, - name: msg.sender.name || platformId + name: msg.sender.name || platformId, }) // 转换时间戳(QQ 导出是毫秒,需要转为秒) @@ -191,17 +191,17 @@ export const qqJsonParser: ChatParser = { messages.push({ senderPlatformId: platformId, + senderName: msg.sender.name || platformId, timestamp, type, - content: textContent || null + content: textContent || null, }) } return { meta, members: Array.from(memberMap.values()), - messages + messages, } - } + }, } - diff --git a/electron/main/parser/qqTxtParser.ts b/electron/main/parser/qqTxtParser.ts index a84682a..0460942 100644 --- a/electron/main/parser/qqTxtParser.ts +++ b/electron/main/parser/qqTxtParser.ts @@ -10,7 +10,7 @@ import { MessageType, type ParseResult, type ParsedMember, - type ParsedMessage + type ParsedMessage, } from '../../../src/types/chat' /** @@ -146,9 +146,10 @@ export const qqTxtParser: ChatParser = { if (content) { messages.push({ senderPlatformId: currentSender.platformId, + senderName: currentSender.name, timestamp: currentTimestamp, type: detectMessageType(content), - content + content, }) } } @@ -180,7 +181,7 @@ export const qqTxtParser: ChatParser = { // 更新成员信息(保留最新昵称) memberMap.set(qqNumber, { platformId: qqNumber, - name + name, }) currentSender = { platformId: qqNumber, name } @@ -198,11 +199,10 @@ export const qqTxtParser: ChatParser = { meta: { name: groupName, platform: ChatPlatform.QQ, - type: ChatType.GROUP // TXT 导出通常是群聊 + type: ChatType.GROUP, // TXT 导出通常是群聊 }, members: Array.from(memberMap.values()), - messages + messages, } - } + }, } - diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 4149bcf..c225a03 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -2,10 +2,11 @@ import { ElectronAPI } from '@electron-toolkit/preload' import type { AnalysisSession, MemberActivity, + MemberNameHistory, HourlyActivity, DailyActivity, MessageType, - ImportProgress + ImportProgress, } from '../../src/types/chat' interface TimeFilter { @@ -21,6 +22,7 @@ interface ChatApi { deleteSession: (sessionId: string) => Promise getAvailableYears: (sessionId: string) => Promise getMemberActivity: (sessionId: string, filter?: TimeFilter) => Promise + getMemberNameHistory: (sessionId: string, memberId: number) => Promise getHourlyActivity: (sessionId: string, filter?: TimeFilter) => Promise getDailyActivity: (sessionId: string, filter?: TimeFilter) => Promise getMessageTypeDistribution: ( diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 4b2f8a6..ebcb507 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -3,10 +3,11 @@ import { electronAPI } from '@electron-toolkit/preload' import type { AnalysisSession, MemberActivity, + MemberNameHistory, HourlyActivity, DailyActivity, MessageType, - ImportProgress + ImportProgress, } from '../../src/types/chat' // Custom APIs for renderer @@ -18,7 +19,7 @@ const api = { 'check-update', 'get-gpu-acceleration', 'set-gpu-acceleration', - 'save-gpu-acceleration' + 'save-gpu-acceleration', ] if (validChannels.includes(channel)) { ipcRenderer.send(channel, data) @@ -33,7 +34,7 @@ const api = { }, removeListener: (channel: string, func: (...args: unknown[]) => void) => { ipcRenderer.removeListener(channel, func) - } + }, } // Chat Analysis API @@ -83,30 +84,28 @@ const chatApi = { /** * 获取成员活跃度排行 */ - getMemberActivity: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { + getMemberActivity: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { return ipcRenderer.invoke('chat:getMemberActivity', sessionId, filter) }, + /** + * 获取成员历史昵称 + */ + getMemberNameHistory: (sessionId: string, memberId: number): Promise => { + return ipcRenderer.invoke('chat:getMemberNameHistory', sessionId, memberId) + }, + /** * 获取每小时活跃度分布 */ - getHourlyActivity: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { + getHourlyActivity: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { return ipcRenderer.invoke('chat:getHourlyActivity', sessionId, filter) }, /** * 获取每日活跃度趋势 */ - getDailyActivity: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { + getDailyActivity: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { return ipcRenderer.invoke('chat:getDailyActivity', sessionId, filter) }, @@ -152,7 +151,7 @@ const chatApi = { return () => { ipcRenderer.removeListener('chat:importProgress', handler) } - } + }, } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/App.vue b/src/App.vue index 2d92a21..75fd933 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,6 @@ + + diff --git a/src/components/WelcomeGuide.vue b/src/components/WelcomeGuide.vue index 19472fe..684eb3a 100644 --- a/src/components/WelcomeGuide.vue +++ b/src/components/WelcomeGuide.vue @@ -7,6 +7,7 @@ const chatStore = useChatStore() const { isImporting, importProgress } = storeToRefs(chatStore) const importError = ref(null) +const isDragOver = ref(false) const features = [ { @@ -43,6 +44,53 @@ async function handleImport() { } } +// 拖拽事件处理 +function handleDragOver(e: DragEvent) { + e.preventDefault() + e.stopPropagation() + isDragOver.value = true +} + +function handleDragLeave(e: DragEvent) { + e.preventDefault() + e.stopPropagation() + isDragOver.value = false +} + +async function handleDrop(e: DragEvent) { + e.preventDefault() + e.stopPropagation() + isDragOver.value = false + + if (isImporting.value) return + + const files = e.dataTransfer?.files + if (!files || files.length === 0) return + + const file = files[0] + + // 使用 Electron 的 webUtils 获取文件真实路径 + let filePath: string + try { + filePath = window.electron.webUtils.getPathForFile(file) + } catch (error) { + console.error('获取文件路径失败:', error) + importError.value = '无法读取文件路径' + return + } + + if (!filePath) { + importError.value = '无法读取文件路径' + return + } + + importError.value = null + const result = await chatStore.importFileFromPath(filePath) + if (!result.success && result.error) { + importError.value = result.error + } +} + function openTutorial(type: 'wechat' | 'qq') { // TODO: 打开教程页面 console.log('Tutorial:', type) @@ -85,7 +133,7 @@ function getProgressText(): string { > ChatLens -

ChatLens 帮你发现那些被遗忘的有趣瞬间

+

获取你的聊天记录分析报告

@@ -106,38 +154,65 @@ function getProgressText(): string {
- - + +
- -
- -

- {{ importProgress.message }} -

+ +
+ + +
-
- - {{ importError }} +
+ + {{ importError }}
@@ -158,9 +233,6 @@ function getProgressText(): string { QQ导入教程
- - -

支持 QQ、微信聊天记录(JSON/TXT 格式)

diff --git a/src/components/analysis/AnalysisDashboard.vue b/src/components/analysis/AnalysisDashboard.vue index 428cd4e..4e3daf9 100644 --- a/src/components/analysis/AnalysisDashboard.vue +++ b/src/components/analysis/AnalysisDashboard.vue @@ -3,6 +3,7 @@ import { ref, onMounted, watch, computed } from 'vue' import { useChatStore } from '@/stores/chat' import { storeToRefs } from 'pinia' import type { AnalysisSession, MemberActivity, HourlyActivity, DailyActivity, MessageType } from '@/types/chat' +import UITabs from '@/components/UI/Tabs.vue' import OverviewTab from './OverviewTab.vue' import MembersTab from './MembersTab.vue' import TimelineTab from './TimelineTab.vue' @@ -21,7 +22,7 @@ const timeRange = ref<{ start: number; end: number } | null>(null) // 年份筛选 const availableYears = ref([]) -const selectedYear = ref(null) // null 表示全部 +const selectedYear = ref(0) // 0 表示全部 // Tab 配置 const tabs = [ @@ -34,7 +35,7 @@ const activeTab = ref('overview') // 计算时间过滤参数 const timeFilter = computed(() => { - if (selectedYear.value === null) { + if (selectedYear.value === 0) { return undefined } // 计算年份的开始和结束时间戳 @@ -48,7 +49,7 @@ const timeFilter = computed(() => { // 年份选项 const yearOptions = computed(() => { - const options = [{ label: '全部时间', value: null as number | null }] + const options = [{ label: '全部时间', value: 0 }] for (const year of availableYears.value) { options.push({ label: `${year}年`, value: year }) } @@ -128,7 +129,7 @@ async function loadData() { watch( currentSessionId, () => { - selectedYear.value = null // 重置年份筛选 + selectedYear.value = 0 // 重置年份筛选 loadData() }, { immediate: true } @@ -179,13 +180,6 @@ onMounted(loadData)
- - - - - 生成报告 @@ -209,6 +203,7 @@ onMounted(loadData) {{ tab.label }} +
@@ -239,7 +234,11 @@ onMounted(loadData) :filtered-message-count="filteredMessageCount" :filtered-member-count="filteredMemberCount" /> - + -import { computed, ref } from 'vue' -import type { MemberActivity } from '@/types/chat' +import { computed, ref, watch } from 'vue' +import type { MemberActivity, MemberNameHistory } from '@/types/chat' import { MemberRankList } from '@/components/charts' import type { MemberRankItem } from '@/components/charts' const props = defineProps<{ + sessionId: string memberActivity: MemberActivity[] }>() @@ -29,17 +30,86 @@ const fullRankData = computed(() => { }) const isOpen = ref(false) + +// 昵称变更记录 +interface MemberWithHistory { + memberId: number + name: string + history: MemberNameHistory[] +} + +const membersWithNicknameChanges = ref([]) +const isLoadingHistory = ref(false) + +// 加载有昵称变更的成员 +async function loadMembersWithNicknameChanges() { + if (!props.sessionId || props.memberActivity.length === 0) return + + isLoadingHistory.value = true + const membersWithChanges: MemberWithHistory[] = [] + + try { + // 并发查询所有成员的历史昵称 + const historyPromises = props.memberActivity.map((member) => + window.chatApi.getMemberNameHistory(props.sessionId, member.memberId) + ) + + const allHistories = await Promise.all(historyPromises) + + // 筛选出有昵称变更的成员(历史记录 > 1) + props.memberActivity.forEach((member, index) => { + const history = allHistories[index] + if (history.length > 1) { + membersWithChanges.push({ + memberId: member.memberId, + name: member.name, + history, + }) + } + }) + + membersWithNicknameChanges.value = membersWithChanges + } catch (error) { + console.error('加载昵称变更记录失败:', error) + } finally { + isLoadingHistory.value = false + } +} + +// 监听 sessionId 和 memberActivity 变化,重新加载昵称历史 +watch( + () => [props.sessionId, props.memberActivity.length], + () => { + loadMembersWithNicknameChanges() + }, + { immediate: true } +) + +// 格式化时间段(用于横向展示) +function formatPeriod(startTs: number, endTs: number | null): string { + const formatDate = (ts: number) => { + const date = new Date(ts * 1000) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } + + const start = formatDate(startTs) + if (endTs === null) { + return `${start} ~ 至今` + } + const end = formatDate(endTs) + if (start === end) { + return start + } + return `${start} ~ ${end}` +} - + + + + +
+
+

昵称变更记录

+

+ {{ + isLoadingHistory + ? '加载中...' + : membersWithNicknameChanges.length > 0 + ? `${membersWithNicknameChanges.length} 位成员曾修改过昵称` + : '暂无成员修改昵称' + }} +

+
+ +
+
+ +
+ {{ member.name }} +
+ + +
+ +
+
+
+ +
+ 该群组所有成员均未修改过昵称 +
+ +
正在加载昵称变更记录...
diff --git a/src/components/charts/MemberNicknameHistory.vue b/src/components/charts/MemberNicknameHistory.vue new file mode 100644 index 0000000..9e58286 --- /dev/null +++ b/src/components/charts/MemberNicknameHistory.vue @@ -0,0 +1,98 @@ + + + diff --git a/src/components/charts/MemberRankList.vue b/src/components/charts/MemberRankList.vue index 88aa3ca..035522e 100644 --- a/src/components/charts/MemberRankList.vue +++ b/src/components/charts/MemberRankList.vue @@ -1,5 +1,7 @@