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(
-
-
-
-
-
-
- {{ index + 1 }}
-
-
{{ item.maxChainLength }}人
-
- {{ item.originatorName }}:
-
- {{ truncateContent(item.content) }}
-
-
-
- {{ item.count }} 次
- |
- {{ formatDate(item.lastTs) }}
-
-
-
-
@@ -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"
/>