mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat: 夜猫分析模块
This commit is contained in:
1 parent
72605a6ef4
commit
e2dcd4080f
7 files changed
+668
-13
No files matched your search
@@ -15,6 +15,12 @@ import type {
|
||||
ChainLengthDistribution,
|
||||
HotRepeatContent,
|
||||
CatchphraseAnalysis,
|
||||
NightOwlAnalysis,
|
||||
NightOwlRankItem,
|
||||
NightOwlTitle,
|
||||
TimeRankItem,
|
||||
ConsecutiveNightRecord,
|
||||
NightOwlChampion,
|
||||
} from '../../../src/types/chat'
|
||||
import { openDatabase } from './core'
|
||||
|
||||
@@ -658,3 +664,351 @@ export function getCatchphraseAnalysis(sessionId: string, filter?: TimeFilter):
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据深夜发言数获取称号
|
||||
*/
|
||||
function getNightOwlTitleByCount(count: number): NightOwlTitle {
|
||||
if (count === 0) return '养生达人'
|
||||
if (count <= 20) return '偶尔失眠'
|
||||
if (count <= 50) return '夜猫子'
|
||||
if (count <= 100) return '秃头预备役'
|
||||
if (count <= 200) return '修仙练习生'
|
||||
if (count <= 500) return '守夜冠军'
|
||||
return '不睡觉の神'
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间戳转换为"调整后的日期"(以凌晨5点为界)
|
||||
* 05:00 之前的消息算作前一天
|
||||
*/
|
||||
function getAdjustedDate(ts: number): string {
|
||||
const date = new Date(ts * 1000)
|
||||
const hour = date.getHours()
|
||||
|
||||
// 如果是凌晨 0-4 点,算作前一天
|
||||
if (hour < 5) {
|
||||
date.setDate(date.getDate() - 1)
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分钟数为 HH:MM
|
||||
*/
|
||||
function formatMinutes(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = Math.round(minutes % 60)
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取夜猫分析数据
|
||||
* 深夜时段定义:23:00-05:00
|
||||
* 一天定义:05:00 ~ 次日 04:59
|
||||
*/
|
||||
export function getNightOwlAnalysis(sessionId: string, filter?: TimeFilter): NightOwlAnalysis {
|
||||
const db = openDatabase(sessionId)
|
||||
const emptyResult: NightOwlAnalysis = {
|
||||
nightOwlRank: [],
|
||||
lastSpeakerRank: [],
|
||||
firstSpeakerRank: [],
|
||||
consecutiveRecords: [],
|
||||
champions: [],
|
||||
totalDays: 0,
|
||||
}
|
||||
|
||||
if (!db) return emptyResult
|
||||
|
||||
try {
|
||||
const { clause, params } = buildTimeFilter(filter)
|
||||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||||
|
||||
// 1. 获取所有消息(用于多种分析)
|
||||
const messages = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
msg.id,
|
||||
msg.sender_id as senderId,
|
||||
msg.ts,
|
||||
m.platform_id as platformId,
|
||||
m.name
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
${clauseWithSystem}
|
||||
ORDER BY msg.ts ASC
|
||||
`
|
||||
)
|
||||
.all(...params) as Array<{
|
||||
id: number
|
||||
senderId: number
|
||||
ts: number
|
||||
platformId: string
|
||||
name: string
|
||||
}>
|
||||
|
||||
if (messages.length === 0) return emptyResult
|
||||
|
||||
// 成员信息映射
|
||||
const memberInfo = new Map<number, { platformId: string; name: string }>()
|
||||
|
||||
// ========== 分析 1: 修仙排行榜 ==========
|
||||
const nightStats = new Map<
|
||||
number,
|
||||
{
|
||||
total: number
|
||||
h23: number
|
||||
h0: number
|
||||
h1: number
|
||||
h2: number
|
||||
h3to4: number
|
||||
totalMessages: number
|
||||
}
|
||||
>()
|
||||
|
||||
// ========== 分析 2 & 3: 最晚/最早发言 ==========
|
||||
// 按调整后的日期分组消息
|
||||
const dailyMessages = new Map<
|
||||
string,
|
||||
Array<{ senderId: number; ts: number; hour: number; minute: number }>
|
||||
>()
|
||||
|
||||
// ========== 分析 4: 连续修仙天数 ==========
|
||||
const memberNightDays = new Map<number, Set<string>>() // 成员 -> 有深夜发言的日期集合
|
||||
|
||||
for (const msg of messages) {
|
||||
// 记录成员信息
|
||||
if (!memberInfo.has(msg.senderId)) {
|
||||
memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name })
|
||||
}
|
||||
|
||||
const date = new Date(msg.ts * 1000)
|
||||
const hour = date.getHours()
|
||||
const minute = date.getMinutes()
|
||||
const adjustedDate = getAdjustedDate(msg.ts)
|
||||
|
||||
// 初始化成员夜猫统计
|
||||
if (!nightStats.has(msg.senderId)) {
|
||||
nightStats.set(msg.senderId, { total: 0, h23: 0, h0: 0, h1: 0, h2: 0, h3to4: 0, totalMessages: 0 })
|
||||
}
|
||||
const stats = nightStats.get(msg.senderId)!
|
||||
stats.totalMessages++
|
||||
|
||||
// 统计深夜发言 (23:00-05:00)
|
||||
if (hour === 23) {
|
||||
stats.h23++
|
||||
stats.total++
|
||||
} else if (hour === 0) {
|
||||
stats.h0++
|
||||
stats.total++
|
||||
} else if (hour === 1) {
|
||||
stats.h1++
|
||||
stats.total++
|
||||
} else if (hour === 2) {
|
||||
stats.h2++
|
||||
stats.total++
|
||||
} else if (hour >= 3 && hour < 5) {
|
||||
stats.h3to4++
|
||||
stats.total++
|
||||
}
|
||||
|
||||
// 记录深夜发言的日期(用于连续天数统计)
|
||||
if (hour >= 23 || hour < 5) {
|
||||
if (!memberNightDays.has(msg.senderId)) {
|
||||
memberNightDays.set(msg.senderId, new Set())
|
||||
}
|
||||
memberNightDays.get(msg.senderId)!.add(adjustedDate)
|
||||
}
|
||||
|
||||
// 按日期分组消息(用于最晚/最早发言统计)
|
||||
if (!dailyMessages.has(adjustedDate)) {
|
||||
dailyMessages.set(adjustedDate, [])
|
||||
}
|
||||
dailyMessages.get(adjustedDate)!.push({ senderId: msg.senderId, ts: msg.ts, hour, minute })
|
||||
}
|
||||
|
||||
const totalDays = dailyMessages.size
|
||||
|
||||
// ========== 构建修仙排行榜 ==========
|
||||
const nightOwlRank: NightOwlRankItem[] = []
|
||||
for (const [memberId, stats] of nightStats.entries()) {
|
||||
if (stats.total === 0) continue
|
||||
const info = memberInfo.get(memberId)!
|
||||
nightOwlRank.push({
|
||||
memberId,
|
||||
platformId: info.platformId,
|
||||
name: info.name,
|
||||
totalNightMessages: stats.total,
|
||||
title: getNightOwlTitleByCount(stats.total),
|
||||
hourlyBreakdown: {
|
||||
h23: stats.h23,
|
||||
h0: stats.h0,
|
||||
h1: stats.h1,
|
||||
h2: stats.h2,
|
||||
h3to4: stats.h3to4,
|
||||
},
|
||||
percentage: stats.totalMessages > 0 ? Math.round((stats.total / stats.totalMessages) * 10000) / 100 : 0,
|
||||
})
|
||||
}
|
||||
nightOwlRank.sort((a, b) => b.totalNightMessages - a.totalNightMessages)
|
||||
|
||||
// ========== 构建最晚/最早发言排行 ==========
|
||||
const lastSpeakerStats = new Map<number, { count: number; times: number[] }>()
|
||||
const firstSpeakerStats = new Map<number, { count: number; times: number[] }>()
|
||||
|
||||
for (const [, dayMessages] of dailyMessages.entries()) {
|
||||
if (dayMessages.length === 0) continue
|
||||
|
||||
// 找到当天最后发言的人
|
||||
const lastMsg = dayMessages[dayMessages.length - 1]
|
||||
if (!lastSpeakerStats.has(lastMsg.senderId)) {
|
||||
lastSpeakerStats.set(lastMsg.senderId, { count: 0, times: [] })
|
||||
}
|
||||
const lastStats = lastSpeakerStats.get(lastMsg.senderId)!
|
||||
lastStats.count++
|
||||
lastStats.times.push(lastMsg.hour * 60 + lastMsg.minute)
|
||||
|
||||
// 找到当天最早发言的人
|
||||
const firstMsg = dayMessages[0]
|
||||
if (!firstSpeakerStats.has(firstMsg.senderId)) {
|
||||
firstSpeakerStats.set(firstMsg.senderId, { count: 0, times: [] })
|
||||
}
|
||||
const firstStats = firstSpeakerStats.get(firstMsg.senderId)!
|
||||
firstStats.count++
|
||||
firstStats.times.push(firstMsg.hour * 60 + firstMsg.minute)
|
||||
}
|
||||
|
||||
// 构建最晚发言排行
|
||||
const lastSpeakerRank: TimeRankItem[] = []
|
||||
for (const [memberId, stats] of lastSpeakerStats.entries()) {
|
||||
const info = memberInfo.get(memberId)!
|
||||
const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length
|
||||
const maxMinutes = Math.max(...stats.times)
|
||||
lastSpeakerRank.push({
|
||||
memberId,
|
||||
platformId: info.platformId,
|
||||
name: info.name,
|
||||
count: stats.count,
|
||||
avgTime: formatMinutes(avgMinutes),
|
||||
extremeTime: formatMinutes(maxMinutes),
|
||||
percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0,
|
||||
})
|
||||
}
|
||||
lastSpeakerRank.sort((a, b) => b.count - a.count)
|
||||
|
||||
// 构建最早发言排行
|
||||
const firstSpeakerRank: TimeRankItem[] = []
|
||||
for (const [memberId, stats] of firstSpeakerStats.entries()) {
|
||||
const info = memberInfo.get(memberId)!
|
||||
const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length
|
||||
const minMinutes = Math.min(...stats.times)
|
||||
firstSpeakerRank.push({
|
||||
memberId,
|
||||
platformId: info.platformId,
|
||||
name: info.name,
|
||||
count: stats.count,
|
||||
avgTime: formatMinutes(avgMinutes),
|
||||
extremeTime: formatMinutes(minMinutes),
|
||||
percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0,
|
||||
})
|
||||
}
|
||||
firstSpeakerRank.sort((a, b) => b.count - a.count)
|
||||
|
||||
// ========== 构建连续修仙天数记录 ==========
|
||||
const consecutiveRecords: ConsecutiveNightRecord[] = []
|
||||
|
||||
for (const [memberId, nightDaysSet] of memberNightDays.entries()) {
|
||||
if (nightDaysSet.size === 0) continue
|
||||
|
||||
const info = memberInfo.get(memberId)!
|
||||
const sortedDays = Array.from(nightDaysSet).sort()
|
||||
|
||||
let maxStreak = 1
|
||||
let currentStreak = 1
|
||||
|
||||
for (let i = 1; i < sortedDays.length; i++) {
|
||||
const prevDate = new Date(sortedDays[i - 1])
|
||||
const currDate = new Date(sortedDays[i])
|
||||
const diffDays = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
|
||||
if (diffDays === 1) {
|
||||
currentStreak++
|
||||
maxStreak = Math.max(maxStreak, currentStreak)
|
||||
} else {
|
||||
currentStreak = 1
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前是否还在连续(最后一天是否是最近的)
|
||||
const lastDay = sortedDays[sortedDays.length - 1]
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]
|
||||
const isCurrentStreak = lastDay === today || lastDay === yesterday
|
||||
|
||||
consecutiveRecords.push({
|
||||
memberId,
|
||||
platformId: info.platformId,
|
||||
name: info.name,
|
||||
maxConsecutiveDays: maxStreak,
|
||||
currentStreak: isCurrentStreak ? currentStreak : 0,
|
||||
})
|
||||
}
|
||||
consecutiveRecords.sort((a, b) => b.maxConsecutiveDays - a.maxConsecutiveDays)
|
||||
|
||||
// ========== 构建修仙王者(综合排名) ==========
|
||||
// 综合得分 = 深夜发言数 × 1 + 最晚下班次数 × 10 + 连续修仙天数 × 20
|
||||
const championScores = new Map<number, { nightMessages: number; lastSpeakerCount: number; consecutiveDays: number }>()
|
||||
|
||||
for (const item of nightOwlRank) {
|
||||
if (!championScores.has(item.memberId)) {
|
||||
championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 })
|
||||
}
|
||||
championScores.get(item.memberId)!.nightMessages = item.totalNightMessages
|
||||
}
|
||||
|
||||
for (const item of lastSpeakerRank) {
|
||||
if (!championScores.has(item.memberId)) {
|
||||
championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 })
|
||||
}
|
||||
championScores.get(item.memberId)!.lastSpeakerCount = item.count
|
||||
}
|
||||
|
||||
for (const item of consecutiveRecords) {
|
||||
if (!championScores.has(item.memberId)) {
|
||||
championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 })
|
||||
}
|
||||
championScores.get(item.memberId)!.consecutiveDays = item.maxConsecutiveDays
|
||||
}
|
||||
|
||||
const champions: NightOwlChampion[] = []
|
||||
for (const [memberId, scores] of championScores.entries()) {
|
||||
const info = memberInfo.get(memberId)!
|
||||
const score = scores.nightMessages * 1 + scores.lastSpeakerCount * 10 + scores.consecutiveDays * 20
|
||||
if (score > 0) {
|
||||
champions.push({
|
||||
memberId,
|
||||
platformId: info.platformId,
|
||||
name: info.name,
|
||||
score,
|
||||
nightMessages: scores.nightMessages,
|
||||
lastSpeakerCount: scores.lastSpeakerCount,
|
||||
consecutiveDays: scores.consecutiveDays,
|
||||
})
|
||||
}
|
||||
}
|
||||
champions.sort((a, b) => b.score - a.score)
|
||||
|
||||
return {
|
||||
nightOwlRank,
|
||||
lastSpeakerRank,
|
||||
firstSpeakerRank,
|
||||
consecutiveRecords,
|
||||
champions,
|
||||
totalDays,
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
getMemberNameHistory,
|
||||
getRepeatAnalysis,
|
||||
getCatchphraseAnalysis,
|
||||
getNightOwlAnalysis,
|
||||
} from './analysis'
|
||||
|
||||
// 类型导出
|
||||
|
||||
@@ -405,6 +405,28 @@ const mainIpcMain = (win: BrowserWindow) => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取夜猫分析数据
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'chat:getNightOwlAnalysis',
|
||||
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
|
||||
try {
|
||||
return database.getNightOwlAnalysis(sessionId, filter)
|
||||
} catch (error) {
|
||||
console.error('获取夜猫分析失败:', error)
|
||||
return {
|
||||
nightOwlRank: [],
|
||||
lastSpeakerRank: [],
|
||||
firstSpeakerRank: [],
|
||||
consecutiveRecords: [],
|
||||
champions: [],
|
||||
totalDays: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default mainIpcMain
|
||||
Vendored
+2
@@ -10,6 +10,7 @@ import type {
|
||||
ImportProgress,
|
||||
RepeatAnalysis,
|
||||
CatchphraseAnalysis,
|
||||
NightOwlAnalysis,
|
||||
} from '../../src/types/chat'
|
||||
|
||||
interface TimeFilter {
|
||||
@@ -39,6 +40,7 @@ interface ChatApi {
|
||||
onImportProgress: (callback: (progress: ImportProgress) => void) => () => void
|
||||
getRepeatAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<RepeatAnalysis>
|
||||
getCatchphraseAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<CatchphraseAnalysis>
|
||||
getNightOwlAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<NightOwlAnalysis>
|
||||
}
|
||||
|
||||
interface Api {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ImportProgress,
|
||||
RepeatAnalysis,
|
||||
CatchphraseAnalysis,
|
||||
NightOwlAnalysis,
|
||||
} from '../../src/types/chat'
|
||||
|
||||
// Custom APIs for renderer
|
||||
@@ -179,6 +180,16 @@ const chatApi = {
|
||||
): Promise<CatchphraseAnalysis> => {
|
||||
return ipcRenderer.invoke('chat:getCatchphraseAnalysis', sessionId, filter)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取夜猫分析数据
|
||||
*/
|
||||
getNightOwlAnalysis: (
|
||||
sessionId: string,
|
||||
filter?: { startTs?: number; endTs?: number }
|
||||
): Promise<NightOwlAnalysis> => {
|
||||
return ipcRenderer.invoke('chat:getNightOwlAnalysis', sessionId, filter)
|
||||
},
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { HourlyActivity, WeekdayActivity } from '@/types/chat'
|
||||
import { BarChart } from '@/components/charts'
|
||||
import type { HourlyActivity, WeekdayActivity, NightOwlAnalysis } from '@/types/chat'
|
||||
import { BarChart, RankListPro } from '@/components/charts'
|
||||
import type { BarChartData } from '@/components/charts'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -14,9 +14,24 @@ const props = defineProps<{
|
||||
const weekdayActivity = ref<WeekdayActivity[]>([])
|
||||
const isLoadingWeekday = ref(false)
|
||||
|
||||
// 夜猫分析数据
|
||||
const nightOwlData = ref<NightOwlAnalysis | null>(null)
|
||||
const isLoadingNightOwl = ref(false)
|
||||
|
||||
// 星期名称映射(周一开始)
|
||||
const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
// 称号颜色映射
|
||||
const titleColors: Record<string, string> = {
|
||||
养生达人: 'text-green-600 dark:text-green-400',
|
||||
偶尔失眠: 'text-blue-600 dark:text-blue-400',
|
||||
夜猫子: 'text-yellow-600 dark:text-yellow-400',
|
||||
秃头预备役: 'text-orange-600 dark:text-orange-400',
|
||||
修仙练习生: 'text-pink-600 dark:text-pink-400',
|
||||
守夜冠军: 'text-purple-600 dark:text-purple-400',
|
||||
不睡觉の神: 'text-red-600 dark:text-red-400',
|
||||
}
|
||||
|
||||
// 加载星期活跃度数据
|
||||
async function loadWeekdayActivity() {
|
||||
if (!props.sessionId) return
|
||||
@@ -30,11 +45,25 @@ async function loadWeekdayActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载夜猫分析数据
|
||||
async function loadNightOwlAnalysis() {
|
||||
if (!props.sessionId) return
|
||||
isLoadingNightOwl.value = true
|
||||
try {
|
||||
nightOwlData.value = await window.chatApi.getNightOwlAnalysis(props.sessionId, props.timeFilter)
|
||||
} catch (error) {
|
||||
console.error('加载夜猫分析失败:', error)
|
||||
} finally {
|
||||
isLoadingNightOwl.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 sessionId 和 timeFilter 变化
|
||||
watch(
|
||||
() => [props.sessionId, props.timeFilter],
|
||||
() => {
|
||||
loadWeekdayActivity()
|
||||
loadNightOwlAnalysis()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
@@ -63,11 +92,13 @@ const peakHour = computed(() => {
|
||||
|
||||
const peakWeekday = computed(() => {
|
||||
if (!weekdayActivity.value.length) return null
|
||||
return weekdayActivity.value.reduce((max, w) => (w.messageCount > max.messageCount ? w : max), weekdayActivity.value[0])
|
||||
return weekdayActivity.value.reduce(
|
||||
(max, w) => (w.messageCount > max.messageCount ? w : max),
|
||||
weekdayActivity.value[0]
|
||||
)
|
||||
})
|
||||
|
||||
const lateNightRatio = computed(() => {
|
||||
// 深夜定义为 0-6 点
|
||||
const lateNight = props.hourlyActivity
|
||||
.filter((h) => h.hour >= 0 && h.hour < 6)
|
||||
.reduce((sum, h) => sum + h.messageCount, 0)
|
||||
@@ -76,7 +107,6 @@ const lateNightRatio = computed(() => {
|
||||
})
|
||||
|
||||
const morningRatio = computed(() => {
|
||||
// 早间定义为 6-12 点
|
||||
const morning = props.hourlyActivity
|
||||
.filter((h) => h.hour >= 6 && h.hour < 12)
|
||||
.reduce((sum, h) => sum + h.messageCount, 0)
|
||||
@@ -85,7 +115,6 @@ const morningRatio = computed(() => {
|
||||
})
|
||||
|
||||
const afternoonRatio = computed(() => {
|
||||
// 下午定义为 12-18 点
|
||||
const afternoon = props.hourlyActivity
|
||||
.filter((h) => h.hour >= 12 && h.hour < 18)
|
||||
.reduce((sum, h) => sum + h.messageCount, 0)
|
||||
@@ -94,7 +123,6 @@ const afternoonRatio = computed(() => {
|
||||
})
|
||||
|
||||
const eveningRatio = computed(() => {
|
||||
// 晚间定义为 18-24 点
|
||||
const evening = props.hourlyActivity
|
||||
.filter((h) => h.hour >= 18 && h.hour < 24)
|
||||
.reduce((sum, h) => sum + h.messageCount, 0)
|
||||
@@ -102,7 +130,6 @@ const eveningRatio = computed(() => {
|
||||
return total > 0 ? Math.round((evening / total) * 100) : 0
|
||||
})
|
||||
|
||||
// 工作日 vs 周末
|
||||
const weekdayVsWeekend = computed(() => {
|
||||
if (!weekdayActivity.value.length) return { weekday: 0, weekend: 0 }
|
||||
const weekdaySum = weekdayActivity.value
|
||||
@@ -117,6 +144,28 @@ const weekdayVsWeekend = computed(() => {
|
||||
weekend: total > 0 ? Math.round((weekendSum / total) * 100) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
// 最晚下班排行
|
||||
const lastSpeakerMembers = computed(() => {
|
||||
if (!nightOwlData.value) return []
|
||||
return nightOwlData.value.lastSpeakerRank.map((item) => ({
|
||||
id: String(item.memberId),
|
||||
name: item.name,
|
||||
value: item.count,
|
||||
percentage: item.percentage,
|
||||
}))
|
||||
})
|
||||
|
||||
// 最早上班排行
|
||||
const firstSpeakerMembers = computed(() => {
|
||||
if (!nightOwlData.value) return []
|
||||
return nightOwlData.value.firstSpeakerRank.map((item) => ({
|
||||
id: String(item.memberId),
|
||||
name: item.name,
|
||||
value: item.count,
|
||||
percentage: item.percentage,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -164,20 +213,25 @@ const weekdayVsWeekend = computed(() => {
|
||||
</div>
|
||||
<BarChart v-else :data="weekdayChartData" :height="256" />
|
||||
|
||||
<!-- 工作日 vs 周末 -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">工作日(周一至周五)</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{{ weekdayVsWeekend.weekday }}%</div>
|
||||
<div class="mx-auto mt-2 h-1 w-full max-w-24 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-pink-500 transition-all" :style="{ width: `${weekdayVsWeekend.weekday}%` }" />
|
||||
<div
|
||||
class="h-full rounded-full bg-pink-500 transition-all"
|
||||
:style="{ width: `${weekdayVsWeekend.weekday}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">周末(周六、周日)</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{{ weekdayVsWeekend.weekend }}%</div>
|
||||
<div class="mx-auto mt-2 h-1 w-full max-w-24 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-blue-500 transition-all" :style="{ width: `${weekdayVsWeekend.weekend}%` }" />
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-500 transition-all"
|
||||
:style="{ width: `${weekdayVsWeekend.weekend}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +246,6 @@ const weekdayVsWeekend = computed(() => {
|
||||
:x-label-filter="(_, index) => (index % 3 === 0 ? `${index}:00` : '')"
|
||||
/>
|
||||
|
||||
<!-- 时段分析 -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">凌晨 0-6点</div>
|
||||
@@ -224,6 +277,129 @@ const weekdayVsWeekend = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 夜猫分析模块 -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<span class="text-2xl">🦉</span>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">夜猫分析</h3>
|
||||
<span class="text-xs text-gray-400">深夜时段 23:00 - 05:00</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingNightOwl" class="flex h-32 items-center justify-center">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="nightOwlData">
|
||||
<!-- 修仙王者 TOP 3 -->
|
||||
<div v-if="nightOwlData.champions.length > 0" class="mb-6">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">🏆 修仙王者</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div
|
||||
v-for="(champion, index) in nightOwlData.champions.slice(0, 3)"
|
||||
:key="champion.memberId"
|
||||
class="relative overflow-hidden rounded-lg p-4"
|
||||
:class="[
|
||||
index === 0
|
||||
? 'bg-gradient-to-br from-amber-50 to-orange-100 dark:from-amber-900/20 dark:to-orange-900/20'
|
||||
: index === 1
|
||||
? 'bg-gradient-to-br from-gray-50 to-slate-100 dark:from-gray-800/50 dark:to-slate-800/50'
|
||||
: 'bg-gradient-to-br from-orange-50 to-amber-100 dark:from-orange-900/10 dark:to-amber-900/10',
|
||||
]"
|
||||
>
|
||||
<div class="absolute right-2 top-2 text-3xl opacity-20">
|
||||
{{ index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉' }}
|
||||
</div>
|
||||
<div class="text-lg font-bold text-gray-900 dark:text-white">{{ champion.name }}</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">综合得分 {{ champion.score }}</div>
|
||||
<div class="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>🌙 深夜发言 {{ champion.nightMessages }} 条</div>
|
||||
<div>🔚 最晚下班 {{ champion.lastSpeakerCount }} 次</div>
|
||||
<div>🔥 连续修仙 {{ champion.consecutiveDays }} 天</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修仙排行榜 -->
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">🌙 修仙排行榜</h4>
|
||||
<div v-if="nightOwlData.nightOwlRank.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="(item, index) in nightOwlData.nightOwlRank.slice(0, 10)"
|
||||
:key="item.memberId"
|
||||
class="flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50"
|
||||
>
|
||||
<span class="w-6 text-center text-sm font-bold text-gray-400">{{ index + 1 }}</span>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ item.name }}</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="titleColors[item.title]">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 flex gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>共 {{ item.totalNightMessages }} 条</span>
|
||||
<span>23点:{{ item.hourlyBreakdown.h23 }}</span>
|
||||
<span>0点:{{ item.hourlyBreakdown.h0 }}</span>
|
||||
<span>1点:{{ item.hourlyBreakdown.h1 }}</span>
|
||||
<span>2点:{{ item.hourlyBreakdown.h2 }}</span>
|
||||
<span>3-4点:{{ item.hourlyBreakdown.h3to4 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-pink-600 dark:text-pink-400">{{ item.percentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-8 text-center text-sm text-gray-400">暂无深夜发言数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 最晚下班 & 最早上班 -->
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- 最晚下班排名 -->
|
||||
<div>
|
||||
<RankListPro
|
||||
v-if="lastSpeakerMembers.length > 0"
|
||||
:members="lastSpeakerMembers"
|
||||
title="🔚 最晚下班排名"
|
||||
:description="`每天最后一个发言的人(共 ${nightOwlData.totalDays} 天)`"
|
||||
unit="次"
|
||||
/>
|
||||
<div v-else class="py-4 text-center text-sm text-gray-400">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 最早上班排名 -->
|
||||
<div>
|
||||
<RankListPro
|
||||
v-if="firstSpeakerMembers.length > 0"
|
||||
:members="firstSpeakerMembers"
|
||||
title="🌅 最早上班排名"
|
||||
:description="`每天第一个发言的人(共 ${nightOwlData.totalDays} 天)`"
|
||||
unit="次"
|
||||
/>
|
||||
<div v-else class="py-4 text-center text-sm text-gray-400">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连续修仙记录 -->
|
||||
<div v-if="nightOwlData.consecutiveRecords.length > 0" class="mt-6">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">🔥 连续修仙记录</h4>
|
||||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="record in nightOwlData.consecutiveRecords.slice(0, 6)"
|
||||
:key="record.memberId"
|
||||
class="flex items-center justify-between rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ record.name }}</span>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-pink-600 dark:text-pink-400">{{ record.maxConsecutiveDays }} 天</div>
|
||||
<div v-if="record.currentStreak > 0" class="text-xs text-green-600 dark:text-green-400">
|
||||
当前连续 {{ record.currentStreak }} 天
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -141,6 +141,95 @@ export interface WeekdayActivity {
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
// ==================== 夜猫分析类型 ====================
|
||||
|
||||
/**
|
||||
* 夜猫称号等级
|
||||
*/
|
||||
export type NightOwlTitle =
|
||||
| '养生达人'
|
||||
| '偶尔失眠'
|
||||
| '夜猫子'
|
||||
| '秃头预备役'
|
||||
| '修仙练习生'
|
||||
| '守夜冠军'
|
||||
| '不睡觉の神'
|
||||
|
||||
/**
|
||||
* 修仙排行榜项
|
||||
*/
|
||||
export interface NightOwlRankItem {
|
||||
memberId: number
|
||||
platformId: string
|
||||
name: string
|
||||
totalNightMessages: number // 深夜发言总数
|
||||
title: NightOwlTitle // 称号
|
||||
hourlyBreakdown: {
|
||||
// 各时段分布
|
||||
h23: number // 23:00-24:00
|
||||
h0: number // 00:00-01:00
|
||||
h1: number // 01:00-02:00
|
||||
h2: number // 02:00-03:00
|
||||
h3to4: number // 03:00-05:00
|
||||
}
|
||||
percentage: number // 占该用户总发言的百分比
|
||||
}
|
||||
|
||||
/**
|
||||
* 最晚/最早发言排行项
|
||||
*/
|
||||
export interface TimeRankItem {
|
||||
memberId: number
|
||||
platformId: string
|
||||
name: string
|
||||
count: number // 成为最晚/最早发言者的次数
|
||||
avgTime: string // 平均时间,格式 "HH:MM"
|
||||
extremeTime: string // 最极端时间,格式 "HH:MM"
|
||||
percentage: number // 占总天数的百分比
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续修仙记录
|
||||
*/
|
||||
export interface ConsecutiveNightRecord {
|
||||
memberId: number
|
||||
platformId: string
|
||||
name: string
|
||||
maxConsecutiveDays: number // 最长连续修仙天数
|
||||
currentStreak: number // 当前连续天数(如果还在持续)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修仙王者项(综合排名)
|
||||
*/
|
||||
export interface NightOwlChampion {
|
||||
memberId: number
|
||||
platformId: string
|
||||
name: string
|
||||
score: number // 综合得分
|
||||
nightMessages: number // 深夜发言数
|
||||
lastSpeakerCount: number // 最晚下班次数
|
||||
consecutiveDays: number // 最长连续天数
|
||||
}
|
||||
|
||||
/**
|
||||
* 夜猫分析完整结果
|
||||
*/
|
||||
export interface NightOwlAnalysis {
|
||||
/** 修仙排行榜 */
|
||||
nightOwlRank: NightOwlRankItem[]
|
||||
/** 最晚下班排名 */
|
||||
lastSpeakerRank: TimeRankItem[]
|
||||
/** 最早上班排名 */
|
||||
firstSpeakerRank: TimeRankItem[]
|
||||
/** 连续修仙记录 */
|
||||
consecutiveRecords: ConsecutiveNightRecord[]
|
||||
/** 修仙王者(综合排名) */
|
||||
champions: NightOwlChampion[]
|
||||
/** 统计的总天数 */
|
||||
totalDays: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析会话信息(用于会话列表展示)
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user