feat: 支持头像导入

This commit is contained in:
digua
2025-12-19 02:08:21 +08:00
parent ae6e89be4f
commit 145b9416d7
18 changed files with 788 additions and 75 deletions
+16 -7
View File
@@ -70,7 +70,9 @@ function createDatabase(sessionId: string): Database.Database {
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL,
imported_at INTEGER NOT NULL
imported_at INTEGER NOT NULL,
group_id TEXT,
group_avatar TEXT
);
CREATE TABLE IF NOT EXISTS member (
@@ -78,7 +80,8 @@ function createDatabase(sessionId: string): Database.Database {
platform_id TEXT NOT NULL UNIQUE,
account_name TEXT,
group_nickname TEXT,
aliases TEXT DEFAULT '[]'
aliases TEXT DEFAULT '[]',
avatar TEXT
);
CREATE TABLE IF NOT EXISTS member_name_history (
@@ -134,18 +137,20 @@ export function importData(parseResult: ParseResult): string {
try {
const importTransaction = db.transaction(() => {
const insertMeta = db.prepare(`
INSERT INTO meta (name, platform, type, imported_at)
VALUES (?, ?, ?, ?)
INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar)
VALUES (?, ?, ?, ?, ?, ?)
`)
insertMeta.run(
parseResult.meta.name,
parseResult.meta.platform,
parseResult.meta.type,
Math.floor(Date.now() / 1000)
Math.floor(Date.now() / 1000),
parseResult.meta.groupId || null,
parseResult.meta.groupAvatar || null
)
const insertMember = db.prepare(`
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname) VALUES (?, ?, ?)
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)
`)
const getMemberId = db.prepare(`
SELECT id FROM member WHERE platform_id = ?
@@ -154,7 +159,7 @@ export function importData(parseResult: ParseResult): string {
const memberIdMap = new Map<string, number>()
for (const member of parseResult.members) {
insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null)
insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null, member.avatar || null)
const row = getMemberId.get(member.platformId) as { id: number }
memberIdMap.set(member.platformId, row.id)
}
@@ -312,6 +317,8 @@ export function getAllSessions(): AnalysisSession[] {
messageCount,
memberCount,
dbPath,
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
})
}
@@ -365,6 +372,8 @@ export function getSession(sessionId: string): AnalysisSession | null {
messageCount,
memberCount,
dbPath: getDbPath(sessionId),
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
}
} finally {
db.close()
+20 -1
View File
@@ -391,11 +391,16 @@ export async function mergeFilesWithTempDb(
if (member.groupNickname) {
existing.groupNickname = member.groupNickname
}
// 头像使用最新的(覆盖更新)
if (member.avatar) {
existing.avatar = member.avatar
}
} else {
memberMap.set(member.platformId, {
platformId: member.platformId,
accountName: member.accountName,
groupNickname: member.groupNickname,
avatar: member.avatar,
})
}
}
@@ -453,6 +458,14 @@ export async function mergeFilesWithTempDb(
const platforms = new Set(parseResults.map((r) => r.meta.platform))
const platform = platforms.size === 1 ? parseResults[0].meta.platform : 'mixed'
// 确定群ID和群头像(仅当所有文件都来自同一个群时保留)
const groupIds = new Set(parseResults.map((r) => r.meta.groupId).filter(Boolean))
const groupId = groupIds.size === 1 ? parseResults.find((r) => r.meta.groupId)?.meta.groupId : undefined
// 如果有唯一群ID,使用最后一个文件的群头像(可能是最新的)
const groupAvatar = groupId
? parseResults.filter((r) => r.meta.groupId === groupId).pop()?.meta.groupAvatar
: undefined
// 构建来源信息
const sources: MergeSource[] = parseResults.map(({ reader, source, meta }) => ({
filename: source,
@@ -463,15 +476,18 @@ export async function mergeFilesWithTempDb(
// 构建 ChatLab 格式
const chatLabData: ChatLabFormat = {
chatlab: {
version: '1.0.0',
version: '0.0.1',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'ChatLab Merge Tool',
description: `合并自 ${parseResults.length} 个文件`,
},
meta: {
name: outputName,
platform: platform as ChatPlatform,
type: parseResults[0].meta.type as ChatType,
sources,
groupId,
groupAvatar,
},
members: Array.from(memberMap.values()),
messages: mergedMessages,
@@ -497,11 +513,14 @@ export async function mergeFilesWithTempDb(
name: chatLabData.meta.name,
platform: chatLabData.meta.platform,
type: chatLabData.meta.type,
groupId: chatLabData.meta.groupId,
groupAvatar: chatLabData.meta.groupAvatar,
},
members: chatLabData.members.map((m) => ({
platformId: m.platformId,
accountName: m.accountName,
groupNickname: m.groupNickname,
avatar: m.avatar,
})),
messages: chatLabData.messages.map((msg) => ({
senderPlatformId: msg.sender,
+16 -9
View File
@@ -58,13 +58,16 @@ export function createTempDatabase(dbPath: string): Database.Database {
CREATE TABLE IF NOT EXISTS meta (
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL
type TEXT NOT NULL,
group_id TEXT,
group_avatar TEXT
);
CREATE TABLE IF NOT EXISTS member (
platform_id TEXT PRIMARY KEY,
account_name TEXT,
group_nickname TEXT
group_nickname TEXT,
avatar TEXT
);
CREATE TABLE IF NOT EXISTS message (
@@ -101,10 +104,10 @@ export class TempDbWriter {
// 准备语句
this.insertMeta = this.db.prepare(`
INSERT INTO meta (name, platform, type) VALUES (?, ?, ?)
INSERT INTO meta (name, platform, type, group_id, group_avatar) VALUES (?, ?, ?, ?, ?)
`)
this.insertMember = this.db.prepare(`
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname) VALUES (?, ?, ?)
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)
`)
this.insertMessage = this.db.prepare(`
INSERT INTO message (sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content)
@@ -119,7 +122,7 @@ export class TempDbWriter {
* 写入元信息
*/
writeMeta(meta: ParsedMeta): void {
this.insertMeta.run(meta.name, meta.platform, meta.type)
this.insertMeta.run(meta.name, meta.platform, meta.type, meta.groupId || null, meta.groupAvatar || null)
}
/**
@@ -129,7 +132,7 @@ export class TempDbWriter {
for (const m of members) {
if (!this.memberSet.has(m.platformId)) {
this.memberSet.add(m.platformId)
this.insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null)
this.insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null, m.avatar || null)
}
}
}
@@ -139,10 +142,10 @@ export class TempDbWriter {
*/
writeMessages(messages: ParsedMessage[]): void {
for (const msg of messages) {
// 确保成员存在
// 确保成员存在(消息中没有头像信息,设为 null
if (!this.memberSet.has(msg.senderPlatformId)) {
this.memberSet.add(msg.senderPlatformId)
this.insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null)
this.insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null)
}
this.insertMessage.run(
@@ -202,13 +205,15 @@ export class TempDbReader {
*/
getMeta(): ParsedMeta | null {
const row = this.db.prepare('SELECT * FROM meta LIMIT 1').get() as
| { name: string; platform: string; type: string }
| { name: string; platform: string; type: string; group_id: string | null; group_avatar: string | null }
| undefined
if (!row) return null
return {
name: row.name,
platform: row.platform,
type: row.type as 'group' | 'private',
groupId: row.group_id || undefined,
groupAvatar: row.group_avatar || undefined,
}
}
@@ -220,11 +225,13 @@ export class TempDbReader {
platform_id: string
account_name: string | null
group_nickname: string | null
avatar: string | null
}>
return rows.map((r) => ({
platformId: r.platform_id,
accountName: r.account_name || r.platform_id, // 如果没有账号名称,使用 platformId
groupNickname: r.group_nickname || undefined,
avatar: r.avatar || undefined,
}))
}
+4
View File
@@ -70,6 +70,7 @@ interface ChatLabMember {
accountName: string // 账号名称
groupNickname?: string // 群昵称
aliases?: string[]
avatar?: string // 头像(base64 Data URL
}
// ==================== 解析器实现 ====================
@@ -125,6 +126,8 @@ async function* parseChatLab(options: ParseOptions): AsyncGenerator<ParseEvent,
name: metaObj.name || '未知群聊',
platform: (metaObj.platform as ChatPlatform) || ChatPlatform.UNKNOWN,
type: (metaObj.type as ChatType) || ChatType.GROUP,
groupId: metaObj.groupId,
groupAvatar: metaObj.groupAvatar,
}
}
}
@@ -150,6 +153,7 @@ async function* parseChatLab(options: ParseOptions): AsyncGenerator<ParseEvent,
platformId: m.platformId,
accountName: m.accountName,
groupNickname: m.groupNickname,
avatar: m.avatar,
})
}
}
@@ -99,6 +99,7 @@ interface MemberInfo {
platformId: string
accountName: string // 账号名称(QQ原始昵称 sendNickName
groupNickname: string | undefined // 群昵称(sendMemberName,可为空)
avatar: string | undefined // 头像(base64 Data URL
}
// ==================== 消息类型转换 ====================
@@ -271,6 +272,163 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
// 如果无法获取 senders 数量,默认为群聊(群聊是更常见的使用场景)
const chatType = sendersCount > 0 ? (sendersCount > 2 ? ChatType.GROUP : ChatType.PRIVATE) : ChatType.GROUP // 默认为群聊
// 解析 avatars 对象(头像)
// avatars 格式:{ "uin1": "data:image/jpeg;base64,...", "uin2": "..." }
// 注意:base64 字符串很长,需要特殊处理匹配花括号
const avatarsMap = new Map<string, string>()
/**
* 从字符串中提取 avatars 对象内容
* 正确处理 JSON 字符串中的花括号匹配(考虑字符串内的转义字符)
*/
function extractAvatarsObject(content: string): string | null {
const searchStr = '"avatars":'
const startIdx = content.indexOf(searchStr)
if (startIdx === -1) return null
let i = startIdx + searchStr.length
// 跳过空白字符
while (i < content.length && /\s/.test(content[i])) i++
if (content[i] !== '{') return null
// 从 { 开始匹配
let braceDepth = 0
let inString = false
let escape = false
const objStart = i
for (; i < content.length; i++) {
const char = content[i]
if (escape) {
escape = false
continue
}
if (char === '\\' && inString) {
escape = true
continue
}
if (char === '"') {
inString = !inString
continue
}
if (!inString) {
if (char === '{') braceDepth++
if (char === '}') {
braceDepth--
if (braceDepth === 0) {
return content.slice(objStart, i + 1)
}
}
}
}
return null
}
try {
// 先尝试从文件头解析(适用于成员较少的聊天)
const avatarsContent = extractAvatarsObject(headContent)
if (avatarsContent) {
const avatarsObj = JSON.parse(avatarsContent) as Record<string, string>
for (const [uin, avatar] of Object.entries(avatarsObj)) {
if (avatar && typeof avatar === 'string' && avatar.startsWith('data:image/')) {
avatarsMap.set(uin, avatar)
}
}
}
} catch {
// avatars 解析失败,继续不带头像
}
// 如果文件头没有完整的 avatars(可能超出 100KB),尝试流式读取
if (avatarsMap.size === 0) {
try {
await new Promise<void>((resolve) => {
const avatarStream = fs.createReadStream(filePath, { encoding: 'utf-8' })
let avatarsContent = ''
let inAvatars = false
let braceDepth = 0
let inString = false
let escape = false
avatarStream.on('data', (chunk: string | Buffer) => {
const str = typeof chunk === 'string' ? chunk : chunk.toString()
for (let i = 0; i < str.length; i++) {
const char = str[i]
if (!inAvatars) {
// 查找 "avatars": 的位置
const searchStr = '"avatars":'
if (str.slice(i, i + searchStr.length) === searchStr) {
inAvatars = true
// 跳过 "avatars": 和可能的空白
i += searchStr.length - 1
continue
}
} else {
// 开始收集 avatars 对象内容
avatarsContent += char
if (escape) {
escape = false
continue
}
if (char === '\\' && inString) {
escape = true
continue
}
if (char === '"') {
inString = !inString
continue
}
if (!inString) {
if (char === '{') braceDepth++
if (char === '}') {
braceDepth--
if (braceDepth === 0) {
// avatars 对象结束
avatarStream.destroy()
return
}
}
}
}
}
})
avatarStream.on('close', () => {
if (avatarsContent) {
try {
const avatarsObj = JSON.parse(avatarsContent) as Record<string, string>
for (const [uin, avatar] of Object.entries(avatarsObj)) {
if (avatar && typeof avatar === 'string' && avatar.startsWith('data:image/')) {
avatarsMap.set(uin, avatar)
}
}
} catch {
// 解析失败
}
}
resolve()
})
avatarStream.on('error', () => resolve())
})
} catch {
// 流式解析失败,继续不带头像
}
}
// 发送 meta
const meta: ParsedMeta = {
name: chatInfo.name === '未知群聊' ? extractNameFromFilePath(filePath) : chatInfo.name,
@@ -303,6 +461,9 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
const accountName = raw?.sendNickName || msg.sender.name || platformId // QQ 原始昵称
const groupNickname = raw?.sendMemberName || undefined // 群昵称(可为空)
// 获取头像(通过 uin 查找)
const avatar = avatarsMap.get(platformId)
// 更新成员信息(保留最新的名字)
const existingMember = memberMap.get(platformId)
if (!existingMember) {
@@ -310,6 +471,7 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
platformId,
accountName,
groupNickname,
avatar,
})
} else {
// 更新为最新的名字
@@ -317,6 +479,10 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
if (groupNickname) {
existingMember.groupNickname = groupNickname
}
// 头像使用最新的(覆盖更新)
if (avatar) {
existingMember.avatar = avatar
}
}
// 解析时间戳
@@ -386,6 +552,7 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
platformId: m.platformId,
accountName: m.accountName,
groupNickname: m.groupNickname,
avatar: m.avatar,
}))
yield { type: 'members', data: members }
@@ -80,9 +80,17 @@ interface EchotraceMessage {
isSend: number | null // 0=接收, 1=发送, null=系统
senderUsername: string // 发送者微信ID
senderDisplayName: string // 发送者显示名
senderAvatarKey: string // 头像查找 key(通常与 senderUsername 相同)
source: string
}
// ==================== 头像信息结构 ====================
interface EchotraceAvatarInfo {
displayName: string
base64: string // 原始 base64,不包含 Data URL 前缀
}
// ==================== 消息类型映射 ====================
/**
@@ -133,6 +141,7 @@ function convertMessageType(typeStr: string): MessageType {
interface MemberInfo {
platformId: string
accountName: string
avatar: string | undefined // 头像(base64 Data URL
}
// ==================== 解析器实现 ====================
@@ -180,11 +189,178 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator<ParseEvent
// 确定聊天名称
const chatName = session?.displayName || session?.nickname || extractNameFromFilePath(filePath)
// 提取群ID(群聊类型时有值)
// 群ID 格式:以 @chatroom 结尾
const groupId = chatType === ChatType.GROUP && session?.wxid ? session.wxid : undefined
// 解析 avatars 对象(头像)
// avatars 格式:{ "wxid": { "displayName": "...", "base64": "..." } }
// 注意:base64 不包含 Data URL 前缀,需要添加
const avatarsMap = new Map<string, string>()
/**
* 从字符串中提取 avatars 对象内容
* 正确处理 JSON 字符串中的花括号匹配(考虑字符串内的转义字符)
*/
function extractAvatarsObject(content: string): string | null {
const searchStr = '"avatars":'
const startIdx = content.indexOf(searchStr)
if (startIdx === -1) return null
let i = startIdx + searchStr.length
// 跳过空白字符
while (i < content.length && /\s/.test(content[i])) i++
if (content[i] !== '{') return null
// 从 { 开始匹配
let braceDepth = 0
let inString = false
let escape = false
const objStart = i
for (; i < content.length; i++) {
const char = content[i]
if (escape) {
escape = false
continue
}
if (char === '\\' && inString) {
escape = true
continue
}
if (char === '"') {
inString = !inString
continue
}
if (!inString) {
if (char === '{') braceDepth++
if (char === '}') {
braceDepth--
if (braceDepth === 0) {
return content.slice(objStart, i + 1)
}
}
}
}
return null
}
try {
// 先尝试从文件头解析(适用于成员较少的聊天)
const avatarsContent = extractAvatarsObject(headContent)
if (avatarsContent) {
const avatarsObj = JSON.parse(avatarsContent) as Record<string, EchotraceAvatarInfo>
for (const [wxid, avatarInfo] of Object.entries(avatarsObj)) {
if (avatarInfo && typeof avatarInfo === 'object' && avatarInfo.base64) {
// 添加 Data URL 前缀
avatarsMap.set(wxid, `data:image/jpeg;base64,${avatarInfo.base64}`)
}
}
}
} catch {
// avatars 解析失败,继续不带头像
}
// 如果文件头没有完整的 avatars(可能超出 2000 字节),尝试流式读取
if (avatarsMap.size === 0) {
try {
await new Promise<void>((resolve) => {
const avatarStream = fs.createReadStream(filePath, { encoding: 'utf-8' })
let avatarsContent = ''
let inAvatars = false
let braceDepth = 0
let inString = false
let escape = false
avatarStream.on('data', (chunk: string | Buffer) => {
const str = typeof chunk === 'string' ? chunk : chunk.toString()
for (let i = 0; i < str.length; i++) {
const char = str[i]
if (!inAvatars) {
// 查找 "avatars": 的位置
const searchStr = '"avatars":'
if (str.slice(i, i + searchStr.length) === searchStr) {
inAvatars = true
// 跳过 "avatars": 和可能的空白
i += searchStr.length - 1
continue
}
} else {
// 开始收集 avatars 对象内容
avatarsContent += char
if (escape) {
escape = false
continue
}
if (char === '\\' && inString) {
escape = true
continue
}
if (char === '"') {
inString = !inString
continue
}
if (!inString) {
if (char === '{') braceDepth++
if (char === '}') {
braceDepth--
if (braceDepth === 0) {
// avatars 对象结束
avatarStream.destroy()
return
}
}
}
}
}
})
avatarStream.on('close', () => {
if (avatarsContent) {
try {
const avatarsObj = JSON.parse(avatarsContent) as Record<string, EchotraceAvatarInfo>
for (const [wxid, avatarInfo] of Object.entries(avatarsObj)) {
if (avatarInfo && typeof avatarInfo === 'object' && avatarInfo.base64) {
avatarsMap.set(wxid, `data:image/jpeg;base64,${avatarInfo.base64}`)
}
}
} catch {
// 解析失败
}
}
resolve()
})
avatarStream.on('error', () => resolve())
})
} catch {
// 流式解析失败,继续不带头像
}
}
// 提取群头像(从 avatars 中获取群ID对应的头像)
const groupAvatar = groupId ? avatarsMap.get(groupId) : undefined
// 发送 meta
const meta: ParsedMeta = {
name: chatName,
platform: ChatPlatform.WECHAT,
type: chatType,
groupId,
groupAvatar,
}
yield { type: 'meta', data: meta }
@@ -209,18 +385,34 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator<ParseEvent
}
const platformId = msg.senderUsername
// 跳过群"成员"(群ID以 @chatroom 结尾的消息)
// 这些通常是系统消息,发送者是群本身,不是真正的成员
if (platformId.endsWith('@chatroom')) {
return null
}
const accountName = msg.senderDisplayName || platformId
// 获取头像(优先使用 senderAvatarKeyfallback 到 senderUsername
const avatarKey = msg.senderAvatarKey || msg.senderUsername
const avatar = avatarsMap.get(avatarKey)
// 更新成员信息
if (!memberMap.has(platformId)) {
memberMap.set(platformId, {
platformId,
accountName,
avatar,
})
} else {
// 更新为最新的显示名
const existing = memberMap.get(platformId)!
existing.accountName = accountName
// 头像使用最新的(覆盖更新)
if (avatar) {
existing.avatar = avatar
}
}
// 转换消息类型
@@ -278,6 +470,7 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator<ParseEvent
const members: ParsedMember[] = Array.from(memberMap.values()).map((m) => ({
platformId: m.platformId,
accountName: m.accountName,
avatar: m.avatar,
}))
yield { type: 'members', data: members }
+2
View File
@@ -14,6 +14,8 @@ export interface ParsedMeta {
name: string
platform: ChatPlatform
type: ChatType
groupId?: string // 群ID(群聊类型有值)
groupAvatar?: string // 群头像(base64 Data URL
}
/**
+35 -14
View File
@@ -65,13 +65,16 @@ function createTempDatabase(dbPath: string): Database.Database {
CREATE TABLE IF NOT EXISTS meta (
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL
type TEXT NOT NULL,
group_id TEXT,
group_avatar TEXT
);
CREATE TABLE IF NOT EXISTS member (
platform_id TEXT PRIMARY KEY,
account_name TEXT,
group_nickname TEXT
group_nickname TEXT,
avatar TEXT
);
CREATE TABLE IF NOT EXISTS message (
@@ -141,7 +144,9 @@ function createDatabaseWithoutIndexes(sessionId: string): Database.Database {
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL,
imported_at INTEGER NOT NULL
imported_at INTEGER NOT NULL,
group_id TEXT,
group_avatar TEXT
);
CREATE TABLE IF NOT EXISTS member (
@@ -149,7 +154,8 @@ function createDatabaseWithoutIndexes(sessionId: string): Database.Database {
platform_id TEXT NOT NULL UNIQUE,
account_name TEXT,
group_nickname TEXT,
aliases TEXT DEFAULT '[]'
aliases TEXT DEFAULT '[]',
avatar TEXT
);
CREATE TABLE IF NOT EXISTS member_name_history (
@@ -241,10 +247,10 @@ export async function streamImport(filePath: string, requestId: string): Promise
// 准备语句
const insertMeta = db.prepare(`
INSERT INTO meta (name, platform, type, imported_at) VALUES (?, ?, ?, ?)
INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar) VALUES (?, ?, ?, ?, ?, ?)
`)
const insertMember = db.prepare(`
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname) VALUES (?, ?, ?)
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)
`)
const getMemberId = db.prepare(`SELECT id FROM member WHERE platform_id = ?`)
const insertMessage = db.prepare(`
@@ -347,14 +353,21 @@ export async function streamImport(filePath: string, requestId: string): Promise
onMeta: (meta: ParsedMeta) => {
if (!metaInserted) {
insertMeta.run(meta.name, meta.platform, meta.type, Math.floor(Date.now() / 1000))
insertMeta.run(
meta.name,
meta.platform,
meta.type,
Math.floor(Date.now() / 1000),
meta.groupId || null,
meta.groupAvatar || null
)
metaInserted = true
}
},
onMembers: (members: ParsedMember[]) => {
for (const member of members) {
insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null)
insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null, member.avatar || null)
const row = getMemberId.get(member.platformId) as { id: number } | undefined
if (row) {
memberIdMap.set(member.platformId, row.id)
@@ -387,7 +400,8 @@ export async function streamImport(filePath: string, requestId: string): Promise
// 确保成员存在
let t0 = Date.now()
if (!memberIdMap.has(msg.senderPlatformId)) {
insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null)
// 消息中没有头像信息,设为 null
insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null)
const row = getMemberId.get(msg.senderPlatformId) as { id: number } | undefined
if (row) {
memberIdMap.set(msg.senderPlatformId, row.id)
@@ -683,9 +697,9 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
const db = createTempDatabase(tempDbPath)
// 准备语句
const insertMeta = db.prepare('INSERT INTO meta (name, platform, type) VALUES (?, ?, ?)')
const insertMeta = db.prepare('INSERT INTO meta (name, platform, type, group_id, group_avatar) VALUES (?, ?, ?, ?, ?)')
const insertMember = db.prepare(
'INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname) VALUES (?, ?, ?)'
'INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)'
)
const insertMessage = db.prepare(`
INSERT INTO message (sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content)
@@ -712,7 +726,13 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
onMeta: (parsedMeta) => {
meta = parsedMeta
if (!metaInserted) {
insertMeta.run(parsedMeta.name, parsedMeta.platform, parsedMeta.type)
insertMeta.run(
parsedMeta.name,
parsedMeta.platform,
parsedMeta.type,
parsedMeta.groupId || null,
parsedMeta.groupAvatar || null
)
metaInserted = true
}
},
@@ -721,7 +741,7 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
for (const m of parsedMembers) {
if (!memberSet.has(m.platformId)) {
memberSet.add(m.platformId)
insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null)
insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null, m.avatar || null)
}
}
},
@@ -731,7 +751,8 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
// 确保成员存在
if (!memberSet.has(msg.senderPlatformId)) {
memberSet.add(msg.senderPlatformId)
insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null)
// 消息中没有头像信息,设为 null
insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null)
}
insertMessage.run(
+54 -2
View File
@@ -69,6 +69,7 @@ export function getMemberActivity(sessionId: string, filter?: TimeFilter): any[]
m.id as memberId,
m.platform_id as platformId,
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name,
m.avatar as avatar,
COUNT(msg.id) as messageCount
FROM member m
LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem}
@@ -82,6 +83,7 @@ export function getMemberActivity(sessionId: string, filter?: TimeFilter): any[]
memberId: number
platformId: string
name: string
avatar: string | null
messageCount: number
}>
@@ -89,6 +91,7 @@ export function getMemberActivity(sessionId: string, filter?: TimeFilter): any[]
memberId: row.memberId,
platformId: row.platformId,
name: row.name,
avatar: row.avatar,
messageCount: row.messageCount,
percentage: totalMessages > 0 ? Math.round((row.messageCount / totalMessages) * 10000) / 100 : 0,
}))
@@ -313,6 +316,8 @@ interface DbMeta {
platform: string
type: string
imported_at: number
group_id: string | null
group_avatar: string | null
}
/**
@@ -367,6 +372,8 @@ export function getAllSessions(): any[] {
messageCount,
memberCount,
dbPath,
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
})
}
@@ -419,6 +426,8 @@ export function getSession(sessionId: string): any | null {
messageCount,
memberCount,
dbPath: getDbPath(sessionId),
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
}
}
@@ -434,10 +443,13 @@ interface MemberWithStats {
groupNickname: string | null
aliases: string[]
messageCount: number
avatar: string | null
}
// 用于标记已检查过 aliases 字段的会话
const aliasesCheckedSessions = new Set<string>()
// 用于标记已检查过 avatar 字段的会话
const avatarCheckedSessions = new Set<string>()
/**
* 确保 member 表有 aliases 字段(数据库迁移)
@@ -476,11 +488,48 @@ function ensureAliasesColumn(sessionId: string): void {
}
/**
* 获取所有成员列表(含消息数和别名
* 确保 member 表有 avatar 字段(数据库迁移
* 用于兼容旧数据库
*/
export function ensureAvatarColumn(sessionId: string): void {
// 每个会话只检查一次
if (avatarCheckedSessions.has(sessionId)) return
const dbPath = getDbPath(sessionId)
if (!fs.existsSync(dbPath)) return
// 先关闭可能缓存的只读连接
closeDatabase(sessionId)
// 使用写入模式打开数据库检查并添加字段
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
try {
// 检查 avatar 字段是否存在
const columns = db.prepare('PRAGMA table_info(member)').all() as Array<{ name: string }>
const hasAvatar = columns.some((col) => col.name === 'avatar')
if (!hasAvatar) {
// 添加 avatar 字段
db.exec('ALTER TABLE member ADD COLUMN avatar TEXT')
console.log(`[Worker] Added avatar column to member table in session ${sessionId}`)
}
// 标记为已检查
avatarCheckedSessions.add(sessionId)
} finally {
db.close()
}
}
/**
* 获取所有成员列表(含消息数、别名和头像)
*/
export function getMembers(sessionId: string): MemberWithStats[] {
// 先确保数据库有 aliases 字段(兼容旧数据库)
// 先确保数据库有 aliases 和 avatar 字段(兼容旧数据库)
ensureAliasesColumn(sessionId)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return []
@@ -494,6 +543,7 @@ export function getMembers(sessionId: string): MemberWithStats[] {
m.account_name as accountName,
m.group_nickname as groupNickname,
m.aliases,
m.avatar,
COUNT(msg.id) as messageCount
FROM member m
LEFT JOIN message msg ON m.id = msg.sender_id
@@ -508,6 +558,7 @@ export function getMembers(sessionId: string): MemberWithStats[] {
accountName: string | null
groupNickname: string | null
aliases: string | null
avatar: string | null
messageCount: number
}>
@@ -518,6 +569,7 @@ export function getMembers(sessionId: string): MemberWithStats[] {
groupNickname: row.groupNickname,
aliases: row.aliases ? JSON.parse(row.aliases) : [],
messageCount: row.messageCount,
avatar: row.avatar,
}))
}
+29 -1
View File
@@ -5,6 +5,7 @@
*/
import { openDatabase, buildTimeFilter, type TimeFilter } from '../core'
import { ensureAvatarColumn } from './basic'
// ==================== 类型定义 ====================
@@ -16,6 +17,7 @@ export interface MessageResult {
senderName: string
senderPlatformId: string
senderAliases: string[]
senderAvatar: string | null
content: string
timestamp: number
type: number
@@ -40,13 +42,14 @@ export interface MessagesWithTotal {
// ==================== 工具函数 ====================
/**
* 数据库行类型(包含 aliases JSON 字符串)
* 数据库行类型(包含 aliases JSON 字符串和头像
*/
interface DbMessageRow {
id: number
senderName: string
senderPlatformId: string
aliases: string | null
avatar: string | null
content: string
timestamp: number
type: number
@@ -72,6 +75,7 @@ function sanitizeMessageRow(row: DbMessageRow): MessageResult {
senderName: String(row.senderName || ''),
senderPlatformId: String(row.senderPlatformId || ''),
senderAliases: aliases,
senderAvatar: row.avatar || null,
content: row.content != null ? String(row.content) : '',
timestamp: Number(row.timestamp),
type: Number(row.type),
@@ -119,6 +123,9 @@ export function getRecentMessages(
filter?: TimeFilter,
limit: number = 100
): MessagesWithTotal {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], total: 0 }
@@ -146,6 +153,7 @@ export function getRecentMessages(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -185,6 +193,9 @@ export function searchMessages(
offset: number = 0,
senderId?: number
): MessagesWithTotal {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], total: 0 }
@@ -223,6 +234,7 @@ export function searchMessages(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -257,6 +269,9 @@ export function getMessageContext(
messageIds: number | number[],
contextSize: number = 20
): MessageResult[] {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return []
@@ -305,6 +320,7 @@ export function getMessageContext(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -336,6 +352,9 @@ export function getMessagesBefore(
senderId?: number,
keywords?: string[]
): PaginatedMessages {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], hasMore: false }
@@ -355,6 +374,7 @@ export function getMessagesBefore(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -398,6 +418,9 @@ export function getMessagesAfter(
senderId?: number,
keywords?: string[]
): PaginatedMessages {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], hasMore: false }
@@ -417,6 +440,7 @@ export function getMessagesAfter(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -458,6 +482,9 @@ export function getConversationBetween(
filter?: TimeFilter,
limit: number = 100
): MessagesWithTotal & { member1Name: string; member2Name: string } {
// 确保数据库有 avatar 字段(兼容旧数据库)
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return { messages: [], total: 0, member1Name: '', member2Name: '' }
@@ -499,6 +526,7 @@ export function getConversationBetween(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type
@@ -201,10 +201,16 @@ function highlightContent(content: string): string {
<div class="flex gap-3">
<!-- 头像 -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
:class="avatarColor"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white overflow-hidden"
:class="message.senderAvatar ? '' : avatarColor"
>
{{ avatarLetter }}
<img
v-if="message.senderAvatar"
:src="message.senderAvatar"
:alt="message.senderName"
class="h-full w-full object-cover"
/>
<span v-else>{{ avatarLetter }}</span>
</div>
<!-- 消息内容区 -->
+12 -4
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
/**
* 页面 Header 通用组件
* 包含标题、描述、可选图标,以及默认 slot 用于额外内容
* 包含标题、描述、可选头像/图标,以及默认 slot 用于额外内容
*/
defineProps<{
title: string
description?: string
icon?: string
icon?: string // fallback 图标
avatar?: string | null // 头像图片(base64 Data URL),优先级高于 icon
}>()
</script>
@@ -16,9 +17,16 @@ defineProps<{
<!-- 标题区域 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<!-- 可选图标 -->
<!-- 头像图片优先显示 -->
<img
v-if="avatar"
:src="avatar"
:alt="title"
class="h-10 w-10 rounded-xl object-cover"
/>
<!-- 可选图标fallback -->
<div
v-if="icon"
v-else-if="icon"
class="flex h-10 w-10 items-center justify-center rounded-xl bg-linear-to-br from-pink-400 to-pink-600"
>
<UIcon :name="icon" class="h-5 w-5 text-white" />
@@ -250,7 +250,15 @@ onMounted(() => {
<!-- 账号名称 (ID) -->
<td class="px-4 py-4">
<div class="flex items-center gap-2">
<!-- 头像优先显示真实头像否则显示首字母 -->
<img
v-if="member.avatar"
:src="member.avatar"
:alt="getDisplayName(member)"
class="h-8 w-8 shrink-0 rounded-full object-cover"
/>
<div
v-else
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-400 to-pink-600 text-xs font-medium text-white"
>
{{ getFirstChar(member) }}
+1
View File
@@ -249,6 +249,7 @@ onMounted(() => {
<PageHeader
:title="session.name"
:description="`${dateRangeText}${selectedYear ? filteredMemberCount : session.memberCount} 位成员共聊了 ${selectedYear ? filteredMessageCount : session.messageCount} 条消息`"
:avatar="session.groupAvatar"
icon="i-heroicons-chat-bubble-left-right"
>
<template #actions>
+179 -30
View File
@@ -14,29 +14,34 @@ const emit = defineEmits<{
// 通用格式弹窗状态
const showFormatModal = ref(false)
// 复制格式示例
// 复制格式示例(完整版)
const formatExample = `{
"chatlab": {
"version": "1.0.0",
"version": "0.0.1",
"exportedAt": 1732924800,
"generator": "Your Tool Name"
"generator": "Your Tool Name",
"description": "自定义描述信息"
},
"meta": {
"name": "群聊名称",
"platform": "qq",
"type": "group"
"type": "group",
"groupId": "123456789",
"groupAvatar": "data:image/jpeg;base64,/9j/4AAQ..."
},
"members": [
{
"platformId": "123456789",
"accountName": "用户昵称",
"groupNickname": "群昵称(可选)"
"groupNickname": "群昵称(可选)",
"avatar": "data:image/jpeg;base64,/9j/4AAQ..."
}
],
"messages": [
{
"sender": "123456789",
"accountName": "发送时昵称",
"groupNickname": "发送时群昵称(可选)",
"timestamp": 1732924800,
"type": 0,
"content": "消息内容"
@@ -193,79 +198,219 @@ function openExternalLink(url: string) {
</div>
<!-- 格式说明 -->
<div class="space-y-4">
<div class="max-h-[60vh] space-y-4 overflow-y-auto">
<p class="text-sm text-gray-600 dark:text-gray-300">
ChatLab 定义了一套聊天记录分析用标准 JSON 格式只需在 JSON 文件中包含
<code class="rounded bg-gray-100 px-1.5 py-0.5 text-pink-600 dark:bg-gray-800 dark:text-pink-400">
chatlab
</code>
对象即可被识别
对象即可被识别以下是完整的格式规范供开发者参考
</p>
<!-- JSON 示例 -->
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">示例格式</span>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">完整示例含可选字段</span>
<UButton variant="ghost" size="xs" icon="i-heroicons-clipboard-document" @click="copyFormatExample">
复制
</UButton>
</div>
<pre class="overflow-x-auto text-xs leading-relaxed text-gray-700 dark:text-gray-300"><code>{
"chatlab": {
"version": "1.0.0",
"exportedAt": 1732924800,
"generator": "Your Tool Name"
"version": "0.0.1", // 必填:格式版本号
"exportedAt": 1732924800, // 必填:导出时间(秒级时间戳)
"generator": "Your Tool Name", // 可选:生成工具名称
"description": "自定义描述" // 可选:描述信息(自定义内容)
},
"meta": {
"name": "群聊名称",
"platform": "qq", // qq | wechat | telegram | discord
"type": "group" // group | private (群聊|私聊)
"name": "群聊名称", // 必填:群名/对话名
"platform": "qq", // 必填:qq | wechat | discord | mixed | unknown
"type": "group", // 必填:group(群聊)| private私聊)
"groupId": "123456789", // 可选:群ID(仅群聊)
"groupAvatar": "data:image/jpeg;base64,..." // 可选:群头像(Data URL
},
"members": [
{
"platformId": "123456789",
"accountName": "用户昵称",
"groupNickname": "群昵称(可选)"
"platformId": "123456789", // 必填:用户唯一标识(QQ号/微信ID等)
"accountName": "用户昵称", // 必填:账号名称
"groupNickname": "群昵称", // 可选:群昵称(仅群聊)
"avatar": "data:image/jpeg;base64,..." // 可选:用户头像(Data URL
}
],
"messages": [
{
"sender": "123456789",
"accountName": "发送时昵称",
"timestamp": 1732924800, // 秒级时间戳
"type": 0, // 0=文本 1=图片 2=语音 3=视频
"content": "消息内容"
"sender": "123456789", // 必填:发送者 platformId
"accountName": "发送时昵称", // 必填:发送时的账号名称
"groupNickname": "发送时群昵称", // 可选:发送时的群昵称
"timestamp": 1732924800, // 必填:秒级 Unix 时间戳
"type": 0, // 必填:消息类型(见下方说明,0=文本)
"content": "消息内容" // 必填:消息内容(null 表示非文本)
}
]
}</code></pre>
</div>
<!-- 字段说明 -->
<!-- 消息类型说明 -->
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">消息类型说明</h3>
<div class="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">消息类型 (type)</h3>
<!-- 基础类型 (0-19) -->
<p class="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">基础消息类型 (0-19)</p>
<div class="mb-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">0</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">文本</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">TEXT 文本</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">1</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">图片</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">IMAGE 图片</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">2</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">语音</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">VOICE 语音</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">3</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">视频</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">VIDEO 视频</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">4</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">FILE 文件</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">5</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">EMOJI 表情</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">7</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">LINK 链接</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">8</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">LOCATION 位置</span>
</div>
</div>
<!-- 交互类型 (20-39) -->
<p class="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">交互消息类型 (20-39)</p>
<div class="mb-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">20</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">RED_PACKET 红包</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">21</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">TRANSFER 转账</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">22</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">POKE 拍一拍</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">23</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">CALL 通话</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">24</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">SHARE 分享</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">25</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">REPLY 回复</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">26</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">FORWARD 转发</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">27</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">CONTACT 名片</span>
</div>
</div>
<!-- 系统/其他 (80+) -->
<p class="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">系统消息类型 (80+)</p>
<div class="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">80</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">SYSTEM 系统</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">81</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">RECALL 撤回</span>
</div>
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<span class="font-mono text-pink-600 dark:text-pink-400">99</span>
<span class="ml-2 text-gray-600 dark:text-gray-300">OTHER 其他</span>
</div>
</div>
</div>
<!-- 头像格式说明 -->
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">头像格式说明</h3>
<p class="mb-2 text-xs text-gray-600 dark:text-gray-300">
头像字段
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700">avatar</code>
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700">groupAvatar</code>
使用 Data URL 格式
</p>
<pre
class="mb-3 overflow-x-auto rounded-lg bg-gray-50 p-2 text-xs text-gray-700 dark:bg-gray-700 dark:text-gray-300"
><code>data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...</code></pre>
<p class="text-xs text-gray-500 dark:text-gray-400">
支持的 MIME 类型
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700">image/jpeg</code>
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700">image/png</code>
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700">image/gif</code>
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700">image/webp</code>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">建议导出时压缩处理100*100像素即可满足需求</p>
</div>
<!-- 字段必要性 -->
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">字段必要性速查</h3>
<div class="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
<div>
<h4 class="mb-2 font-medium text-gray-700 dark:text-gray-300"> 必填字段</h4>
<ul class="space-y-1 text-gray-600 dark:text-gray-400">
<li><code class="rounded bg-green-50 px-1 dark:bg-green-900/30">chatlab.version</code></li>
<li><code class="rounded bg-green-50 px-1 dark:bg-green-900/30">chatlab.exportedAt</code></li>
<li>
<code class="rounded bg-green-50 px-1 dark:bg-green-900/30">meta.name / platform / type</code>
</li>
<li>
<code class="rounded bg-green-50 px-1 dark:bg-green-900/30">
members[].platformId / accountName
</code>
</li>
<li><code class="rounded bg-green-50 px-1 dark:bg-green-900/30">messages[] 所有基础字段</code></li>
</ul>
</div>
<div>
<h4 class="mb-2 font-medium text-gray-700 dark:text-gray-300">📎 可选字段</h4>
<ul class="space-y-1 text-gray-600 dark:text-gray-400">
<li>
<code class="rounded bg-blue-50 px-1 dark:bg-blue-900/30">chatlab.generator / description</code>
</li>
<li><code class="rounded bg-blue-50 px-1 dark:bg-blue-900/30">meta.groupId / groupAvatar</code></li>
<li>
<code class="rounded bg-blue-50 px-1 dark:bg-blue-900/30">members[].groupNickname / avatar</code>
</li>
<li><code class="rounded bg-blue-50 px-1 dark:bg-blue-900/30">messages[].groupNickname</code></li>
</ul>
</div>
</div>
</div>
</div>
<!-- 底部提示 -->
<div class="mt-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="mt-6 space-y-2 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-600 dark:text-blue-400">
💡 文件名只需以
<code class="rounded bg-blue-100 px-1 dark:bg-blue-800">.json</code>
@@ -273,6 +418,10 @@ function openExternalLink(url: string) {
<code class="rounded bg-blue-100 px-1 dark:bg-blue-800">chatlab</code>
对象即可被识别
</p>
<p class="text-xs text-blue-500 dark:text-blue-400/80">
📖 完整格式规范请参考项目文档
<code class="rounded bg-blue-100 px-1 dark:bg-blue-800">.docs/guide/chatLabFormat.md</code>
</p>
</div>
</div>
</template>
@@ -118,8 +118,15 @@ onMounted(() => {
>
<!-- 成员头部信息 -->
<div class="flex items-start gap-4">
<!-- 头像 -->
<!-- 头像优先显示真实头像否则显示首字母 -->
<img
v-if="member.avatar"
:src="member.avatar"
:alt="getDisplayName(member)"
class="h-14 w-14 shrink-0 rounded-full object-cover"
/>
<div
v-else
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-400 to-pink-600 text-lg font-medium text-white"
>
{{ getFirstChar(member) }}
@@ -75,11 +75,13 @@ const memberComparisonData = computed(() => {
return {
member1: {
name: sorted[0].name,
avatar: sorted[0].avatar,
count: sorted[0].messageCount,
percentage: total > 0 ? Math.round((sorted[0].messageCount / total) * 100) : 0,
},
member2: {
name: sorted[1].name,
avatar: sorted[1].avatar,
count: sorted[1].messageCount,
percentage: total > 0 ? Math.round((sorted[1].messageCount / total) * 100) : 0,
},
@@ -154,7 +156,15 @@ watch(
<div class="flex items-center gap-8">
<!-- 左侧成员 -->
<div class="flex-1 text-center">
<!-- 头像优先显示真实头像 -->
<img
v-if="memberComparisonData.member1.avatar"
:src="memberComparisonData.member1.avatar"
:alt="memberComparisonData.member1.name"
class="mx-auto h-16 w-16 rounded-full object-cover"
/>
<div
v-else
class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-pink-100 dark:bg-pink-900/30"
>
<span class="text-2xl font-bold text-pink-600 dark:text-pink-400">
@@ -192,7 +202,15 @@ watch(
<!-- 右侧成员 -->
<div class="flex-1 text-center">
<!-- 头像优先显示真实头像 -->
<img
v-if="memberComparisonData.member2.avatar"
:src="memberComparisonData.member2.avatar"
:alt="memberComparisonData.member2.name"
class="mx-auto h-16 w-16 rounded-full object-cover"
/>
<div
v-else
class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30"
>
<span class="text-2xl font-bold text-blue-600 dark:text-blue-400">
+17 -3
View File
@@ -86,7 +86,6 @@ export function getMessageTypeName(type: MessageType | number): string {
export enum ChatPlatform {
QQ = 'qq',
WECHAT = 'wechat',
TELEGRAM = 'telegram',
DISCORD = 'discord',
MIXED = 'mixed', // 合并的多平台聊天记录
UNKNOWN = 'unknown',
@@ -110,6 +109,8 @@ export interface DbMeta {
platform: ChatPlatform // 平台
type: ChatType // 聊天类型
imported_at: number // 导入时间戳(秒)
group_id: string | null // 群ID(群聊类型有值,私聊为空)
group_avatar: string | null // 群头像(base64 Data URL
}
/**
@@ -121,6 +122,7 @@ export interface DbMember {
account_name: string | null // 账号名称(QQ原始昵称 sendNickName
group_nickname: string | null // 群昵称(sendMemberName,可为空)
aliases: string // 用户自定义别名(JSON数组格式)
avatar: string | null // 头像(base64 Data URL
}
/**
@@ -145,6 +147,7 @@ export interface ParsedMember {
platformId: string // 平台标识
accountName: string // 账号名称(QQ原始昵称 sendNickName
groupNickname?: string // 群昵称(sendMemberName,可为空)
avatar?: string // 头像(base64 Data URL,可为空)
}
/**
@@ -167,6 +170,8 @@ export interface ParseResult {
name: string
platform: ChatPlatform
type: ChatType
groupId?: string // 群ID(群聊类型有值)
groupAvatar?: string // 群头像(base64 Data URL
}
members: ParsedMember[]
messages: ParsedMessage[]
@@ -183,6 +188,7 @@ export interface MemberActivity {
name: string
messageCount: number
percentage: number // 占总消息的百分比
avatar?: string | null // 成员头像(base64 Data URL
}
/**
@@ -195,6 +201,7 @@ export interface MemberWithStats {
groupNickname: string | null // 群昵称
aliases: string[] // 用户自定义别名
messageCount: number
avatar: string | null // 头像(base64 Data URL
}
/**
@@ -395,6 +402,8 @@ export interface AnalysisSession {
messageCount: number // 消息总数
memberCount: number // 成员数
dbPath: string // 数据库文件完整路径
groupId: string | null // 群ID(群聊类型有值,私聊为空)
groupAvatar: string | null // 群头像(base64 Data URL
}
/**
@@ -783,9 +792,10 @@ export interface CheckInAnalysis {
* ChatLab 格式版本信息
*/
export interface ChatLabHeader {
version: string // 格式版本,如 "1.0.0"
version: string // 格式版本,如 "0.0.1"
exportedAt: number // 导出时间戳(秒)
generator: string // 生成工具名称
generator?: string // 生成工具名称(可选)
description?: string // 描述信息(可选,自定义内容)
}
/**
@@ -805,6 +815,8 @@ export interface ChatLabMeta {
platform: ChatPlatform // 平台(合并时为 mixed
type: ChatType // 聊天类型
sources?: MergeSource[] // 合并来源(可选)
groupId?: string // 群ID(可选,仅群聊)
groupAvatar?: string // 群头像(base64 Data URL,可选)
}
/**
@@ -815,6 +827,7 @@ export interface ChatLabMember {
accountName: string // 账号名称
groupNickname?: string // 群昵称(可选)
aliases?: string[] // 用户自定义别名(可选)
avatar?: string // 头像(base64 Data URL,可选)
}
/**
@@ -938,6 +951,7 @@ export interface ChatRecordMessage {
senderName: string
senderPlatformId: string
senderAliases: string[]
senderAvatar: string | null // 发送者头像
content: string
timestamp: number
type: number