mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-03 03:31:18 +08:00
feat: 重构文件架构
This commit is contained in:
611
electron/main/database/analysis.ts
Normal file
611
electron/main/database/analysis.ts
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
/**
|
||||||
|
* 数据库分析模块
|
||||||
|
* 负责各种数据分析查询
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MemberActivity,
|
||||||
|
HourlyActivity,
|
||||||
|
DailyActivity,
|
||||||
|
MessageType,
|
||||||
|
RepeatAnalysis,
|
||||||
|
RepeatStatItem,
|
||||||
|
RepeatRateItem,
|
||||||
|
ChainLengthDistribution,
|
||||||
|
HotRepeatContent,
|
||||||
|
CatchphraseAnalysis,
|
||||||
|
} from '../../../src/types/chat'
|
||||||
|
import { openDatabase } from './core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间过滤参数
|
||||||
|
*/
|
||||||
|
export interface TimeFilter {
|
||||||
|
startTs?: number
|
||||||
|
endTs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建时间过滤 WHERE 子句
|
||||||
|
*/
|
||||||
|
function buildTimeFilter(filter?: TimeFilter): { clause: string; params: number[] } {
|
||||||
|
const conditions: string[] = []
|
||||||
|
const params: number[] = []
|
||||||
|
|
||||||
|
if (filter?.startTs !== undefined) {
|
||||||
|
conditions.push('ts >= ?')
|
||||||
|
params.push(filter.startTs)
|
||||||
|
}
|
||||||
|
if (filter?.endTs !== undefined) {
|
||||||
|
conditions.push('ts <= ?')
|
||||||
|
params.push(filter.endTs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clause: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '',
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建排除系统消息的过滤条件
|
||||||
|
*/
|
||||||
|
function buildSystemMessageFilter(existingClause: string): string {
|
||||||
|
const systemFilter = "m.name != '系统消息'"
|
||||||
|
|
||||||
|
if (existingClause.includes('WHERE')) {
|
||||||
|
return existingClause + ' AND ' + systemFilter
|
||||||
|
} else {
|
||||||
|
return ' WHERE ' + systemFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可用的年份列表
|
||||||
|
*/
|
||||||
|
export function getAvailableYears(sessionId: string): number[] {
|
||||||
|
const db = openDatabase(sessionId)
|
||||||
|
if (!db) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT CAST(strftime('%Y', ts, 'unixepoch', 'localtime') AS INTEGER) as year
|
||||||
|
FROM message
|
||||||
|
ORDER BY year DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all() as Array<{ year: number }>
|
||||||
|
|
||||||
|
return rows.map((r) => r.year)
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取成员活跃度排行
|
||||||
|
*/
|
||||||
|
export function getMemberActivity(sessionId: string, filter?: TimeFilter): MemberActivity[] {
|
||||||
|
const db = openDatabase(sessionId)
|
||||||
|
if (!db) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clause, params } = buildTimeFilter(filter)
|
||||||
|
|
||||||
|
const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : ''
|
||||||
|
const msgFilterWithSystem = msgFilterBase + " AND m.name != '系统消息'"
|
||||||
|
|
||||||
|
const totalClauseWithSystem = buildSystemMessageFilter(clause)
|
||||||
|
const totalMessages = (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM message msg
|
||||||
|
JOIN member m ON msg.sender_id = m.id
|
||||||
|
${totalClauseWithSystem}`
|
||||||
|
)
|
||||||
|
.get(...params) as { count: number }
|
||||||
|
).count
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
m.id as memberId,
|
||||||
|
m.platform_id as platformId,
|
||||||
|
m.name,
|
||||||
|
COUNT(msg.id) as messageCount
|
||||||
|
FROM member m
|
||||||
|
LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem}
|
||||||
|
WHERE m.name != '系统消息'
|
||||||
|
GROUP BY m.id
|
||||||
|
HAVING messageCount > 0
|
||||||
|
ORDER BY messageCount DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(...params) as Array<{
|
||||||
|
memberId: number
|
||||||
|
platformId: string
|
||||||
|
name: string
|
||||||
|
messageCount: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
memberId: row.memberId,
|
||||||
|
platformId: row.platformId,
|
||||||
|
name: row.name,
|
||||||
|
messageCount: row.messageCount,
|
||||||
|
percentage: totalMessages > 0 ? Math.round((row.messageCount / totalMessages) * 10000) / 100 : 0,
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取每小时活跃度分布
|
||||||
|
*/
|
||||||
|
export function getHourlyActivity(sessionId: string, filter?: TimeFilter): HourlyActivity[] {
|
||||||
|
const db = openDatabase(sessionId)
|
||||||
|
if (!db) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clause, params } = buildTimeFilter(filter)
|
||||||
|
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
CAST(strftime('%H', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as hour,
|
||||||
|
COUNT(*) as messageCount
|
||||||
|
FROM message msg
|
||||||
|
JOIN member m ON msg.sender_id = m.id
|
||||||
|
${clauseWithSystem}
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(...params) as Array<{ hour: number; messageCount: number }>
|
||||||
|
|
||||||
|
const result: HourlyActivity[] = []
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
const found = rows.find((r) => r.hour === h)
|
||||||
|
result.push({
|
||||||
|
hour: h,
|
||||||
|
messageCount: found ? found.messageCount : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取每日活跃度趋势
|
||||||
|
*/
|
||||||
|
export function getDailyActivity(sessionId: string, filter?: TimeFilter): DailyActivity[] {
|
||||||
|
const db = openDatabase(sessionId)
|
||||||
|
if (!db) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clause, params } = buildTimeFilter(filter)
|
||||||
|
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date,
|
||||||
|
COUNT(*) as messageCount
|
||||||
|
FROM message msg
|
||||||
|
JOIN member m ON msg.sender_id = m.id
|
||||||
|
${clauseWithSystem}
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(...params) as Array<{ date: string; messageCount: number }>
|
||||||
|
|
||||||
|
return rows
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息类型分布
|
||||||
|
*/
|
||||||
|
export function getMessageTypeDistribution(
|
||||||
|
sessionId: string,
|
||||||
|
filter?: TimeFilter
|
||||||
|
): Array<{ type: MessageType; count: number }> {
|
||||||
|
const db = openDatabase(sessionId)
|
||||||
|
if (!db) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clause, params } = buildTimeFilter(filter)
|
||||||
|
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT msg.type, COUNT(*) as count
|
||||||
|
FROM message msg
|
||||||
|
JOIN member m ON msg.sender_id = m.id
|
||||||
|
${clauseWithSystem}
|
||||||
|
GROUP BY msg.type
|
||||||
|
ORDER BY count DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(...params) as Array<{ type: number; count: number }>
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
type: r.type as MessageType,
|
||||||
|
count: r.count,
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取时间范围
|
||||||
|
*/
|
||||||
|
export function getTimeRange(sessionId: string): { start: number; end: number } | null {
|
||||||
|
const db = openDatabase(sessionId)
|
||||||
|
if (!db) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT MIN(ts) as start, MAX(ts) as end FROM message
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get() as { start: number | null; end: number | null }
|
||||||
|
|
||||||
|
if (row.start === null || row.end === null) return null
|
||||||
|
|
||||||
|
return { start: row.start, end: row.end }
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取成员的历史昵称记录
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取复读分析数据
|
||||||
|
* 使用滑动窗口算法检测复读链:
|
||||||
|
* - 复读成立条件:至少 3 条连续的相同内容消息,且发送者不同
|
||||||
|
* - 排除:系统消息、空消息、图片消息
|
||||||
|
*/
|
||||||
|
export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): RepeatAnalysis {
|
||||||
|
const db = openDatabase(sessionId)
|
||||||
|
const emptyResult: RepeatAnalysis = {
|
||||||
|
originators: [],
|
||||||
|
initiators: [],
|
||||||
|
breakers: [],
|
||||||
|
originatorRates: [],
|
||||||
|
initiatorRates: [],
|
||||||
|
breakerRates: [],
|
||||||
|
chainLengthDistribution: [],
|
||||||
|
hotContents: [],
|
||||||
|
avgChainLength: 0,
|
||||||
|
totalRepeatChains: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return emptyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clause, params } = buildTimeFilter(filter)
|
||||||
|
|
||||||
|
let whereClause = clause
|
||||||
|
if (whereClause.includes('WHERE')) {
|
||||||
|
whereClause +=
|
||||||
|
" AND m.name != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''"
|
||||||
|
} else {
|
||||||
|
whereClause =
|
||||||
|
" WHERE m.name != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''"
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
msg.id,
|
||||||
|
msg.sender_id as senderId,
|
||||||
|
msg.content,
|
||||||
|
msg.ts,
|
||||||
|
m.platform_id as platformId,
|
||||||
|
m.name
|
||||||
|
FROM message msg
|
||||||
|
JOIN member m ON msg.sender_id = m.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY msg.ts ASC, msg.id ASC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(...params) as Array<{
|
||||||
|
id: number
|
||||||
|
senderId: number
|
||||||
|
content: string
|
||||||
|
ts: number
|
||||||
|
platformId: string
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
const originatorCount = new Map<number, number>()
|
||||||
|
const initiatorCount = new Map<number, number>()
|
||||||
|
const breakerCount = new Map<number, number>()
|
||||||
|
const memberMessageCount = new Map<number, number>()
|
||||||
|
|
||||||
|
const memberInfo = new Map<number, { platformId: string; name: string }>()
|
||||||
|
|
||||||
|
const chainLengthCount = new Map<number, number>()
|
||||||
|
|
||||||
|
const contentStats = new Map<
|
||||||
|
string,
|
||||||
|
{ count: number; maxChainLength: number; originatorId: number; lastTs: number }
|
||||||
|
>()
|
||||||
|
|
||||||
|
let currentContent: string | null = null
|
||||||
|
let repeatChain: Array<{ senderId: number; content: string; ts: number }> = []
|
||||||
|
let totalRepeatChains = 0
|
||||||
|
let totalChainLength = 0
|
||||||
|
|
||||||
|
const processRepeatChain = (
|
||||||
|
chain: Array<{ senderId: number; content: string; ts: number }>,
|
||||||
|
breakerId?: number
|
||||||
|
) => {
|
||||||
|
if (chain.length < 3) return
|
||||||
|
|
||||||
|
totalRepeatChains++
|
||||||
|
const chainLength = chain.length
|
||||||
|
totalChainLength += chainLength
|
||||||
|
|
||||||
|
const originatorId = chain[0].senderId
|
||||||
|
originatorCount.set(originatorId, (originatorCount.get(originatorId) || 0) + 1)
|
||||||
|
|
||||||
|
const initiatorId = chain[1].senderId
|
||||||
|
initiatorCount.set(initiatorId, (initiatorCount.get(initiatorId) || 0) + 1)
|
||||||
|
|
||||||
|
if (breakerId !== undefined) {
|
||||||
|
breakerCount.set(breakerId, (breakerCount.get(breakerId) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
chainLengthCount.set(chainLength, (chainLengthCount.get(chainLength) || 0) + 1)
|
||||||
|
|
||||||
|
const content = chain[0].content
|
||||||
|
const chainTs = chain[0].ts
|
||||||
|
const existing = contentStats.get(content)
|
||||||
|
if (existing) {
|
||||||
|
existing.count++
|
||||||
|
existing.lastTs = Math.max(existing.lastTs, chainTs)
|
||||||
|
if (chainLength > existing.maxChainLength) {
|
||||||
|
existing.maxChainLength = chainLength
|
||||||
|
existing.originatorId = originatorId
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentStats.set(content, { count: 1, maxChainLength: chainLength, originatorId, lastTs: chainTs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!memberInfo.has(msg.senderId)) {
|
||||||
|
memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
memberMessageCount.set(msg.senderId, (memberMessageCount.get(msg.senderId) || 0) + 1)
|
||||||
|
|
||||||
|
const content = msg.content.trim()
|
||||||
|
|
||||||
|
if (content === currentContent) {
|
||||||
|
const lastSender = repeatChain[repeatChain.length - 1]?.senderId
|
||||||
|
if (lastSender !== msg.senderId) {
|
||||||
|
repeatChain.push({ senderId: msg.senderId, content, ts: msg.ts })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
processRepeatChain(repeatChain, msg.senderId)
|
||||||
|
|
||||||
|
currentContent = content
|
||||||
|
repeatChain = [{ senderId: msg.senderId, content, ts: msg.ts }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processRepeatChain(repeatChain)
|
||||||
|
|
||||||
|
const buildRankList = (countMap: Map<number, number>, total: number): RepeatStatItem[] => {
|
||||||
|
const items: RepeatStatItem[] = []
|
||||||
|
for (const [memberId, count] of countMap.entries()) {
|
||||||
|
const info = memberInfo.get(memberId)
|
||||||
|
if (info) {
|
||||||
|
items.push({
|
||||||
|
memberId,
|
||||||
|
platformId: info.platformId,
|
||||||
|
name: info.name,
|
||||||
|
count,
|
||||||
|
percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => b.count - a.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildRateList = (countMap: Map<number, number>): RepeatRateItem[] => {
|
||||||
|
const items: RepeatRateItem[] = []
|
||||||
|
for (const [memberId, count] of countMap.entries()) {
|
||||||
|
const info = memberInfo.get(memberId)
|
||||||
|
const totalMessages = memberMessageCount.get(memberId) || 0
|
||||||
|
if (info && totalMessages > 0) {
|
||||||
|
items.push({
|
||||||
|
memberId,
|
||||||
|
platformId: info.platformId,
|
||||||
|
name: info.name,
|
||||||
|
count,
|
||||||
|
totalMessages,
|
||||||
|
rate: Math.round((count / totalMessages) * 10000) / 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => b.rate - a.rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chainLengthDistribution: ChainLengthDistribution[] = []
|
||||||
|
for (const [length, count] of chainLengthCount.entries()) {
|
||||||
|
chainLengthDistribution.push({ length, count })
|
||||||
|
}
|
||||||
|
chainLengthDistribution.sort((a, b) => a.length - b.length)
|
||||||
|
|
||||||
|
const hotContents: HotRepeatContent[] = []
|
||||||
|
for (const [content, stats] of contentStats.entries()) {
|
||||||
|
const originatorInfo = memberInfo.get(stats.originatorId)
|
||||||
|
hotContents.push({
|
||||||
|
content,
|
||||||
|
count: stats.count,
|
||||||
|
maxChainLength: stats.maxChainLength,
|
||||||
|
originatorName: originatorInfo?.name || '未知',
|
||||||
|
lastTs: stats.lastTs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hotContents.sort((a, b) => b.maxChainLength - a.maxChainLength)
|
||||||
|
const top10HotContents = hotContents.slice(0, 10)
|
||||||
|
|
||||||
|
return {
|
||||||
|
originators: buildRankList(originatorCount, totalRepeatChains),
|
||||||
|
initiators: buildRankList(initiatorCount, totalRepeatChains),
|
||||||
|
breakers: buildRankList(breakerCount, totalRepeatChains),
|
||||||
|
originatorRates: buildRateList(originatorCount),
|
||||||
|
initiatorRates: buildRateList(initiatorCount),
|
||||||
|
breakerRates: buildRateList(breakerCount),
|
||||||
|
chainLengthDistribution,
|
||||||
|
hotContents: top10HotContents,
|
||||||
|
avgChainLength: totalRepeatChains > 0 ? Math.round((totalChainLength / totalRepeatChains) * 100) / 100 : 0,
|
||||||
|
totalRepeatChains,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取口头禅分析数据
|
||||||
|
* 统计每个成员最常说的内容(前5个)
|
||||||
|
* - 排除:系统消息、空消息、图片消息
|
||||||
|
* - 排除:过短的内容(少于2个字符)
|
||||||
|
*/
|
||||||
|
export function getCatchphraseAnalysis(sessionId: string, filter?: TimeFilter): CatchphraseAnalysis {
|
||||||
|
const db = openDatabase(sessionId)
|
||||||
|
if (!db) {
|
||||||
|
return { members: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clause, params } = buildTimeFilter(filter)
|
||||||
|
|
||||||
|
let whereClause = clause
|
||||||
|
if (whereClause.includes('WHERE')) {
|
||||||
|
whereClause +=
|
||||||
|
" AND m.name != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2"
|
||||||
|
} else {
|
||||||
|
whereClause =
|
||||||
|
" WHERE m.name != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2"
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
m.id as memberId,
|
||||||
|
m.platform_id as platformId,
|
||||||
|
m.name,
|
||||||
|
TRIM(msg.content) as content,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM message msg
|
||||||
|
JOIN member m ON msg.sender_id = m.id
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY m.id, TRIM(msg.content)
|
||||||
|
ORDER BY m.id, count DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(...params) as Array<{
|
||||||
|
memberId: number
|
||||||
|
platformId: string
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
const memberMap = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
memberId: number
|
||||||
|
platformId: string
|
||||||
|
name: string
|
||||||
|
catchphrases: Array<{ content: string; count: number }>
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!memberMap.has(row.memberId)) {
|
||||||
|
memberMap.set(row.memberId, {
|
||||||
|
memberId: row.memberId,
|
||||||
|
platformId: row.platformId,
|
||||||
|
name: row.name,
|
||||||
|
catchphrases: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = memberMap.get(row.memberId)!
|
||||||
|
if (member.catchphrases.length < 5) {
|
||||||
|
member.catchphrases.push({
|
||||||
|
content: row.content,
|
||||||
|
count: row.count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = Array.from(memberMap.values())
|
||||||
|
members.sort((a, b) => {
|
||||||
|
const aTotal = a.catchphrases.reduce((sum, c) => sum + c.count, 0)
|
||||||
|
const bTotal = b.catchphrases.reduce((sum, c) => sum + c.count, 0)
|
||||||
|
return bTotal - aTotal
|
||||||
|
})
|
||||||
|
|
||||||
|
return { members }
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
379
electron/main/database/core.ts
Normal file
379
electron/main/database/core.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* 数据库核心模块
|
||||||
|
* 负责数据库的创建、打开、关闭和数据导入
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import type { DbMeta, ParseResult, AnalysisSession } from '../../../src/types/chat'
|
||||||
|
|
||||||
|
// 数据库存储目录
|
||||||
|
let DB_DIR: string | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据库目录(懒加载)
|
||||||
|
*/
|
||||||
|
function getDbDir(): string {
|
||||||
|
if (DB_DIR) return DB_DIR
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docPath = app.getPath('documents')
|
||||||
|
console.log('[Database] app.getPath("documents"):', docPath)
|
||||||
|
DB_DIR = path.join(docPath, 'ChatLens', 'databases')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] Error getting userData path:', error)
|
||||||
|
DB_DIR = path.join(process.cwd(), 'databases')
|
||||||
|
console.log('[Database] Using fallback DB_DIR:', DB_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保数据库目录存在
|
||||||
|
*/
|
||||||
|
function ensureDbDir(): void {
|
||||||
|
const dir = getDbDir()
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一的会话ID
|
||||||
|
*/
|
||||||
|
function generateSessionId(): string {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const random = Math.random().toString(36).substring(2, 8)
|
||||||
|
return `chat_${timestamp}_${random}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据库文件路径
|
||||||
|
*/
|
||||||
|
export function getDbPath(sessionId: string): string {
|
||||||
|
return path.join(getDbDir(), `${sessionId}.db`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新数据库并初始化表结构
|
||||||
|
*/
|
||||||
|
function createDatabase(sessionId: string): Database.Database {
|
||||||
|
ensureDbDir()
|
||||||
|
const dbPath = getDbPath(sessionId)
|
||||||
|
const db = new Database(dbPath)
|
||||||
|
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
imported_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS member (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
platform_id TEXT NOT NULL UNIQUE,
|
||||||
|
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,
|
||||||
|
sender_id INTEGER NOT NULL,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
FOREIGN KEY(sender_id) REFERENCES member(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开已存在的数据库
|
||||||
|
*/
|
||||||
|
export function openDatabase(sessionId: string): Database.Database | null {
|
||||||
|
const dbPath = getDbPath(sessionId)
|
||||||
|
if (!fs.existsSync(dbPath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const db = new Database(dbPath, { readonly: true })
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入解析后的数据到数据库
|
||||||
|
*/
|
||||||
|
export function importData(parseResult: ParseResult): string {
|
||||||
|
console.log('[Database] importData called')
|
||||||
|
const sessionId = generateSessionId()
|
||||||
|
console.log('[Database] Generated sessionId:', sessionId)
|
||||||
|
|
||||||
|
const dbPath = getDbPath(sessionId)
|
||||||
|
console.log('[Database] Creating database at:', dbPath)
|
||||||
|
|
||||||
|
const db = createDatabase(sessionId)
|
||||||
|
console.log('[Database] Database created successfully')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const importTransaction = db.transaction(() => {
|
||||||
|
const insertMeta = db.prepare(`
|
||||||
|
INSERT INTO meta (name, platform, type, imported_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`)
|
||||||
|
insertMeta.run(
|
||||||
|
parseResult.meta.name,
|
||||||
|
parseResult.meta.platform,
|
||||||
|
parseResult.meta.type,
|
||||||
|
Math.floor(Date.now() / 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
const insertMember = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO member (platform_id, name) VALUES (?, ?)
|
||||||
|
`)
|
||||||
|
const getMemberId = db.prepare(`
|
||||||
|
SELECT id FROM member WHERE platform_id = ?
|
||||||
|
`)
|
||||||
|
|
||||||
|
const memberIdMap = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const member of parseResult.members) {
|
||||||
|
insertMember.run(member.platformId, member.name)
|
||||||
|
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)
|
||||||
|
const nicknameTracker = new Map<string, { currentName: string; lastSeenTs: number }>()
|
||||||
|
|
||||||
|
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 sortedMessages) {
|
||||||
|
const senderId = memberIdMap.get(msg.senderPlatformId)
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
insertNameHistory.run(senderId, currentName, msg.timestamp, null)
|
||||||
|
} else if (tracker.currentName !== currentName) {
|
||||||
|
updateNameHistoryEndTs.run(msg.timestamp, senderId)
|
||||||
|
insertNameHistory.run(senderId, currentName, msg.timestamp, null)
|
||||||
|
tracker.currentName = currentName
|
||||||
|
tracker.lastSeenTs = msg.timestamp
|
||||||
|
} else {
|
||||||
|
tracker.lastSeenTs = msg.timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [platformId, tracker] of nicknameTracker.entries()) {
|
||||||
|
updateMemberName.run(tracker.currentName, platformId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Database] Executing transaction...')
|
||||||
|
importTransaction()
|
||||||
|
console.log('[Database] Transaction completed')
|
||||||
|
|
||||||
|
const fileExists = fs.existsSync(dbPath)
|
||||||
|
console.log('[Database] File exists after transaction:', fileExists, dbPath)
|
||||||
|
|
||||||
|
return sessionId
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] Error in importData:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
console.log('[Database] Closing database...')
|
||||||
|
db.close()
|
||||||
|
console.log('[Database] Database closed')
|
||||||
|
|
||||||
|
const fileExists = fs.existsSync(dbPath)
|
||||||
|
console.log('[Database] File exists after close:', fileExists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有分析会话列表
|
||||||
|
*/
|
||||||
|
export function getAllSessions(): AnalysisSession[] {
|
||||||
|
ensureDbDir()
|
||||||
|
const sessions: AnalysisSession[] = []
|
||||||
|
|
||||||
|
const dbDir = getDbDir()
|
||||||
|
console.log('[Database] getAllSessions: DB_DIR =', dbDir)
|
||||||
|
console.log('[Database] getAllSessions: DB_DIR exists =', fs.existsSync(dbDir))
|
||||||
|
|
||||||
|
const allFiles = fs.readdirSync(dbDir)
|
||||||
|
console.log('[Database] getAllSessions: all files in dir:', allFiles)
|
||||||
|
|
||||||
|
const files = allFiles.filter((f) => f.endsWith('.db'))
|
||||||
|
console.log('[Database] getAllSessions: filtered .db files:', files)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const sessionId = file.replace('.db', '')
|
||||||
|
const dbPath = getDbPath(sessionId)
|
||||||
|
console.log('[Database] Opening database:', dbPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = new Database(dbPath)
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
|
||||||
|
const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined
|
||||||
|
console.log('[Database] Meta:', meta)
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
const messageCount = (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM message msg
|
||||||
|
JOIN member m ON msg.sender_id = m.id
|
||||||
|
WHERE m.name != '系统消息'`
|
||||||
|
)
|
||||||
|
.get() as { count: number }
|
||||||
|
).count
|
||||||
|
const memberCount = (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM member
|
||||||
|
WHERE name != '系统消息'`
|
||||||
|
)
|
||||||
|
.get() as { count: number }
|
||||||
|
).count
|
||||||
|
console.log('[Database] Counts:', { messageCount, memberCount })
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
id: sessionId,
|
||||||
|
name: meta.name,
|
||||||
|
platform: meta.platform as AnalysisSession['platform'],
|
||||||
|
type: meta.type as AnalysisSession['type'],
|
||||||
|
importedAt: meta.imported_at,
|
||||||
|
messageCount,
|
||||||
|
memberCount,
|
||||||
|
dbPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Database] Failed to read database \${file}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Database] getAllSessions: returning', sessions.length, 'sessions')
|
||||||
|
return sessions.sort((a, b) => b.importedAt - a.importedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个会话信息
|
||||||
|
*/
|
||||||
|
export function getSession(sessionId: string): AnalysisSession | null {
|
||||||
|
const db = openDatabase(sessionId)
|
||||||
|
if (!db) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined
|
||||||
|
if (!meta) return null
|
||||||
|
|
||||||
|
const messageCount = (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM message msg
|
||||||
|
JOIN member m ON msg.sender_id = m.id
|
||||||
|
WHERE m.name != '系统消息'`
|
||||||
|
)
|
||||||
|
.get() as { count: number }
|
||||||
|
).count
|
||||||
|
|
||||||
|
const memberCount = (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM member
|
||||||
|
WHERE name != '系统消息'`
|
||||||
|
)
|
||||||
|
.get() as { count: number }
|
||||||
|
).count
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
name: meta.name,
|
||||||
|
platform: meta.platform as AnalysisSession['platform'],
|
||||||
|
type: meta.type as AnalysisSession['type'],
|
||||||
|
importedAt: meta.imported_at,
|
||||||
|
messageCount,
|
||||||
|
memberCount,
|
||||||
|
dbPath: getDbPath(sessionId),
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除会话
|
||||||
|
*/
|
||||||
|
export function deleteSession(sessionId: string): boolean {
|
||||||
|
const dbPath = getDbPath(sessionId)
|
||||||
|
const walPath = dbPath + '-wal'
|
||||||
|
const shmPath = dbPath + '-shm'
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath)
|
||||||
|
if (fs.existsSync(walPath)) fs.unlinkSync(walPath)
|
||||||
|
if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据库存储目录
|
||||||
|
*/
|
||||||
|
export function getDbDirectory(): string {
|
||||||
|
ensureDbDir()
|
||||||
|
return getDbDir()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import type { MemberActivity, MemberNameHistory, RepeatAnalysis, CatchphraseAnalysis } from '@/types/chat'
|
import type { MemberActivity, MemberNameHistory, RepeatAnalysis, CatchphraseAnalysis } from '@/types/chat'
|
||||||
import { MemberRankList, BarChart } from '@/components/charts'
|
import { RankListPro, BarChart, ListPro } from '@/components/charts'
|
||||||
import type { MemberRankItem, BarChartData } from '@/components/charts'
|
import type { RankItem, BarChartData } from '@/components/charts'
|
||||||
|
|
||||||
interface TimeFilter {
|
interface TimeFilter {
|
||||||
startTs?: number
|
startTs?: number
|
||||||
@@ -22,37 +22,37 @@ const isLoadingRepeat = ref(false)
|
|||||||
// 复读排行榜显示模式:count(绝对次数)或 rate(复读率)
|
// 复读排行榜显示模式:count(绝对次数)或 rate(复读率)
|
||||||
const repeatRankMode = ref<'count' | 'rate'>('rate')
|
const repeatRankMode = ref<'count' | 'rate'>('rate')
|
||||||
|
|
||||||
// 转换复读数据为排行榜格式(绝对次数)
|
// 转换复读数据为排行榜格式
|
||||||
const originatorRankData = computed<MemberRankItem[]>(() => {
|
const originatorRankData = computed<RankItem[]>(() => {
|
||||||
if (!repeatAnalysis.value) return []
|
if (!repeatAnalysis.value) return []
|
||||||
const data =
|
const data =
|
||||||
repeatRankMode.value === 'count' ? repeatAnalysis.value.originators : repeatAnalysis.value.originatorRates
|
repeatRankMode.value === 'count' ? repeatAnalysis.value.originators : repeatAnalysis.value.originatorRates
|
||||||
return data.slice(0, 10).map((m) => ({
|
return data.map((m) => ({
|
||||||
id: m.memberId.toString(),
|
id: m.memberId.toString(),
|
||||||
name: m.name,
|
name: m.name,
|
||||||
value: repeatRankMode.value === 'count' ? (m as any).count : (m as any).count,
|
value: (m as any).count,
|
||||||
percentage: repeatRankMode.value === 'count' ? (m as any).percentage : (m as any).rate,
|
percentage: repeatRankMode.value === 'count' ? (m as any).percentage : (m as any).rate,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const initiatorRankData = computed<MemberRankItem[]>(() => {
|
const initiatorRankData = computed<RankItem[]>(() => {
|
||||||
if (!repeatAnalysis.value) return []
|
if (!repeatAnalysis.value) return []
|
||||||
const data = repeatRankMode.value === 'count' ? repeatAnalysis.value.initiators : repeatAnalysis.value.initiatorRates
|
const data = repeatRankMode.value === 'count' ? repeatAnalysis.value.initiators : repeatAnalysis.value.initiatorRates
|
||||||
return data.slice(0, 10).map((m) => ({
|
return data.map((m) => ({
|
||||||
id: m.memberId.toString(),
|
id: m.memberId.toString(),
|
||||||
name: m.name,
|
name: m.name,
|
||||||
value: repeatRankMode.value === 'count' ? (m as any).count : (m as any).count,
|
value: (m as any).count,
|
||||||
percentage: repeatRankMode.value === 'count' ? (m as any).percentage : (m as any).rate,
|
percentage: repeatRankMode.value === 'count' ? (m as any).percentage : (m as any).rate,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const breakerRankData = computed<MemberRankItem[]>(() => {
|
const breakerRankData = computed<RankItem[]>(() => {
|
||||||
if (!repeatAnalysis.value) return []
|
if (!repeatAnalysis.value) return []
|
||||||
const data = repeatRankMode.value === 'count' ? repeatAnalysis.value.breakers : repeatAnalysis.value.breakerRates
|
const data = repeatRankMode.value === 'count' ? repeatAnalysis.value.breakers : repeatAnalysis.value.breakerRates
|
||||||
return data.slice(0, 10).map((m) => ({
|
return data.map((m) => ({
|
||||||
id: m.memberId.toString(),
|
id: m.memberId.toString(),
|
||||||
name: m.name,
|
name: m.name,
|
||||||
value: repeatRankMode.value === 'count' ? (m as any).count : (m as any).count,
|
value: (m as any).count,
|
||||||
percentage: repeatRankMode.value === 'count' ? (m as any).percentage : (m as any).rate,
|
percentage: repeatRankMode.value === 'count' ? (m as any).percentage : (m as any).rate,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
@@ -114,18 +114,8 @@ async function loadCatchphraseAnalysis() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top 10 排行榜数据
|
// 成员活跃度排行数据
|
||||||
const top10RankData = computed<MemberRankItem[]>(() => {
|
const memberRankData = computed<RankItem[]>(() => {
|
||||||
return props.memberActivity.slice(0, 10).map((m) => ({
|
|
||||||
id: m.memberId.toString(),
|
|
||||||
name: m.name,
|
|
||||||
value: m.messageCount,
|
|
||||||
percentage: m.percentage,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 完整排行榜数据
|
|
||||||
const fullRankData = computed<MemberRankItem[]>(() => {
|
|
||||||
return props.memberActivity.map((m) => ({
|
return props.memberActivity.map((m) => ({
|
||||||
id: m.memberId.toString(),
|
id: m.memberId.toString(),
|
||||||
name: m.name,
|
name: m.name,
|
||||||
@@ -134,8 +124,6 @@ const fullRankData = computed<MemberRankItem[]>(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const isOpen = ref(false)
|
|
||||||
|
|
||||||
// 昵称变更记录
|
// 昵称变更记录
|
||||||
interface MemberWithHistory {
|
interface MemberWithHistory {
|
||||||
memberId: number
|
memberId: number
|
||||||
@@ -225,30 +213,7 @@ function formatPeriod(startTs: number, endTs: number | null): string {
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- 成员活跃度排行 -->
|
<!-- 成员活跃度排行 -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
<RankListPro :members="memberRankData" title="成员活跃度排行" />
|
||||||
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-800">
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">成员活跃度排行</h3>
|
|
||||||
<!-- 完整排行榜 Dialog -->
|
|
||||||
<UModal v-model:open="isOpen" :ui="{ width: 'max-w-3xl' }">
|
|
||||||
<UButton v-if="memberActivity.length > 10" icon="i-heroicons-list-bullet" color="gray" variant="ghost">
|
|
||||||
查看完整排行
|
|
||||||
</UButton>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">完整成员排行榜</h3>
|
|
||||||
<span>(共 {{ memberActivity.length }} 位成员)</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #body>
|
|
||||||
<div class="max-h-[60vh] overflow-y-auto">
|
|
||||||
<MemberRankList :members="fullRankData" :session-id="sessionId" :clickable="true" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UModal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MemberRankList :members="top10RankData" :session-id="sessionId" :clickable="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 昵称变更记录区域 -->
|
<!-- 昵称变更记录区域 -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
@@ -406,86 +371,53 @@ function formatPeriod(startTs: number, endTs: number | null): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 最容易产生复读(原创者) -->
|
<!-- 最容易产生复读(原创者) -->
|
||||||
<div class="rounded-lg border border-gray-100 bg-gray-50/50 dark:border-gray-800 dark:bg-gray-800/50">
|
<RankListPro
|
||||||
<div class="border-b border-gray-100 px-4 py-3 dark:border-gray-800">
|
v-if="originatorRankData.length > 0"
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">🎯 谁的聊天最容易产生复读</h4>
|
:members="originatorRankData"
|
||||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
title="🎯 谁的聊天最容易产生复读"
|
||||||
{{ repeatRankMode === 'rate' ? '被复读次数 / 总发言数' : '发出的消息被别人复读的次数' }}
|
:description="repeatRankMode === 'rate' ? '被复读次数 / 总发言数' : '发出的消息被别人复读的次数'"
|
||||||
</p>
|
unit="次"
|
||||||
</div>
|
/>
|
||||||
<MemberRankList
|
|
||||||
v-if="originatorRankData.length > 0"
|
|
||||||
:members="originatorRankData"
|
|
||||||
:session-id="sessionId"
|
|
||||||
:clickable="true"
|
|
||||||
/>
|
|
||||||
<div v-else class="px-4 py-6 text-center text-sm text-gray-400">暂无数据</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 最喜欢挑起复读(挑起者) -->
|
<!-- 最喜欢挑起复读(挑起者) -->
|
||||||
<div class="rounded-lg border border-gray-100 bg-gray-50/50 dark:border-gray-800 dark:bg-gray-800/50">
|
<RankListPro
|
||||||
<div class="border-b border-gray-100 px-4 py-3 dark:border-gray-800">
|
v-if="initiatorRankData.length > 0"
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">🔥 谁最喜欢挑起复读</h4>
|
:members="initiatorRankData"
|
||||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
title="🔥 谁最喜欢挑起复读"
|
||||||
{{ repeatRankMode === 'rate' ? '挑起复读次数 / 总发言数' : '第二个发送相同消息、带起节奏的人' }}
|
:description="repeatRankMode === 'rate' ? '挑起复读次数 / 总发言数' : '第二个发送相同消息、带起节奏的人'"
|
||||||
</p>
|
unit="次"
|
||||||
</div>
|
/>
|
||||||
<MemberRankList
|
|
||||||
v-if="initiatorRankData.length > 0"
|
|
||||||
:members="initiatorRankData"
|
|
||||||
:session-id="sessionId"
|
|
||||||
:clickable="true"
|
|
||||||
/>
|
|
||||||
<div v-else class="px-4 py-6 text-center text-sm text-gray-400">暂无数据</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 最喜欢打断复读(终结者) -->
|
<!-- 最喜欢打断复读(终结者) -->
|
||||||
<div class="rounded-lg border border-gray-100 bg-gray-50/50 dark:border-gray-800 dark:bg-gray-800/50">
|
<RankListPro
|
||||||
<div class="border-b border-gray-100 px-4 py-3 dark:border-gray-800">
|
v-if="breakerRankData.length > 0"
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">✂️ 谁喜欢打断复读</h4>
|
:members="breakerRankData"
|
||||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
title="✂️ 谁喜欢打断复读"
|
||||||
{{ repeatRankMode === 'rate' ? '打断复读次数 / 总发言数' : '终结复读链的人' }}
|
:description="repeatRankMode === 'rate' ? '打断复读次数 / 总发言数' : '终结复读链的人'"
|
||||||
</p>
|
unit="次"
|
||||||
</div>
|
/>
|
||||||
<MemberRankList
|
|
||||||
v-if="breakerRankData.length > 0"
|
|
||||||
:members="breakerRankData"
|
|
||||||
:session-id="sessionId"
|
|
||||||
:clickable="true"
|
|
||||||
/>
|
|
||||||
<div v-else class="px-4 py-6 text-center text-sm text-gray-400">暂无数据</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="px-5 py-8 text-center text-sm text-gray-400">该群组暂无复读记录</div>
|
<div v-else class="px-5 py-8 text-center text-sm text-gray-400">该群组暂无复读记录</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 口头禅分析模块 -->
|
<!-- 口头禅分析模块 -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
<div
|
||||||
<div class="border-b border-gray-200 px-5 py-4 dark:border-gray-800">
|
v-if="isLoadingCatchphrase"
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">💬 口头禅分析</h3>
|
class="rounded-xl border border-gray-200 bg-white px-5 py-8 text-center text-sm text-gray-400 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
>
|
||||||
{{
|
正在分析口头禅数据...
|
||||||
isLoadingCatchphrase
|
</div>
|
||||||
? '加载中...'
|
|
||||||
: catchphraseAnalysis
|
|
||||||
? `分析了 ${catchphraseAnalysis.members.length} 位成员的高频发言`
|
|
||||||
: '暂无数据'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isLoadingCatchphrase" class="px-5 py-8 text-center text-sm text-gray-400">正在分析口头禅数据...</div>
|
<ListPro
|
||||||
|
v-else-if="catchphraseAnalysis && catchphraseAnalysis.members.length > 0"
|
||||||
<div
|
:items="catchphraseAnalysis.members"
|
||||||
v-else-if="catchphraseAnalysis && catchphraseAnalysis.members.length > 0"
|
title="💬 口头禅分析"
|
||||||
class="divide-y divide-gray-100 dark:divide-gray-800"
|
:description="`分析了 ${catchphraseAnalysis.members.length} 位成员的高频发言`"
|
||||||
>
|
countTemplate="共 {count} 位成员"
|
||||||
<div
|
>
|
||||||
v-for="member in catchphraseAnalysis.members.slice(0, 20)"
|
<template #item="{ item: member }">
|
||||||
:key="member.memberId"
|
<div class="flex items-start gap-4">
|
||||||
class="flex items-start gap-4 px-5 py-4"
|
|
||||||
>
|
|
||||||
<!-- 成员名称 -->
|
<!-- 成员名称 -->
|
||||||
<div class="w-28 shrink-0 pt-1 font-medium text-gray-900 dark:text-white">
|
<div class="w-28 shrink-0 pt-1 font-medium text-gray-900 dark:text-white">
|
||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
@@ -518,9 +450,14 @@ function formatPeriod(startTs: number, endTs: number | null): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</ListPro>
|
||||||
|
|
||||||
<div v-else class="px-5 py-8 text-center text-sm text-gray-400">暂无口头禅数据</div>
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-xl border border-gray-200 bg-white px-5 py-8 text-center text-sm text-gray-400 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
暂无口头禅数据
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
78
src/components/charts/ListPro.vue
Normal file
78
src/components/charts/ListPro.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 完整数据列表 */
|
||||||
|
items: any[]
|
||||||
|
/** 标题 */
|
||||||
|
title: string
|
||||||
|
/** 描述(可选) */
|
||||||
|
description?: string
|
||||||
|
/** 默认显示数量,默认 10 */
|
||||||
|
topN?: number
|
||||||
|
/** 弹窗中的总数描述模板,如 "共 {count} 位成员" */
|
||||||
|
countTemplate?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
topN: 10,
|
||||||
|
countTemplate: '共 {count} 项',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 控制弹窗
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
// Top N 数据
|
||||||
|
const topNData = computed(() => props.items.slice(0, props.topN))
|
||||||
|
|
||||||
|
// 是否显示"查看完整"按钮
|
||||||
|
const showViewAll = computed(() => props.items.length > props.topN)
|
||||||
|
|
||||||
|
// 格式化总数描述
|
||||||
|
const formattedCount = computed(() => props.countTemplate.replace('{count}', String(props.items.length)))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
|
||||||
|
<p v-if="description" class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- 自定义头部右侧内容 -->
|
||||||
|
<slot name="headerRight" />
|
||||||
|
|
||||||
|
<!-- 完整列表弹窗 -->
|
||||||
|
<UModal v-model:open="isOpen" :ui="{ width: 'max-w-3xl' }">
|
||||||
|
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" color="gray" variant="ghost">查看完整列表</UButton>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
|
||||||
|
<span class="text-sm text-gray-500">({{ formattedCount }})</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div class="max-h-[60vh] divide-y divide-gray-100 overflow-y-auto dark:divide-gray-800">
|
||||||
|
<div v-for="(item, index) in items" :key="index" class="px-5 py-4">
|
||||||
|
<slot name="item" :item="item" :index="index" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 默认显示 Top N -->
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
<div v-for="(item, index) in topNData" :key="index" class="px-5 py-4">
|
||||||
|
<slot name="item" :item="item" :index="index" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="items.length === 0" class="px-5 py-8 text-center text-sm text-gray-400">暂无数据</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { MemberNameHistory } from '@/types/chat'
|
|
||||||
import { MemberNicknameHistory } from '@/components/charts'
|
|
||||||
|
|
||||||
export interface MemberRankItem {
|
export interface RankItem {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
value: number
|
value: number
|
||||||
@@ -11,48 +9,22 @@ export interface MemberRankItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
members: MemberRankItem[]
|
members: RankItem[]
|
||||||
showAvatar?: boolean
|
showAvatar?: boolean
|
||||||
rankLimit?: number // 限制显示数量,0 表示不限制
|
rankLimit?: number // 限制显示数量,0 表示不限制
|
||||||
sessionId?: string // 会话ID,用于查询历史昵称
|
unit?: string // 单位名称,默认"条"
|
||||||
clickable?: boolean // 是否可点击查看历史
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
showAvatar: false,
|
showAvatar: false,
|
||||||
rankLimit: 0,
|
rankLimit: 0,
|
||||||
clickable: false,
|
unit: '条',
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayMembers = computed(() => {
|
const displayMembers = computed(() => {
|
||||||
return props.rankLimit > 0 ? props.members.slice(0, props.rankLimit) : props.members
|
return props.rankLimit > 0 ? props.members.slice(0, props.rankLimit) : props.members
|
||||||
})
|
})
|
||||||
|
|
||||||
// 昵称历史弹窗
|
|
||||||
const isHistoryModalOpen = ref(false)
|
|
||||||
const selectedMember = ref<MemberRankItem | null>(null)
|
|
||||||
const selectedMemberHistory = ref<MemberNameHistory[]>([])
|
|
||||||
const isLoadingHistory = ref(false)
|
|
||||||
|
|
||||||
// 点击成员名称,查看历史
|
|
||||||
async function handleMemberClick(member: MemberRankItem) {
|
|
||||||
if (!props.clickable || !props.sessionId) return
|
|
||||||
|
|
||||||
selectedMember.value = member
|
|
||||||
isHistoryModalOpen.value = true
|
|
||||||
isLoadingHistory.value = true
|
|
||||||
selectedMemberHistory.value = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const history = await window.chatApi.getMemberNameHistory(props.sessionId, parseInt(member.id))
|
|
||||||
selectedMemberHistory.value = history
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载昵称历史失败:', error)
|
|
||||||
} finally {
|
|
||||||
isLoadingHistory.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取相对于第一名的百分比
|
// 获取相对于第一名的百分比
|
||||||
function getRelativePercentage(index: number): number {
|
function getRelativePercentage(index: number): number {
|
||||||
if (displayMembers.value.length === 0) return 0
|
if (displayMembers.value.length === 0) return 0
|
||||||
@@ -110,11 +82,7 @@ function getBarColor(index: number): string {
|
|||||||
|
|
||||||
<!-- 名字 -->
|
<!-- 名字 -->
|
||||||
<div class="w-32 shrink-0">
|
<div class="w-32 shrink-0">
|
||||||
<p
|
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||||
class="truncate font-medium text-gray-900 dark:text-white"
|
|
||||||
:class="{ 'cursor-pointer hover:text-[#de335e] transition-colors': clickable }"
|
|
||||||
@click="clickable ? handleMemberClick(member) : null"
|
|
||||||
>
|
|
||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,25 +101,8 @@ function getBarColor(index: number): string {
|
|||||||
<!-- 数值和百分比 -->
|
<!-- 数值和百分比 -->
|
||||||
<div class="flex shrink-0 items-baseline gap-2">
|
<div class="flex shrink-0 items-baseline gap-2">
|
||||||
<span class="text-lg font-bold text-gray-900 dark:text-white">{{ member.value }}</span>
|
<span class="text-lg font-bold text-gray-900 dark:text-white">{{ member.value }}</span>
|
||||||
<span class="text-sm text-gray-500">条 ({{ member.percentage }}%)</span>
|
<span class="text-sm text-gray-500">{{ unit }} ({{ member.percentage }}%)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 昵称历史弹窗 -->
|
|
||||||
<UModal v-model:open="isHistoryModalOpen" :ui="{ width: 'max-w-md' }">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-lg font-semibold text-gray-900 dark:text-white">昵称历史</span>
|
|
||||||
<span v-if="selectedMember" class="text-sm text-gray-500">{{ selectedMember.name }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #body>
|
|
||||||
<div v-if="isLoadingHistory" class="py-8 text-center text-sm text-gray-400">加载中...</div>
|
|
||||||
<div v-else-if="selectedMemberHistory.length > 0" class="px-2 py-4">
|
|
||||||
<MemberNicknameHistory :history="selectedMemberHistory" :compact="true" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="py-8 text-center text-sm text-gray-400">暂无昵称记录</div>
|
|
||||||
</template>
|
|
||||||
</UModal>
|
|
||||||
</template>
|
</template>
|
||||||
65
src/components/charts/RankListPro.vue
Normal file
65
src/components/charts/RankListPro.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import RankList from './RankList.vue'
|
||||||
|
import type { RankItem } from './RankList.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 完整的排行数据 */
|
||||||
|
members: RankItem[]
|
||||||
|
/** 标题 */
|
||||||
|
title: string
|
||||||
|
/** 描述(可选) */
|
||||||
|
description?: string
|
||||||
|
/** 默认显示数量,默认 10 */
|
||||||
|
topN?: number
|
||||||
|
/** 单位名称,默认"条" */
|
||||||
|
unit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
topN: 10,
|
||||||
|
unit: '条',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 控制弹窗
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
// Top N 数据
|
||||||
|
const topNData = computed(() => {
|
||||||
|
return props.members.slice(0, props.topN)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否显示"查看完整"按钮
|
||||||
|
const showViewAll = computed(() => {
|
||||||
|
return props.members.length > props.topN
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
|
||||||
|
<p v-if="description" class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 完整排行榜 Dialog -->
|
||||||
|
<UModal v-model:open="isOpen" :ui="{ width: 'max-w-3xl' }">
|
||||||
|
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" color="gray" variant="ghost">查看完整排行</UButton>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
|
||||||
|
<span class="text-sm text-gray-500">(共 {{ members.length }} 位成员)</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div class="max-h-[60vh] overflow-y-auto">
|
||||||
|
<RankList :members="members" :unit="unit" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RankList :members="topNData" :unit="unit" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,7 +3,9 @@ export { default as DoughnutChart } from './DoughnutChart.vue'
|
|||||||
export { default as HorizontalBarChart } from './HorizontalBarChart.vue'
|
export { default as HorizontalBarChart } from './HorizontalBarChart.vue'
|
||||||
export { default as LineChart } from './LineChart.vue'
|
export { default as LineChart } from './LineChart.vue'
|
||||||
export { default as BarChart } from './BarChart.vue'
|
export { default as BarChart } from './BarChart.vue'
|
||||||
export { default as MemberRankList } from './MemberRankList.vue'
|
export { default as RankList } from './RankList.vue'
|
||||||
|
export { default as RankListPro } from './RankListPro.vue'
|
||||||
|
export { default as ListPro } from './ListPro.vue'
|
||||||
export { default as ProgressBar } from './ProgressBar.vue'
|
export { default as ProgressBar } from './ProgressBar.vue'
|
||||||
export { default as MemberNicknameHistory } from './MemberNicknameHistory.vue'
|
export { default as MemberNicknameHistory } from './MemberNicknameHistory.vue'
|
||||||
|
|
||||||
@@ -12,4 +14,4 @@ export type { DoughnutChartData } from './DoughnutChart.vue'
|
|||||||
export type { HorizontalBarChartData } from './HorizontalBarChart.vue'
|
export type { HorizontalBarChartData } from './HorizontalBarChart.vue'
|
||||||
export type { LineChartData } from './LineChart.vue'
|
export type { LineChartData } from './LineChart.vue'
|
||||||
export type { BarChartData } from './BarChart.vue'
|
export type { BarChartData } from './BarChart.vue'
|
||||||
export type { MemberRankItem } from './MemberRankList.vue'
|
export type { RankItem } from './RankList.vue'
|
||||||
|
|||||||
@@ -163,8 +163,10 @@ export const useChatStore = defineStore(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
persist: {
|
persist: {
|
||||||
// 只持久化 currentSessionId,sessions 从数据库加载
|
// 使用 sessionStorage:页面刷新时保留,应用重启时清除
|
||||||
|
// 这样启动应用默认显示 WelcomeGuide,但刷新页面保留当前状态
|
||||||
pick: ['currentSessionId'],
|
pick: ['currentSessionId'],
|
||||||
|
storage: sessionStorage,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user