From 479d81960ea0b1843a7a188e125a0130c56c9f18 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 30 Nov 2025 14:27:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E6=A6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipcMain.ts | 19 ++ electron/main/worker/dbWorker.ts | 2 + electron/main/worker/index.ts | 1 + electron/main/worker/queryAdvanced.ts | 168 ++++++++++++++++++ electron/main/worker/workerManager.ts | 4 + electron/preload/index.d.ts | 2 + electron/preload/index.ts | 8 + src/components/analysis/QuotesTab.vue | 55 +++++- src/components/analysis/RankingTab.vue | 49 ++++- .../analysis/ranking/CheckInRank.vue | 128 +++++++++++++ .../analysis/ranking/RepeatSection.vue | 58 +----- src/pages/chat.vue | 2 + src/types/chat.ts | 33 ++++ 13 files changed, 474 insertions(+), 55 deletions(-) create mode 100644 src/components/analysis/ranking/CheckInRank.vue diff --git a/electron/main/ipcMain.ts b/electron/main/ipcMain.ts index 1c7d849..0fbca24 100644 --- a/electron/main/ipcMain.ts +++ b/electron/main/ipcMain.ts @@ -556,6 +556,25 @@ const mainIpcMain = (win: BrowserWindow) => { } } ) + + /** + * 获取打卡分析数据(火花榜 + 忠臣榜) + */ + ipcMain.handle( + 'chat:getCheckInAnalysis', + async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { + try { + return await worker.getCheckInAnalysis(sessionId, filter) + } catch (error) { + console.error('获取打卡分析失败:', error) + return { + streakRank: [], + loyaltyRank: [], + totalDays: 0, + } + } + } + ) } export default mainIpcMain diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index a65c9da..5e83225 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -34,6 +34,7 @@ import { getMentionAnalysis, getLaughAnalysis, getMemeBattleAnalysis, + getCheckInAnalysis, } from './queryAdvanced' // 初始化数据库目录 @@ -82,6 +83,7 @@ const handlers: Record any> = { getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter), getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords), getMemeBattleAnalysis: (p) => getMemeBattleAnalysis(p.sessionId, p.filter), + getCheckInAnalysis: (p) => getCheckInAnalysis(p.sessionId, p.filter), } // 处理消息 diff --git a/electron/main/worker/index.ts b/electron/main/worker/index.ts index 37250b5..2a4d82a 100644 --- a/electron/main/worker/index.ts +++ b/electron/main/worker/index.ts @@ -26,6 +26,7 @@ export { getMentionAnalysis, getLaughAnalysis, getMemeBattleAnalysis, + getCheckInAnalysis, // 会话管理 API(异步) getAllSessions, getSession, diff --git a/electron/main/worker/queryAdvanced.ts b/electron/main/worker/queryAdvanced.ts index 5b9bd1b..a205c34 100644 --- a/electron/main/worker/queryAdvanced.ts +++ b/electron/main/worker/queryAdvanced.ts @@ -1622,3 +1622,171 @@ export function getMemeBattleAnalysis(sessionId: string, filter?: TimeFilter): a totalBattles: battles.length, } } + +// ==================== 打卡分析 ==================== + +/** + * 获取打卡分析数据(火花榜 + 忠臣榜) + */ +export function getCheckInAnalysis(sessionId: string, filter?: TimeFilter): any { + const db = openDatabase(sessionId) + const emptyResult = { + streakRank: [], + loyaltyRank: [], + totalDays: 0, + } + + if (!db) return emptyResult + + const { clause, params } = buildTimeFilter(filter) + const whereClause = buildSystemMessageFilter(clause) + + // 1. 获取每个成员每天是否发言的数据 + // 检查时间戳格式:如果 ts > 1e12 则是毫秒,否则是秒 + const sampleTs = db.prepare(`SELECT ts FROM message LIMIT 1`).get() as { ts: number } | undefined + const tsIsMillis = sampleTs?.ts && sampleTs.ts > 1e12 + const tsExpr = tsIsMillis ? 'msg.ts / 1000' : 'msg.ts' + + const dailyActivity = db + .prepare( + ` + SELECT + msg.sender_id as senderId, + m.name, + DATE(${tsExpr}, 'unixepoch', 'localtime') as day + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${whereClause} + GROUP BY msg.sender_id, day + ORDER BY msg.sender_id, day + ` + ) + .all(...params) as Array<{ + senderId: number + name: string + day: string + }> + + if (dailyActivity.length === 0) return emptyResult + + // 2. 获取群聊总天数 + const allDays = new Set(dailyActivity.map((r) => r.day)) + const totalDays = allDays.size + + // 获取最后一天(用于判断当前连续) + const sortedDays = Array.from(allDays).sort() + const lastDay = sortedDays[sortedDays.length - 1] + + // 3. 按成员分组 + const memberDays = new Map }>() + for (const record of dailyActivity) { + if (!memberDays.has(record.senderId)) { + memberDays.set(record.senderId, { name: record.name, days: new Set() }) + } + memberDays.get(record.senderId)!.days.add(record.day) + } + + // 4. 计算每个成员的连续发言和累计发言 + const streakData: Array<{ + memberId: number + name: string + maxStreak: number + maxStreakStart: string + maxStreakEnd: string + currentStreak: number + }> = [] + + const loyaltyData: Array<{ + memberId: number + name: string + totalDays: number + }> = [] + + for (const [memberId, data] of memberDays) { + const sortedMemberDays = Array.from(data.days).sort() + const totalMemberDays = sortedMemberDays.length + + // 计算最长连续 + let maxStreak = 1 + let maxStreakStart = sortedMemberDays[0] + let maxStreakEnd = sortedMemberDays[0] + + let currentStreakCount = 1 + let currentStreakStart = sortedMemberDays[0] + + for (let i = 1; i < sortedMemberDays.length; i++) { + const prevDate = new Date(sortedMemberDays[i - 1]) + const currDate = new Date(sortedMemberDays[i]) + const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)) + + if (diffDays === 1) { + // 连续 + currentStreakCount++ + } else { + // 中断,检查是否更新最大值 + if (currentStreakCount > maxStreak) { + maxStreak = currentStreakCount + maxStreakStart = currentStreakStart + maxStreakEnd = sortedMemberDays[i - 1] + } + currentStreakCount = 1 + currentStreakStart = sortedMemberDays[i] + } + } + + // 检查最后一段连续 + if (currentStreakCount > maxStreak) { + maxStreak = currentStreakCount + maxStreakStart = currentStreakStart + maxStreakEnd = sortedMemberDays[sortedMemberDays.length - 1] + } + + // 计算当前连续(是否以最后一天结束) + let finalCurrentStreak = 0 + if (sortedMemberDays[sortedMemberDays.length - 1] === lastDay) { + // 从最后一天往前数 + finalCurrentStreak = 1 + for (let i = sortedMemberDays.length - 2; i >= 0; i--) { + const currDate = new Date(sortedMemberDays[i + 1]) + const prevDate = new Date(sortedMemberDays[i]) + const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)) + if (diffDays === 1) { + finalCurrentStreak++ + } else { + break + } + } + } + + streakData.push({ + memberId, + name: data.name, + maxStreak, + maxStreakStart, + maxStreakEnd, + currentStreak: finalCurrentStreak, + }) + + loyaltyData.push({ + memberId, + name: data.name, + totalDays: totalMemberDays, + }) + } + + // 5. 排序 + const streakRank = streakData.sort((a, b) => b.maxStreak - a.maxStreak) + + const sortedLoyalty = loyaltyData.sort((a, b) => b.totalDays - a.totalDays) + const maxLoyaltyDays = sortedLoyalty.length > 0 ? sortedLoyalty[0].totalDays : 1 + const loyaltyRank = sortedLoyalty.map((item) => ({ + ...item, + percentage: Math.round((item.totalDays / maxLoyaltyDays) * 100), + })) + + return { + streakRank, + loyaltyRank, + totalDays, + } +} diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 7c0af28..26d9aa3 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -243,6 +243,10 @@ export async function getMemeBattleAnalysis(sessionId: string, filter?: any): Pr return sendToWorker('getMemeBattleAnalysis', { sessionId, filter }) } +export async function getCheckInAnalysis(sessionId: string, filter?: any): Promise { + return sendToWorker('getCheckInAnalysis', { sessionId, filter }) +} + export async function getAllSessions(): Promise { return sendToWorker('getAllSessions', {}) } diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 6c96a0d..72c1114 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -18,6 +18,7 @@ import type { MentionAnalysis, LaughAnalysis, MemeBattleAnalysis, + CheckInAnalysis, } from '../../src/types/chat' interface TimeFilter { @@ -55,6 +56,7 @@ interface ChatApi { getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise + getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise } interface Api { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index e68a221..a495e1e 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -18,6 +18,7 @@ import type { MonologueAnalysis, MentionAnalysis, LaughAnalysis, + CheckInAnalysis, MemeBattleAnalysis, } from '../../src/types/chat' @@ -265,6 +266,13 @@ const chatApi = { ): Promise => { return ipcRenderer.invoke('chat:getMemeBattleAnalysis', sessionId, filter) }, + + /** + * 获取打卡分析数据(火花榜 + 忠臣榜) + */ + getCheckInAnalysis: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { + return ipcRenderer.invoke('chat:getCheckInAnalysis', sessionId, filter) + }, } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/components/analysis/QuotesTab.vue b/src/components/analysis/QuotesTab.vue index 46103bc..8304c49 100644 --- a/src/components/analysis/QuotesTab.vue +++ b/src/components/analysis/QuotesTab.vue @@ -1,9 +1,10 @@ + + diff --git a/src/components/analysis/ranking/RepeatSection.vue b/src/components/analysis/ranking/RepeatSection.vue index 00e951f..251e70d 100644 --- a/src/components/analysis/ranking/RepeatSection.vue +++ b/src/components/analysis/ranking/RepeatSection.vue @@ -4,7 +4,7 @@ import type { RepeatAnalysis } from '@/types/chat' import { RankListPro, BarChart, ListPro } from '@/components/charts' import type { RankItem, BarChartData } from '@/components/charts' import { SectionCard, EmptyState, LoadingState } from '@/components/UI' -import { formatDate, getRankBadgeClass } from '@/utils' +import { getRankBadgeClass } from '@/utils' interface TimeFilter { startTs?: number @@ -18,7 +18,7 @@ const props = defineProps<{ const analysis = ref(null) const isLoading = ref(false) -const rankMode = ref<'count' | 'rate'>('rate') +const rankMode = ref<'count' | 'rate'>('count') async function loadData() { if (!props.sessionId) return @@ -32,11 +32,6 @@ async function loadData() { } } -function truncateContent(content: string, maxLength = 30): string { - if (content.length <= maxLength) return content - return content.slice(0, maxLength) + '...' -} - const originatorRankData = computed(() => { if (!analysis.value) return [] const data = rankMode.value === 'count' ? analysis.value.originators : analysis.value.originatorRates @@ -102,8 +97,8 @@ watch( v-if="analysis && analysis.totalRepeatChains > 0" v-model="rankMode" :items="[ - { label: '按复读率', value: 'rate' }, { label: '按次数', value: 'count' }, + { label: '按复读率', value: 'rate' }, ]" size="xs" /> @@ -125,38 +120,6 @@ watch( - - - - - @@ -210,27 +173,24 @@ watch(

- +
-
-
+
+ {{ (member.avgTimeDiff / 1000).toFixed(2) }}s -
-
参与 {{ member.count }} 次
+ + · {{ member.count }} 次
diff --git a/src/pages/chat.vue b/src/pages/chat.vue index dd92009..e9ce3ab 100644 --- a/src/pages/chat.vue +++ b/src/pages/chat.vue @@ -321,6 +321,8 @@ onMounted(() => { :session-id="currentSessionId!" :member-activity="memberActivity" :time-filter="timeFilter" + :selected-year="selectedYear" + :available-years="availableYears" />