#1043 #1032;修复增量导出意外添加文件的问题;聊天页面支持右键显示资料卡;优化曾经的好友相关算法并新增黑名单;

This commit is contained in:
cc committed 2026-07-03 16:41:37 +08:00
1 parent 801ed16d5e
commit b87444dd64
30 files changed
+1615 -145

No files matched your search

+3 -3
View File
@@ -823,7 +823,7 @@ interface OpenSessionChatWindowOptions {
source?: 'chat' | 'export' source?: 'chat' | 'export'
initialDisplayName?: string initialDisplayName?: string
initialAvatarUrl?: string initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
} }
const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => { const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
@@ -2781,8 +2781,8 @@ function registerIpcHandlers() {
}) })
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => { ipcMain.handle('chat:getContactAvatar', async (_, username: string, chatroomId?: string) => {
return await chatService.getContactAvatar(username) return await chatService.getContactAvatar(username, chatroomId)
}) })
ipcMain.handle('chat:resolveTransferDisplayNames', async (_, chatroomId: string, payerUsername: string, receiverUsername: string) => { ipcMain.handle('chat:resolveTransferDisplayNames', async (_, chatroomId: string, payerUsername: string, receiverUsername: string) => {
+3 -2
View File
@@ -146,7 +146,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
source?: 'chat' | 'export' source?: 'chat' | 'export'
initialDisplayName?: string initialDisplayName?: string
initialAvatarUrl?: string initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
} }
) => ) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options) ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
@@ -224,7 +224,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
}) => }) =>
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit, cursor), ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit, cursor),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username), getContactAvatar: (username: string, chatroomId?: string) => ipcRenderer.invoke('chat:getContactAvatar', username, chatroomId),
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) =>
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent), ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
@@ -582,6 +582,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
biz: { biz: {
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account), listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
listAccountHealth: (account?: string) => ipcRenderer.invoke('biz:listAccountHealth', account),
listMessages: (username: string, account?: string, limit?: number, offset?: number) => listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset), ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
listPayRecords: (account?: string, limit?: number, offset?: number) => listPayRecords: (account?: string, limit?: number, offset?: number) =>
+192 -34
View File
@@ -1,10 +1,7 @@
import { join } from 'path'
import { readdirSync, existsSync } from 'fs'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { ConfigService } from './config' import { ConfigService } from './config'
import { chatService, Message } from './chatService' import { chatService } from './chatService'
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { createHash } from 'crypto'
export interface BizAccount { export interface BizAccount {
username: string username: string
@@ -14,6 +11,24 @@ export interface BizAccount {
last_time: number last_time: number
formatted_last_time: string formatted_last_time: string
unread_count?: number unread_count?: number
status?: 'active' | 'inactive'
health_reason?: string
stale_level?: 'none' | 'one_year' | 'two_year' | 'unknown'
days_since_last_article?: number
}
export interface BizAccountHealth {
summary: {
active_total: number
subscription_total: number
service_total: number
invalid_total: number
stale_one_year_total: number
stale_two_year_total: number
unknown_last_article_total: number
}
accounts: BizAccount[]
invalid_accounts: BizAccount[]
} }
export interface BizMessage { export interface BizMessage {
@@ -39,11 +54,48 @@ export interface BizPayRecord {
export class BizService { export class BizService {
private configService: ConfigService private configService: ConfigService
private readonly builtinOfficialHelpers = new Set([
'gh_f0a92aa7146c' // 微信收款助手不出现在通讯录的公众号/服务号分组里
])
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
} }
private isVisibleOfficialAccount(username: string, localType: number): boolean {
return username.startsWith('gh_') && localType === 1 && !this.builtinOfficialHelpers.has(username)
}
private toInt(value: unknown, fallback: number = 0): number {
const parsed = Number.parseInt(String(value ?? ''), 10)
return Number.isFinite(parsed) ? parsed : fallback
}
private getRowString(row: Record<string, any>, keys: string[]): string {
for (const key of keys) {
const value = row[key]
if (value !== undefined && value !== null && String(value).trim()) return String(value).trim()
}
return ''
}
private formatBizTime(ts: number): string {
if (!ts) return ''
const date = new Date(ts * 1000)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return '昨天'
const isThisYear = date.getFullYear() === now.getFullYear()
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
}
private extractXmlValue(xml: string, tagName: string): string { private extractXmlValue(xml: string, tagName: string): string {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i') const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
const match = regex.exec(xml) const match = regex.exec(xml)
@@ -99,10 +151,9 @@ export class BizService {
const enrichment = await chatService.enrichSessionsContactInfo(usernames) const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {} const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const root = this.configService.get('dbPath')
const myWxid = this.configService.getMyWxidCleaned() const myWxid = this.configService.getMyWxidCleaned()
const accountWxid = account || myWxid const accountWxid = account || myWxid
if (!root || !accountWxid) return [] if (!accountWxid) return []
const bizLatestTime: Record<string, number> = {} const bizLatestTime: Record<string, number> = {}
const bizUnreadCount: Record<string, number> = {} const bizUnreadCount: Record<string, number> = {}
@@ -129,24 +180,6 @@ export class BizService {
console.error('获取 Sessions 失败:', e) console.error('获取 Sessions 失败:', e)
} }
// 3. 格式化时间显示
const formatBizTime = (ts: number) => {
if (!ts) return ''
const date = new Date(ts * 1000)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return '昨天'
const isThisYear = date.getFullYear() === now.getFullYear()
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
}
// 4. 组装数据 // 4. 组装数据
const result: BizAccount[] = officialContacts.map(contact => { const result: BizAccount[] = officialContacts.map(contact => {
const uname = contact.username const uname = contact.username
@@ -158,20 +191,19 @@ export class BizService {
avatar: info?.avatarUrl || '', avatar: info?.avatarUrl || '',
type: 0, type: 0,
last_time: lastTime, last_time: lastTime,
formatted_last_time: formatBizTime(lastTime), formatted_last_time: this.formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0 unread_count: bizUnreadCount[uname] || 0,
status: 'active',
stale_level: 'none'
} }
}) })
// 5. 补充公众号类型 (订阅号/服务号) // 5. 补充公众号类型 (订阅号/服务号)
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db') const bizInfoRes = await wcdbService.execQuery('contact', null, 'SELECT username, type FROM biz_info')
if (existsSync(contactDbPath)) { if (bizInfoRes.success && bizInfoRes.rows) {
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info') const typeMap: Record<string, number> = {}
if (bizInfoRes.success && bizInfoRes.rows) { for (const r of bizInfoRes.rows as Array<Record<string, any>>) typeMap[String(r.username || '')] = this.toInt(r.type)
const typeMap: Record<string, number> = {} for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
}
} }
// 6. 排序输出 // 6. 排序输出
@@ -188,6 +220,131 @@ export class BizService {
} }
} }
async listAccountHealth(account?: string): Promise<BizAccountHealth> {
const empty: BizAccountHealth = {
summary: {
active_total: 0,
subscription_total: 0,
service_total: 0,
invalid_total: 0,
stale_one_year_total: 0,
stale_two_year_total: 0,
unknown_last_article_total: 0
},
accounts: [],
invalid_accounts: []
}
try {
const contactsRes = await wcdbService.execQuery(
'contact',
null,
"SELECT username, nick_name, remark, alias, local_type, delete_flag, verify_flag, flag FROM contact WHERE username LIKE 'gh_%'"
)
if (!contactsRes.success || !Array.isArray(contactsRes.rows)) return empty
const bizInfoRes = await wcdbService.execQuery('contact', null, 'SELECT username, type FROM biz_info')
const typeMap: Record<string, number> = {}
if (bizInfoRes.success && Array.isArray(bizInfoRes.rows)) {
for (const row of bizInfoRes.rows as Array<Record<string, any>>) {
const username = String(row.username || '').trim()
if (username) typeMap[username] = this.toInt(row.type)
}
}
const rows = contactsRes.rows as Array<Record<string, any>>
const usernames = rows.map((row) => String(row.username || '').trim()).filter(Boolean)
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const latestTime: Record<string, number> = {}
const unreadCount: Record<string, number> = {}
try {
const sessionsRes = await chatService.getSessions()
if (sessionsRes.success && sessionsRes.sessions) {
for (const session of sessionsRes.sessions) {
const username = String(session.username || session.strUsrName || session.userName || session.id || '').trim()
if (!username) continue
const time = this.toInt(session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp)
if (time > 0) latestTime[username] = time
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
unreadCount[username] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
}
}
} catch (e) {
console.error('获取 Sessions 失败:', e)
}
const nowSeconds = Math.floor(Date.now() / 1000)
const oneYearSeconds = 365 * 24 * 60 * 60
const accounts: BizAccount[] = []
const invalidAccounts: BizAccount[] = []
for (const row of rows) {
const username = String(row.username || '').trim()
if (!username || this.builtinOfficialHelpers.has(username)) continue
const hasBizInfo = typeMap[username] !== undefined
if (!hasBizInfo) continue
const localType = this.toInt(row.local_type ?? row.localType)
const info = contactInfoMap[username]
const lastTime = latestTime[username] || 0
const isActive = this.isVisibleOfficialAccount(username, localType)
let staleLevel: BizAccount['stale_level'] = 'none'
let daysSinceLastArticle: number | undefined
if (!lastTime) {
staleLevel = 'unknown'
} else {
daysSinceLastArticle = Math.max(0, Math.floor((nowSeconds - lastTime) / 86400))
if (nowSeconds - lastTime >= oneYearSeconds * 2) staleLevel = 'two_year'
else if (nowSeconds - lastTime >= oneYearSeconds) staleLevel = 'one_year'
}
const accountInfo: BizAccount = {
username,
name: info?.displayName || this.getRowString(row, ['remark', 'nick_name', 'alias']) || username,
avatar: info?.avatarUrl || '',
type: typeMap[username] ?? 3,
last_time: lastTime,
formatted_last_time: this.formatBizTime(lastTime),
unread_count: unreadCount[username] || 0,
status: isActive ? 'active' : 'inactive',
health_reason: isActive ? undefined : '不在微信通讯录的公众号/服务号有效分组中,可能是历史残留、已取消关注或已失效账号',
stale_level: staleLevel,
days_since_last_article: daysSinceLastArticle
}
if (isActive) accounts.push(accountInfo)
else invalidAccounts.push(accountInfo)
}
const sortAccounts = (items: BizAccount[]) => items.sort((a, b) => {
if (a.username === 'gh_3dfda90e39d6') return -1
if (b.username === 'gh_3dfda90e39d6') return 1
return (b.last_time || 0) - (a.last_time || 0) || a.name.localeCompare(b.name, 'zh-Hans-CN')
})
sortAccounts(accounts)
sortAccounts(invalidAccounts)
return {
summary: {
active_total: accounts.length,
subscription_total: accounts.filter((item) => item.type === 0).length,
service_total: accounts.filter((item) => item.type === 1).length,
invalid_total: invalidAccounts.length,
stale_one_year_total: accounts.filter((item) => item.stale_level === 'one_year' || item.stale_level === 'two_year').length,
stale_two_year_total: accounts.filter((item) => item.stale_level === 'two_year').length,
unknown_last_article_total: accounts.filter((item) => item.stale_level === 'unknown').length
},
accounts,
invalid_accounts: invalidAccounts
}
} catch (e) {
console.error('获取公众号健康状态失败:', e)
return empty
}
}
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> { async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
try { try {
// 仅保留核心路径:利用 chatService 的自动路由能力 // 仅保留核心路径:利用 chatService 的自动路由能力
@@ -242,6 +399,7 @@ export class BizService {
registerHandlers() { registerHandlers() {
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account)) ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
ipcMain.handle('biz:listAccountHealth', (_, account) => this.listAccountHealth(account))
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset)) ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset)) ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
} }
+248 -26
View File
@@ -170,9 +170,16 @@ export interface ContactInfo {
detailDescription?: string detailDescription?: string
region?: string region?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
officialAccountKind?: 'subscription' | 'service' | 'enterprise' | 'unknown'
officialAccountType?: number
} }
const BUILTIN_OFFICIAL_HELPER_USERNAMES = new Set([
'gh_f0a92aa7146c' // 微信收款助手不出现在通讯录的公众号/服务号分组里
])
const CONTACT_CLASSIFICATION_CACHE_VERSION = 'contact-classification-v5'
interface GetContactsOptions { interface GetContactsOptions {
lite?: boolean lite?: boolean
} }
@@ -220,6 +227,7 @@ interface ExportTabCounts {
group: number group: number
official: number official: number
former_friend: number former_friend: number
blocked?: number
} }
interface SessionDetailFast { interface SessionDetailFast {
@@ -359,6 +367,8 @@ class ChatService {
private readonly messageCursorSessionLimit = 8 private readonly messageCursorSessionLimit = 8
private avatarCache: Map<string, ContactCacheEntry> private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
private groupNicknameCache = new Map<string, { nicknames: Map<string, string>; updatedAt: number }>()
private readonly groupNicknameCacheTtlMs = 3 * 60 * 1000
private readonly defaultV1AesKey = 'cfcd208495d565ef' private readonly defaultV1AesKey = 'cfcd208495d565ef'
private readonly contactCacheService: ContactCacheService private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService private readonly messageCacheService: MessageCacheService
@@ -1677,7 +1687,8 @@ class ChatService {
private: Number(result.counts.private || 0), private: Number(result.counts.private || 0),
group: Number(result.counts.group || 0), group: Number(result.counts.group || 0),
official: Number(result.counts.official || 0), official: Number(result.counts.official || 0),
former_friend: Number(result.counts.former_friend || 0) former_friend: Number(result.counts.former_friend || 0),
blocked: Number(result.counts.blocked || 0)
} }
return { success: true, counts } return { success: true, counts }
@@ -2053,7 +2064,7 @@ class ChatService {
private getContactsCacheScope(): string { private getContactsCacheScope(): string {
const dbPath = String(this.configService.get('dbPath') || '').trim() const dbPath = String(this.configService.get('dbPath') || '').trim()
const myWxid = String(this.configService.getMyWxidCleaned() || '').trim() const myWxid = String(this.configService.getMyWxidCleaned() || '').trim()
return `${dbPath}::${myWxid}` return `${dbPath}::${myWxid}::${CONTACT_CLASSIFICATION_CACHE_VERSION}`
} }
private cloneContacts(contacts: ContactInfo[]): ContactInfo[] { private cloneContacts(contacts: ContactInfo[]): ContactInfo[] {
@@ -2143,6 +2154,7 @@ class ChatService {
const transformStartedAt = Date.now() const transformStartedAt = Date.now()
const contacts: (ContactInfo & { lastContactTime: number })[] = [] const contacts: (ContactInfo & { lastContactTime: number })[] = []
let contactLabelNameMap = new Map<number, string>() let contactLabelNameMap = new Map<number, string>()
const officialAccountTypeMap = await this.getOfficialAccountTypeMap()
if (!isLiteMode) { if (!isLiteMode) {
const labelMapStartedAt = Date.now() const labelMapStartedAt = Date.now()
contactLabelNameMap = await this.getContactLabelNameMap() contactLabelNameMap = await this.getContactLabelNameMap()
@@ -2153,11 +2165,18 @@ class ChatService {
if (!username) continue if (!username) continue
let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other' let type: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other' = 'other'
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
const flag = this.getRowInt(row, ['flag', 'contact_flag', 'contactFlag', 'WCDB_CT_flag'], 0)
const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim()
const alias = String(this.getRowField(row, ['alias', 'WCDB_CT_alias']) || '').trim()
const remark = String(this.getRowField(row, ['remark', 'WCDB_CT_remark']) || '').trim()
const loweredUsername = username.toLowerCase() const loweredUsername = username.toLowerCase()
const isOpenimEnterprise = this.isEnterpriseOpenimUsername(username) const isOpenimEnterprise = this.isEnterpriseOpenimUsername(username)
const isFormerFriendResidual = !username.startsWith('gh_') && (
(localType === 0 && quanPin) ||
(localType === 3 && flag !== 4 && (quanPin || alias || remark))
)
if (isOpenimEnterprise && !this.isAllowedEnterpriseOpenimByLocalType(username, localType)) { if (isOpenimEnterprise && !this.isAllowedEnterpriseOpenimByLocalType(username, localType)) {
continue continue
} }
@@ -2165,16 +2184,18 @@ class ChatService {
if (username.endsWith('@chatroom')) { if (username.endsWith('@chatroom')) {
type = 'group' type = 'group'
} else if (username.startsWith('gh_')) { } else if (username.startsWith('gh_') && localType === 1 && !BUILTIN_OFFICIAL_HELPER_USERNAMES.has(username)) {
type = 'official' type = 'official'
} else if (!username.startsWith('gh_') && (flag & 8) === 8) {
type = 'blocked'
} else if (isFormerFriendResidual) {
type = 'former_friend'
} else if (isOpenimEnterprise) { } else if (isOpenimEnterprise) {
type = 'friend' type = 'friend'
} else if (isVisibleWeixinContact) { } else if (isVisibleWeixinContact) {
type = 'friend' type = 'friend'
} else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) { } else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
type = 'friend' type = 'friend'
} else if (localType === 0 && quanPin) {
type = 'former_friend'
} else { } else {
continue continue
} }
@@ -2184,6 +2205,7 @@ class ChatService {
const description = isLiteMode ? '' : this.getContactDescription(row) const description = isLiteMode ? '' : this.getContactDescription(row)
const detailDescription = isLiteMode ? '' : this.getContactSignature(row) const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
const region = isLiteMode ? '' : this.getContactRegion(row) const region = isLiteMode ? '' : this.getContactRegion(row)
const officialAccountType = type === 'official' ? officialAccountTypeMap.get(username) : undefined
contacts.push({ contacts.push({
username, username,
@@ -2197,6 +2219,8 @@ class ChatService {
region: region || undefined, region: region || undefined,
avatarUrl: undefined, avatarUrl: undefined,
type, type,
officialAccountType,
officialAccountKind: type === 'official' ? this.getOfficialAccountKind(officialAccountType) : undefined,
lastContactTime: lastContactTimeMap.get(username) || 0 lastContactTime: lastContactTimeMap.get(username) || 0
}) })
} }
@@ -2572,6 +2596,7 @@ class ChatService {
if (normalized.length > 0) { if (normalized.length > 0) {
await this.repairEmojiMessages(normalized) await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId) await this.resolveQuotedMessages(normalized, sessionId)
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
} }
return { return {
@@ -2657,6 +2682,7 @@ class ChatService {
if (normalized.length > 0) { if (normalized.length > 0) {
await this.repairEmojiMessages(normalized) await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId) await this.resolveQuotedMessages(normalized, sessionId)
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
} }
return { return {
@@ -2824,6 +2850,7 @@ class ChatService {
if (fixPromises.length > 0) { if (fixPromises.length > 0) {
await Promise.allSettled(fixPromises) await Promise.allSettled(fixPromises)
} }
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
return { success: true, messages: normalized } return { success: true, messages: normalized }
} catch (e) { } catch (e) {
@@ -2891,6 +2918,7 @@ class ChatService {
if (collected.length > 0) { if (collected.length > 0) {
await this.repairEmojiMessages(collected) await this.repairEmojiMessages(collected)
await this.enrichGroupMessageSenderProfiles(collected, sessionId)
} }
return { success: true, messages: collected } return { success: true, messages: collected }
} }
@@ -3157,6 +3185,7 @@ class ChatService {
if (normalized.length > 0) { if (normalized.length > 0) {
await this.repairEmojiMessages(normalized) await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId) await this.resolveQuotedMessages(normalized, sessionId)
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
} }
return { return {
success: true, success: true,
@@ -3168,6 +3197,41 @@ class ChatService {
} }
} }
private async enrichGroupMessageSenderProfiles(messages: Message[], sessionId: string): Promise<void> {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId.endsWith('@chatroom') || messages.length === 0) return
const senderUsernames = Array.from(new Set(
messages
.map((message) => String(message.senderUsername || '').trim())
.filter(Boolean)
))
if (senderUsernames.length === 0) return
try {
const [groupNicknames, contactsResult] = await Promise.all([
this.getGroupNicknamesForRoom(normalizedSessionId),
this.enrichSessionsContactInfo(senderUsernames)
])
const contacts = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : {}
for (const message of messages) {
const sender = String(message.senderUsername || '').trim()
if (!sender) continue
const groupNickname = this.resolveGroupNicknameByCandidates(
groupNicknames,
this.buildSenderGroupNicknameCandidates(sender)
)
const contact = contacts[sender]
message.senderDisplayName = groupNickname || contact?.displayName || message.senderDisplayName || sender
message.senderAvatarUrl = contact?.avatarUrl || message.senderAvatarUrl
}
} catch (error) {
console.warn('[ChatService] 补充群消息发送者资料失败:', error)
}
}
private getRowField(row: Record<string, any>, keys: string[]): any { private getRowField(row: Record<string, any>, keys: string[]): any {
for (const key of keys) { for (const key of keys) {
if (row[key] !== undefined && row[key] !== null) return row[key] if (row[key] !== undefined && row[key] !== null) return row[key]
@@ -3423,6 +3487,45 @@ class ChatService {
return new Map(labelMap) return new Map(labelMap)
} }
private async getOfficialAccountTypeMap(): Promise<Map<string, number>> {
const typeMap = new Map<string, number>()
try {
const tableResult = await wcdbService.execQuery(
'contact',
null,
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='biz_info' LIMIT 1"
)
const tableName = tableResult.success && Array.isArray(tableResult.rows)
? String((tableResult.rows[0] as Record<string, any> | undefined)?.name || '').trim()
: ''
if (!tableName) return typeMap
const result = await wcdbService.execQuery(
'contact',
null,
`SELECT username, type FROM ${this.quoteSqlIdentifier(tableName)}`
)
if (!result.success || !Array.isArray(result.rows)) return typeMap
for (const row of result.rows as Array<Record<string, any>>) {
const username = String(this.getRowField(row, ['username', 'user_name', 'userName']) || '').trim()
if (!username) continue
const type = this.getRowInt(row, ['type', 'biz_type', 'bizType'], -1)
if (type >= 0) typeMap.set(username, type)
}
} catch (error) {
console.warn('读取 biz_info 公众号类型失败:', error)
}
return typeMap
}
private getOfficialAccountKind(type: number | undefined): 'subscription' | 'service' | 'enterprise' | 'unknown' {
if (type === 0) return 'subscription'
if (type === 1) return 'service'
if (type === 2 || type === 3) return 'enterprise'
return 'unknown'
}
private toExtraBufferBytes(row: Record<string, any>): Buffer | null { private toExtraBufferBytes(row: Record<string, any>): Buffer | null {
const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer']) const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer'])
if (raw === undefined || raw === null) return null if (raw === undefined || raw === null) return null
@@ -3972,6 +4075,114 @@ class ChatService {
return [lowerRaw] return [lowerRaw]
} }
private getGroupNicknameCacheKey(chatroomId: string): string {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const myWxid = String(this.configService.getMyWxidCleaned() || '').trim()
return `${dbPath}::${myWxid}::${String(chatroomId || '').trim()}`
}
private normalizeGroupNickname(value: unknown): string {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
const cleaned = trimmed.replace(/[\x00-\x1F\x7F]/g, '').trim()
if (!cleaned) return ''
if (/^[,"'“”‘’,、]+$/.test(cleaned)) return ''
return cleaned
}
private normalizeGroupNicknameIdentity(value: unknown): string {
return String(value || '').trim().toLowerCase()
}
private buildGroupNicknameIdCandidates(values: Array<unknown>): string[] {
const candidates = new Set<string>()
for (const rawValue of values) {
const raw = String(rawValue || '').trim()
if (!raw) continue
candidates.add(raw)
for (const identityKey of this.buildIdentityKeys(raw)) {
if (identityKey) candidates.add(identityKey)
}
}
return Array.from(candidates)
}
private buildTrustedGroupNicknameMap(entries: Iterable<[string, string]>): Map<string, string> {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of entries) {
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw)
if (!identity) continue
const nickname = this.normalizeGroupNickname(nicknameRaw)
if (!nickname) continue
const bucket = buckets.get(identity)
if (bucket) {
bucket.add(nickname)
} else {
buckets.set(identity, new Set([nickname]))
}
}
const trusted = new Map<string, string>()
for (const [identity, nicknameSet] of buckets.entries()) {
if (nicknameSet.size === 1) {
trusted.set(identity, Array.from(nicknameSet)[0])
}
}
return trusted
}
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedChatroomId.endsWith('@chatroom')) return new Map<string, string>()
const cacheKey = this.getGroupNicknameCacheKey(normalizedChatroomId)
const cached = this.groupNicknameCache.get(cacheKey)
if (cached && Date.now() - cached.updatedAt <= this.groupNicknameCacheTtlMs) {
return cached.nicknames
}
try {
const result = await wcdbService.getGroupNicknames(normalizedChatroomId)
const nicknames = result.success && result.nicknames
? this.buildTrustedGroupNicknameMap(Object.entries(result.nicknames))
: new Map<string, string>()
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
return nicknames
} catch (error) {
console.warn('[ChatService] 获取群成员群名片失败:', error)
return new Map<string, string>()
}
}
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: Array<unknown>): string {
let resolved = ''
for (const candidate of this.buildGroupNicknameIdCandidates(candidates)) {
const normalizedId = this.normalizeGroupNicknameIdentity(candidate)
if (!normalizedId) continue
const nickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '')
if (!nickname) continue
if (!resolved) {
resolved = nickname
continue
}
if (resolved !== nickname) return ''
}
return resolved
}
private buildSenderGroupNicknameCandidates(username: string): string[] {
const normalizedUsername = String(username || '').trim()
const candidates: unknown[] = [normalizedUsername, this.cleanAccountDirName(normalizedUsername)]
const myWxid = String(this.configService.getMyWxidCleaned() || '').trim()
if (myWxid) {
const myIdentityKeys = new Set(this.buildIdentityKeys(myWxid))
if (this.buildIdentityKeys(normalizedUsername).some((key) => myIdentityKeys.has(key))) {
candidates.push(myWxid, this.cleanAccountDirName(myWxid))
}
}
return this.buildGroupNicknameIdCandidates(candidates)
}
private resolveMessageIsSend(rawIsSend: number | null, senderUsername?: string | null): { private resolveMessageIsSend(rawIsSend: number | null, senderUsername?: string | null): {
isSend: number | null isSend: number | null
selfMatched: boolean selfMatched: boolean
@@ -7596,45 +7807,56 @@ class ChatService {
/** /**
* 获取联系人头像和显示名称(用于群聊消息) * 获取联系人头像和显示名称(用于群聊消息)
*/ */
async getContactAvatar(username: string): Promise<{ avatarUrl?: string; displayName?: string } | null> { async getContactAvatar(username: string, chatroomId?: string): Promise<{ avatarUrl?: string; displayName?: string } | null> {
if (!username) return null const normalizedUsername = String(username || '').trim()
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedUsername) return null
try { try {
const connectResult = await this.ensureConnected() const connectResult = await this.ensureConnected()
if (!connectResult.success) return null if (!connectResult.success) return null
const cached = this.avatarCache.get(username)
const groupNicknames = normalizedChatroomId.endsWith('@chatroom')
? await this.getGroupNicknamesForRoom(normalizedChatroomId)
: new Map<string, string>()
const groupDisplayName = this.resolveGroupNicknameByCandidates(
groupNicknames,
this.buildSenderGroupNicknameCandidates(normalizedUsername)
)
const cached = this.avatarCache.get(normalizedUsername)
// 检查缓存是否有效,且头像不是错误的 hex 格式 // 检查缓存是否有效,且头像不是错误的 hex 格式
const cachedAvatarUrl = this.shouldPersistAvatarUrl(cached?.avatarUrl) ? cached?.avatarUrl : undefined const cachedAvatarUrl = this.shouldPersistAvatarUrl(cached?.avatarUrl) ? cached?.avatarUrl : undefined
if (cached && cachedAvatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) { if (cached && cachedAvatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
return { avatarUrl: cachedAvatarUrl, displayName: cached.displayName } return { avatarUrl: cachedAvatarUrl, displayName: groupDisplayName || cached.displayName }
} }
const contact = await this.getContact(username) const contact = await this.getContact(normalizedUsername)
const avatarResult = await wcdbService.getAvatarUrls([username]) const avatarResult = await wcdbService.getAvatarUrls([normalizedUsername])
let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[normalizedUsername] : undefined
if (!this.isValidAvatarUrl(avatarUrl)) { if (!this.isValidAvatarUrl(avatarUrl)) {
avatarUrl = undefined avatarUrl = undefined
} }
if (!avatarUrl) { if (!avatarUrl) {
const headImageAvatars = await this.getAvatarsFromHeadImageDb([username]) const headImageAvatars = await this.getAvatarsFromHeadImageDb([normalizedUsername])
const fallbackAvatarUrl = headImageAvatars[username] const fallbackAvatarUrl = headImageAvatars[normalizedUsername]
if (this.isValidAvatarUrl(fallbackAvatarUrl)) { if (this.isValidAvatarUrl(fallbackAvatarUrl)) {
avatarUrl = fallbackAvatarUrl avatarUrl = fallbackAvatarUrl
} }
} }
const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username const effectiveAvatarUrl = this.shouldPersistAvatarUrl(avatarUrl)
? avatarUrl
: cachedAvatarUrl
const contactDisplayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || normalizedUsername
const displayName = groupDisplayName || contactDisplayName
const cacheEntry: ContactCacheEntry = { const cacheEntry: ContactCacheEntry = {
avatarUrl: this.shouldPersistAvatarUrl(avatarUrl) avatarUrl: effectiveAvatarUrl,
? avatarUrl displayName: contactDisplayName,
: this.shouldPersistAvatarUrl(cached?.avatarUrl)
? cached?.avatarUrl
: undefined,
displayName,
updatedAt: Date.now() updatedAt: Date.now()
} }
this.avatarCache.set(username, cacheEntry) this.avatarCache.set(normalizedUsername, cacheEntry)
this.contactCacheService.setEntries({ [username]: cacheEntry }) this.contactCacheService.setEntries({ [normalizedUsername]: cacheEntry })
return { avatarUrl, displayName } return { avatarUrl: effectiveAvatarUrl, displayName }
} catch { } catch {
return null return null
} }
+15 -3
View File
@@ -9,6 +9,7 @@ interface ContactExportOptions {
friends: boolean friends: boolean
groups: boolean groups: boolean
officials: boolean officials: boolean
blocked?: boolean
} }
selectedUsernames?: string[] selectedUsernames?: string[]
} }
@@ -38,6 +39,7 @@ class ContactExportService {
if (c.type === 'friend' && !options.contactTypes.friends) return false if (c.type === 'friend' && !options.contactTypes.friends) return false
if (c.type === 'group' && !options.contactTypes.groups) return false if (c.type === 'group' && !options.contactTypes.groups) return false
if (c.type === 'official' && !options.contactTypes.officials) return false if (c.type === 'official' && !options.contactTypes.officials) return false
if (c.type === 'blocked' && !options.contactTypes.blocked) return false
return true return true
}) })
@@ -99,7 +101,10 @@ class ContactExportService {
signature: c.detailDescription, signature: c.detailDescription,
detailDescription: c.detailDescription, detailDescription: c.detailDescription,
region: c.region, region: c.region,
type: c.type type: c.type,
officialAccountKind: c.officialAccountKind,
officialAccountType: c.officialAccountType,
typeLabel: this.getTypeLabel(c)
})) }))
} }
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8') fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8')
@@ -120,7 +125,7 @@ class ContactExportService {
c.description || '', c.description || '',
c.detailDescription || '', c.detailDescription || '',
c.region || '', c.region || '',
this.getTypeLabel(c.type) this.getTypeLabel(c)
]) ])
const escapeCell = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"` const escapeCell = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`
@@ -170,11 +175,18 @@ class ContactExportService {
fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8') fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8')
} }
private getTypeLabel(type: string): string { private getTypeLabel(contactOrType: any): string {
const type = typeof contactOrType === 'string' ? contactOrType : String(contactOrType?.type || '')
if (type === 'official' && typeof contactOrType !== 'string') {
if (contactOrType.officialAccountKind === 'service') return '服务号'
if (contactOrType.officialAccountKind === 'enterprise') return '企业号'
return '公众号'
}
switch (type) { switch (type) {
case 'friend': return '好友' case 'friend': return '好友'
case 'group': return '群聊' case 'group': return '群聊'
case 'official': return '公众号' case 'official': return '公众号'
case 'blocked': return '黑名单'
default: return '其他' default: return '其他'
} }
} }
+20 -7
View File
@@ -1393,8 +1393,15 @@ export class ExportContext {
if (normalizedSessionId.startsWith('gh_')) return '公众号_' if (normalizedSessionId.startsWith('gh_')) return '公众号_'
const rawLocalType = contact?.local_type ?? contact?.localType ?? contact?.WCDB_CT_local_type; const rawLocalType = contact?.local_type ?? contact?.localType ?? contact?.WCDB_CT_local_type;
const localType = Number.parseInt(String(rawLocalType ?? ''), 10); const localType = Number.parseInt(String(rawLocalType ?? ''), 10);
const rawFlag = contact?.flag ?? contact?.contact_flag ?? contact?.contactFlag ?? contact?.WCDB_CT_flag;
const flag = Number.parseInt(String(rawFlag ?? '0'), 10);
const quanPin = String(contact?.quan_pin ?? contact?.quanPin ?? contact?.WCDB_CT_quan_pin ?? '').trim(); const quanPin = String(contact?.quan_pin ?? contact?.quanPin ?? contact?.WCDB_CT_quan_pin ?? '').trim();
if (Number.isFinite(localType) && localType === 0 && quanPin) { const alias = String(contact?.alias ?? contact?.WCDB_CT_alias ?? '').trim();
const remark = String(contact?.remark ?? contact?.WCDB_CT_remark ?? '').trim();
if (Number.isFinite(localType) && (
(localType === 0 && quanPin) ||
(localType === 3 && Number.isFinite(flag) && flag !== 4 && (quanPin || alias || remark))
)) {
return '曾经的好友_' return '曾经的好友_'
} }
@@ -2912,9 +2919,13 @@ export class ExportContext {
destFileName = `${md5}${ext || '.gif'}` destFileName = `${md5}${ext || '.gif'}`
} else if (kind === 'file') { } else if (kind === 'file') {
const safeBaseName = path.basename(String(msg?.fileName || path.basename(sourcePath))).replace(/[\\/:*?"<>|]/g, '_') || path.basename(sourcePath) const safeBaseName = path.basename(String(msg?.fileName || path.basename(sourcePath))).replace(/[\\/:*?"<>|]/g, '_') || path.basename(sourcePath)
destFileName = `${String(msg?.localId || Date.now())}_${safeBaseName}` destFileName = safeBaseName
}
let destPath = path.join(targetDir, destFileName)
if (kind === 'file' && this.resolveExportConflictStrategy(options) === 'rename') {
destPath = await reserveUniqueOutputPath(destPath, new Set<string>())
destFileName = path.basename(destPath)
} }
const destPath = path.join(targetDir, destFileName)
if (path.resolve(sourcePath) !== path.resolve(destPath)) { if (path.resolve(sourcePath) !== path.resolve(destPath)) {
if (kind === 'image' || kind === 'emoji' || kind === 'video') { if (kind === 'image' || kind === 'emoji' || kind === 'video') {
const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control, options) const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control, options)
@@ -3945,10 +3956,12 @@ export class ExportContext {
return null return null
} }
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file' let destFileName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
const messageId = String(msg?.localId || Date.now()) let destPath = path.join(fileDir, destFileName)
const destFileName = `${messageId}_${safeBaseName}` if (this.resolveExportConflictStrategy(options) === 'rename') {
const destPath = path.join(fileDir, destFileName) destPath = await reserveUniqueOutputPath(destPath, new Set<string>())
destFileName = path.basename(destPath)
}
const existedBeforeCopy = await pathExists(destPath) const existedBeforeCopy = await pathExists(destPath)
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) { if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 }) this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
+21 -8
View File
@@ -691,14 +691,16 @@ export class WcdbCore {
return `SELECT * FROM contact WHERE username IN (${inList})` return `SELECT * FROM contact WHERE username IN (${inList})`
} }
private deriveContactTypeCounts(rows: Array<Record<string, any>>): { private: number; group: number; official: number; former_friend: number } { private deriveContactTypeCounts(rows: Array<Record<string, any>>): { private: number; group: number; official: number; former_friend: number; blocked: number } {
const counts = { const counts = {
private: 0, private: 0,
group: 0, group: 0,
official: 0, official: 0,
former_friend: 0 former_friend: 0,
blocked: 0
} }
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
const builtinOfficialHelpers = new Set(['gh_f0a92aa7146c'])
for (const row of rows || []) { for (const row of rows || []) {
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
@@ -706,16 +708,26 @@ export class WcdbCore {
const localTypeRaw = row.local_type ?? row.localType ?? row.WCDB_CT_local_type ?? 0 const localTypeRaw = row.local_type ?? row.localType ?? row.WCDB_CT_local_type ?? 0
const localType = Number.isFinite(Number(localTypeRaw)) ? Math.floor(Number(localTypeRaw)) : 0 const localType = Number.isFinite(Number(localTypeRaw)) ? Math.floor(Number(localTypeRaw)) : 0
const flagRaw = row.flag ?? row.contact_flag ?? row.contactFlag ?? row.WCDB_CT_flag ?? 0
const flag = Number.isFinite(Number(flagRaw)) ? Math.floor(Number(flagRaw)) : 0
const quanPin = this.pickFirstStringField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) const quanPin = this.pickFirstStringField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin'])
const alias = this.pickFirstStringField(row, ['alias', 'WCDB_CT_alias'])
const remark = this.pickFirstStringField(row, ['remark', 'WCDB_CT_remark'])
const isFormerFriendResidual = !username.startsWith('gh_') && (
(localType === 0 && quanPin) ||
(localType === 3 && flag !== 4 && (quanPin || alias || remark))
)
if (username.endsWith('@chatroom')) { if (username.endsWith('@chatroom')) {
counts.group += 1 counts.group += 1
} else if (username.startsWith('gh_')) { } else if (username.startsWith('gh_') && localType === 1 && !builtinOfficialHelpers.has(username)) {
counts.official += 1 counts.official += 1
} else if (localType === 1 && !excludeNames.has(username)) { } else if (!username.startsWith('gh_') && (flag & 8) === 8) {
counts.private += 1 counts.blocked += 1
} else if (localType === 0 && quanPin) { } else if (isFormerFriendResidual) {
counts.former_friend += 1 counts.former_friend += 1
} else if (localType === 1 && (flag & 8) === 0 && !excludeNames.has(username)) {
counts.private += 1
} }
} }
@@ -3468,7 +3480,7 @@ export class WcdbCore {
} }
} }
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number; blocked?: number }; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
const runFallback = async (reason: string) => { const runFallback = async (reason: string) => {
const contactsResult = await this.getContactsCompact() const contactsResult = await this.getContactsCompact()
@@ -3494,7 +3506,8 @@ export class WcdbCore {
private: Number(raw.private || 0), private: Number(raw.private || 0),
group: Number(raw.group || 0), group: Number(raw.group || 0),
official: Number(raw.official || 0), official: Number(raw.official || 0),
former_friend: Number(raw.former_friend || 0) former_friend: Number(raw.former_friend || 0),
blocked: Number(raw.blocked || 0)
} }
} }
} catch (e) { } catch (e) {
+1 -1
View File
@@ -426,7 +426,7 @@ export class WcdbService {
return this.callWorker('getContactStatus', { usernames }) return this.callWorker('getContactStatus', { usernames })
} }
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number; blocked?: number }; error?: string }> {
return this.callWorker('getContactTypeCounts') return this.callWorker('getContactTypeCounts')
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+69 -1
View File
@@ -3,6 +3,63 @@
overflow-y: auto; overflow-y: auto;
background-color: var(--bg-secondary); // 对齐会话列表背景 background-color: var(--bg-secondary); // 对齐会话列表背景
.biz-health-panel {
position: sticky;
top: 0;
z-index: 2;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
}
.biz-health-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
margin-bottom: 6px;
}
.biz-health-filter {
height: 30px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-bg);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0 8px;
font-size: 12px;
cursor: pointer;
strong {
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
}
&.active {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-light);
strong {
color: var(--primary);
}
}
}
.biz-health-counts {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-tertiary);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
}
.biz-loading { .biz-loading {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
@@ -93,17 +150,28 @@
} }
} }
.biz-badge-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.biz-badge { .biz-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px; font-size: 10px;
padding: 1px 6px; padding: 1px 6px;
border-radius: 4px; border-radius: 4px;
width: fit-content; width: fit-content;
margin-top: 2px;
&.type-service { color: #07c160; background: rgba(7, 193, 96, 0.1); } &.type-service { color: #07c160; background: rgba(7, 193, 96, 0.1); }
&.type-sub { color: var(--primary); background: var(--primary-light); } &.type-sub { color: var(--primary); background: var(--primary-light); }
&.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); } &.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); }
&.type-unknown { color: var(--text-tertiary); background: var(--bg-tertiary); } &.type-unknown { color: var(--text-tertiary); background: var(--bg-tertiary); }
&.type-invalid { color: #d46b08; background: rgba(250, 173, 20, 0.14); }
&.type-stale { color: #722ed1; background: rgba(114, 46, 209, 0.1); }
} }
} }
} }
+93 -13
View File
@@ -1,24 +1,46 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useThemeStore } from '../stores/themeStore'; import { useThemeStore } from '../stores/themeStore';
import { Newspaper, MessageSquareOff } from 'lucide-react'; import { AlertTriangle, Clock, MessageSquareOff, Newspaper } from 'lucide-react';
import './BizPage.scss'; import './BizPage.scss';
export interface BizAccount { export interface BizAccount {
username: string; username: string;
name: string; name: string;
avatar: string; avatar: string;
type: string; type: number | string;
last_time: number; last_time: number;
formatted_last_time: string; formatted_last_time: string;
unread_count?: number; unread_count?: number;
status?: 'active' | 'inactive';
health_reason?: string;
stale_level?: 'none' | 'one_year' | 'two_year' | 'unknown';
days_since_last_article?: number;
} }
interface BizAccountHealth {
summary: {
active_total: number;
subscription_total: number;
service_total: number;
invalid_total: number;
stale_one_year_total: number;
stale_two_year_total: number;
unknown_last_article_total: number;
};
accounts: BizAccount[];
invalid_accounts: BizAccount[];
}
type BizFilter = 'active' | 'invalid' | 'one_year' | 'two_year' | 'unknown';
export const BizAccountList: React.FC<{ export const BizAccountList: React.FC<{
onSelect: (account: BizAccount) => void; onSelect: (account: BizAccount) => void;
selectedUsername?: string; selectedUsername?: string;
searchKeyword?: string; searchKeyword?: string;
}> = ({ onSelect, selectedUsername, searchKeyword }) => { }> = ({ onSelect, selectedUsername, searchKeyword }) => {
const [accounts, setAccounts] = useState<BizAccount[]>([]); const [accounts, setAccounts] = useState<BizAccount[]>([]);
const [health, setHealth] = useState<BizAccountHealth | null>(null);
const [filter, setFilter] = useState<BizFilter>('active');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [myWxid, setMyWxid] = useState<string>(''); const [myWxid, setMyWxid] = useState<string>('');
@@ -44,8 +66,15 @@ export const BizAccountList: React.FC<{
setLoading(true); setLoading(true);
try { try {
const res = await window.electronAPI.biz.listAccounts(myWxid) if (window.electronAPI.biz.listAccountHealth) {
setAccounts(res || []); const res = await window.electronAPI.biz.listAccountHealth(myWxid) as BizAccountHealth;
setHealth(res || null);
setAccounts([...(res?.accounts || []), ...(res?.invalid_accounts || [])]);
} else {
const res = await window.electronAPI.biz.listAccounts(myWxid)
setHealth(null);
setAccounts(res || []);
}
} catch (err) { } catch (err) {
console.error('获取服务号列表失败:', err); console.error('获取服务号列表失败:', err);
} finally { } finally {
@@ -76,9 +105,14 @@ export const BizAccountList: React.FC<{
const filtered = useMemo(() => { const filtered = useMemo(() => {
let result = accounts; let result = accounts;
if (health) {
if (filter === 'active') result = health.accounts || [];
else if (filter === 'invalid') result = health.invalid_accounts || [];
else result = (health.accounts || []).filter(a => a.stale_level === filter || (filter === 'one_year' && a.stale_level === 'two_year'));
}
if (searchKeyword) { if (searchKeyword) {
const q = searchKeyword.toLowerCase(); const q = searchKeyword.toLowerCase();
result = accounts.filter(a => result = result.filter(a =>
(a.name && a.name.toLowerCase().includes(q)) || (a.name && a.name.toLowerCase().includes(q)) ||
(a.username && a.username.toLowerCase().includes(q)) (a.username && a.username.toLowerCase().includes(q))
); );
@@ -88,13 +122,40 @@ export const BizAccountList: React.FC<{
if (b.username === 'gh_3dfda90e39d6') return 1; if (b.username === 'gh_3dfda90e39d6') return 1;
return b.last_time - a.last_time; return b.last_time - a.last_time;
}); });
}, [accounts, searchKeyword]); }, [accounts, filter, health, searchKeyword]);
const renderFilterButton = (nextFilter: BizFilter, label: string, count: number) => (
<button
type="button"
className={`biz-health-filter ${filter === nextFilter ? 'active' : ''}`}
onClick={() => setFilter(nextFilter)}
>
<span>{label}</span>
<strong>{count}</strong>
</button>
);
if (loading) return <div className="biz-loading">加载中...</div>; if (loading) return <div className="biz-loading">加载中...</div>;
return ( return (
<div className="biz-account-list"> <div className="biz-account-list">
{health && (
<div className="biz-health-panel">
<div className="biz-health-row">
{renderFilterButton('active', '有效', health.summary.active_total)}
{renderFilterButton('invalid', '疑似失效', health.summary.invalid_total)}
</div>
<div className="biz-health-row">
{renderFilterButton('one_year', '1年未发', health.summary.stale_one_year_total)}
{renderFilterButton('two_year', '2年未发', health.summary.stale_two_year_total)}
</div>
<div className="biz-health-counts">
<span>公众号 {health.summary.subscription_total}</span>
<span>服务号 {health.summary.service_total}</span>
<span>无记录 {health.summary.unknown_last_article_total}</span>
</div>
</div>
)}
{filtered.map(item => ( {filtered.map(item => (
<div <div
key={item.username} key={item.username}
@@ -123,13 +184,32 @@ export const BizAccountList: React.FC<{
{/* <div className="biz-badge type-service">微信支付</div>*/} {/* <div className="biz-badge type-service">微信支付</div>*/}
{/*)}*/} {/*)}*/}
<div className={`biz-badge ${ <div className="biz-badge-row">
item.type === '1' ? 'type-service' : {(() => {
item.type === '0' ? 'type-sub' : const typeValue = Number(item.type);
item.type === '2' ? 'type-enterprise' : return (
item.type === '3' ? 'type-enterprise' : 'type-unknown' <div className={`biz-badge ${
}`}> typeValue === 1 ? 'type-service' :
{item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'} typeValue === 0 ? 'type-sub' :
typeValue === 2 ? 'type-enterprise' :
typeValue === 3 ? 'type-enterprise' : 'type-unknown'
}`}>
{typeValue === 0 ? '公众号' : typeValue === 1 ? '服务号' : typeValue === 2 ? '企业号' : typeValue === 3 ? '企业附属' : '未知'}
</div>
);
})()}
{item.status === 'inactive' && (
<div className="biz-badge type-invalid"><AlertTriangle size={11} />疑似失效</div>
)}
{item.status !== 'inactive' && item.stale_level === 'one_year' && (
<div className="biz-badge type-stale"><Clock size={11} />1年未发</div>
)}
{item.status !== 'inactive' && item.stale_level === 'two_year' && (
<div className="biz-badge type-stale"><Clock size={11} />2年未发</div>
)}
{item.status !== 'inactive' && item.stale_level === 'unknown' && (
<div className="biz-badge type-unknown"><Clock size={11} />无记录</div>
)}
</div> </div>
</div> </div>
+23 -1
View File
@@ -20,15 +20,25 @@ export interface ChatMessageBubbleProps {
avatarUrl?: string avatarUrl?: string
isGroupChat?: boolean isGroupChat?: boolean
resolvedSenderName?: string resolvedSenderName?: string
avatarProfile?: MessageAvatarProfile
isSelectionMode?: boolean isSelectionMode?: boolean
isSelected?: boolean isSelected?: boolean
onContextMenu?: (event: React.MouseEvent, message: Message) => void onContextMenu?: (event: React.MouseEvent, message: Message) => void
onAvatarContextMenu?: (event: React.MouseEvent, message: Message, profile: MessageAvatarProfile) => void
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void
actionNode?: React.ReactNode actionNode?: React.ReactNode
children: React.ReactNode children: React.ReactNode
portal?: React.ReactNode portal?: React.ReactNode
} }
export interface MessageAvatarProfile {
username?: string
displayName: string
avatarUrl?: string
isSelf?: boolean
isGroupMember?: boolean
}
function SelectionCheckbox({ checked, side }: { checked?: boolean; side: 'left' | 'right' }) { function SelectionCheckbox({ checked, side }: { checked?: boolean; side: 'left' | 'right' }) {
return ( return (
<div className={`chat-selection-checkbox ${side} ${checked ? 'checked' : ''}`}> <div className={`chat-selection-checkbox ${side} ${checked ? 'checked' : ''}`}>
@@ -54,9 +64,11 @@ function ChatMessageBubble({
avatarUrl, avatarUrl,
isGroupChat, isGroupChat,
resolvedSenderName, resolvedSenderName,
avatarProfile,
isSelectionMode, isSelectionMode,
isSelected, isSelected,
onContextMenu, onContextMenu,
onAvatarContextMenu,
onToggleSelection, onToggleSelection,
actionNode, actionNode,
children, children,
@@ -89,7 +101,15 @@ function ChatMessageBubble({
className={`message-bubble ${bubbleClass} ${isEmoji && emojiHasAsset && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVideo ? 'video' : ''} ${isVoice ? 'voice' : ''}`} className={`message-bubble ${bubbleClass} ${isEmoji && emojiHasAsset && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVideo ? 'video' : ''} ${isVoice ? 'voice' : ''}`}
onContextMenu={(event) => onContextMenu?.(event, message)} onContextMenu={(event) => onContextMenu?.(event, message)}
> >
<div className="bubble-avatar"> <div
className="bubble-avatar"
onContextMenu={(event) => {
if (!avatarProfile || isSystem) return
event.preventDefault()
event.stopPropagation()
onAvatarContextMenu?.(event, message, avatarProfile)
}}
>
<Avatar src={avatarUrl} name={avatarName} size={36} className="bubble-avatar" /> <Avatar src={avatarUrl} name={avatarName} size={36} className="bubble-avatar" />
</div> </div>
<div className="bubble-body"> <div className="bubble-body">
@@ -137,9 +157,11 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
prev.avatarUrl === next.avatarUrl && prev.avatarUrl === next.avatarUrl &&
prev.isGroupChat === next.isGroupChat && prev.isGroupChat === next.isGroupChat &&
prev.resolvedSenderName === next.resolvedSenderName && prev.resolvedSenderName === next.resolvedSenderName &&
prev.avatarProfile === next.avatarProfile &&
prev.isSelectionMode === next.isSelectionMode && prev.isSelectionMode === next.isSelectionMode &&
prev.isSelected === next.isSelected && prev.isSelected === next.isSelected &&
prev.onContextMenu === next.onContextMenu && prev.onContextMenu === next.onContextMenu &&
prev.onAvatarContextMenu === next.onAvatarContextMenu &&
prev.onToggleSelection === next.onToggleSelection && prev.onToggleSelection === next.onToggleSelection &&
prev.actionNode === next.actionNode && prev.actionNode === next.actionNode &&
prev.children === next.children && prev.children === next.children &&
+260
View File
@@ -2097,6 +2097,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
cursor: context-menu;
img { img {
width: 100%; width: 100%;
@@ -4955,6 +4956,265 @@
} }
} }
.avatar-profile-overlay {
position: fixed;
inset: 0;
z-index: 12060;
background: transparent;
}
.avatar-profile-card {
position: fixed;
z-index: 12070;
width: min(340px, calc(100vw - 24px));
max-height: calc(100vh - 24px);
overflow: hidden auto;
padding: 14px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
background: color-mix(in srgb, var(--card-bg) 96%, transparent);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.18), 0 4px 14px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(18px);
animation: avatarProfileIn 0.14s ease-out;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text-tertiary) 58%, transparent);
border-radius: 999px;
}
}
.avatar-profile-close {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
color: var(--text-tertiary);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.avatar-profile-hero {
display: flex;
align-items: center;
gap: 12px;
padding: 2px 28px 14px 2px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
.avatar-profile-photo {
flex: 0 0 auto;
border-radius: 12px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
}
}
.avatar-profile-title {
min-width: 0;
h3 {
margin: 0;
color: var(--text-primary);
font-size: 18px;
line-height: 1.25;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
margin: 5px 0 0;
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.35;
}
}
.avatar-profile-section {
padding: 12px 0;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
&.compact {
padding-bottom: 10px;
}
}
.avatar-profile-row {
min-height: 30px;
display: grid;
grid-template-columns: 58px minmax(0, 1fr) 24px;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
span {
color: var(--text-secondary);
}
strong {
color: var(--text-primary);
font-weight: 500;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:has(.avatar-profile-copy)) {
grid-template-columns: 58px minmax(0, 1fr);
}
}
.avatar-profile-copy {
width: 24px;
height: 24px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.avatar-profile-loading,
.avatar-profile-empty,
.avatar-profile-error,
.avatar-profile-moments-empty,
.avatar-profile-moments-loading {
min-height: 34px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-tertiary);
font-size: 12px;
}
.avatar-profile-error {
min-height: 26px;
color: var(--danger);
}
.avatar-profile-moments {
width: 100%;
padding: 12px 0;
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: transparent;
text-align: left;
cursor: pointer;
&:not(.disabled):hover {
.avatar-profile-moment-thumb {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--primary) 32%, var(--border-color));
}
.avatar-profile-moments-head strong {
color: var(--primary);
}
}
&.disabled {
cursor: default;
}
}
.avatar-profile-moments-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
span {
color: var(--text-secondary);
font-size: 13px;
}
strong {
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
transition: color 0.16s ease;
}
}
.avatar-profile-moment-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.avatar-profile-moment-thumb {
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent);
background: var(--bg-secondary);
transition: transform 0.16s ease, border-color 0.16s ease;
img,
video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.avatar-profile-moment-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
color: var(--text-secondary);
background: linear-gradient(135deg, var(--bg-secondary), var(--card-bg));
font-size: 16px;
font-weight: 600;
&.failed {
color: var(--text-tertiary);
}
}
@keyframes avatarProfileIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// Modal Overlay // Modal Overlay
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
+562 -18
View File
@@ -31,7 +31,7 @@ import {
requestExportSessionStatus requestExportSessionStatus
} from '../services/exportBridge' } from '../services/exportBridge'
import ChatHeader from './Chat/ChatHeader' import ChatHeader from './Chat/ChatHeader'
import ChatMessageBubble from './Chat/ChatMessageBubble' import ChatMessageBubble, { type MessageAvatarProfile } from './Chat/ChatMessageBubble'
import { buildNewMessagesCursor } from './Chat/messageCursor' import { buildNewMessagesCursor } from './Chat/messageCursor'
import { import {
subscribeSharedImageCacheResolved, subscribeSharedImageCacheResolved,
@@ -114,6 +114,7 @@ const VOICE_CACHE_MAX_BYTES = 24 * BYTES_PER_MEGABYTE
const VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES = 1800 const VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES = 1800
const VOICE_TRANSCRIPT_CACHE_MAX_BYTES = 2 * BYTES_PER_MEGABYTE const VOICE_TRANSCRIPT_CACHE_MAX_BYTES = 2 * BYTES_PER_MEGABYTE
const SENDER_AVATAR_CACHE_MAX_ENTRIES = 2000 const SENDER_AVATAR_CACHE_MAX_ENTRIES = 2000
const SENDER_AVATAR_CACHE_TTL_MS = 5 * 60 * 1000
const AUTO_MEDIA_TASK_MAX_CONCURRENCY = 2 const AUTO_MEDIA_TASK_MAX_CONCURRENCY = 2
const AUTO_MEDIA_TASK_MAX_QUEUE = 80 const AUTO_MEDIA_TASK_MAX_QUEUE = 80
@@ -778,6 +779,85 @@ function safeParseJson<T>(raw: string | null): T | null {
} }
} }
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
interface CachedCurrentUserProfile {
wxid?: string
displayName?: string
alias?: string
avatarUrl?: string
}
function normalizeAccountIdForProfile(value?: string | null): string {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
function isUsefulCurrentUserName(value?: string | null, wxid?: string | null): value is string {
const name = String(value || '').trim()
if (!name) return false
const lower = name.toLowerCase()
if (name === '我' || name === '微信用户' || lower === 'self') return false
if (lower.startsWith('wxid_')) return false
const normalizedWxid = normalizeAccountIdForProfile(wxid).toLowerCase()
if (normalizedWxid && normalizeAccountIdForProfile(name).toLowerCase() === normalizedWxid) return false
return true
}
function readCachedCurrentUserProfile(preferredWxid?: string | null): CachedCurrentUserProfile | null {
if (typeof window === 'undefined') return null
const preferredRaw = String(preferredWxid || '').trim()
const preferredNormalized = normalizeAccountIdForProfile(preferredRaw)
const isSameAccount = (candidate?: string | null): boolean => {
const raw = String(candidate || '').trim()
if (!raw) return !preferredRaw && !preferredNormalized
if (!preferredRaw && !preferredNormalized) return true
return raw.toLowerCase() === preferredRaw.toLowerCase() ||
normalizeAccountIdForProfile(raw).toLowerCase() === preferredNormalized.toLowerCase()
}
const sidebarProfile = safeParseJson<CachedCurrentUserProfile & { updatedAt?: number }>(
window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
)
if (sidebarProfile?.wxid && isSameAccount(sidebarProfile.wxid)) {
return {
wxid: normalizeAccountIdForProfile(sidebarProfile.wxid) || sidebarProfile.wxid,
displayName: sidebarProfile.displayName,
alias: sidebarProfile.alias,
avatarUrl: sidebarProfile.avatarUrl
}
}
const accountProfiles = safeParseJson<Record<string, CachedCurrentUserProfile & { updatedAt?: number }>>(
window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
) || {}
const candidates = [
preferredRaw,
preferredNormalized,
...Object.keys(accountProfiles)
].filter(Boolean)
for (const key of candidates) {
const profile = accountProfiles[key]
if (!profile || !isSameAccount(key)) continue
return {
wxid: preferredNormalized || normalizeAccountIdForProfile(key) || key,
displayName: profile.displayName,
alias: profile.alias,
avatarUrl: profile.avatarUrl
}
}
return null
}
function formatYmdDateFromSeconds(timestamp?: number): string { function formatYmdDateFromSeconds(timestamp?: number): string {
if (!timestamp || !Number.isFinite(timestamp)) return '—' if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp * 1000) const d = new Date(timestamp * 1000)
@@ -798,6 +878,11 @@ function formatYmdHmDateTime(timestamp?: number): string {
return `${y}-${m}-${day} ${h}:${min}` return `${y}-${m}-${day} ${h}:${min}`
} }
function formatYmdHmDateTimeFromSeconds(timestamp?: number): string {
if (!timestamp || !Number.isFinite(timestamp)) return ''
return formatYmdHmDateTime(timestamp * 1000)
}
function formatDateInputLocal(date: Date): string { function formatDateInputLocal(date: Date): string {
const y = date.getFullYear() const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0') const m = `${date.getMonth() + 1}`.padStart(2, '0')
@@ -880,6 +965,40 @@ interface SessionContactProfile {
updatedAt: number updatedAt: number
} }
interface AvatarProfileTarget extends MessageAvatarProfile {
username?: string
sourceLabel?: string
messageTime?: number
canOpenMoments: boolean
}
interface AvatarProfileDetail {
wxid: string
displayName: string
remark?: string
nickName?: string
alias?: string
avatarUrl?: string
messageCount?: number
}
interface AvatarProfileMomentPreview {
id: string
src: string
kind: 'image' | 'video'
}
interface AvatarProfileCardState {
x: number
y: number
target: AvatarProfileTarget
detail?: AvatarProfileDetail | null
detailLoading: boolean
detailError?: string | null
momentsLoading: boolean
momentPreviews: AvatarProfileMomentPreview[]
}
type GroupMessageCountStatus = 'loading' | 'ready' | 'failed' type GroupMessageCountStatus = 'loading' | 'ready' | 'failed'
interface GroupPanelMember { interface GroupPanelMember {
@@ -984,12 +1103,12 @@ function resolveQuotedSenderUsername(
} }
function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined { function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined {
const remark = normalizeSearchIdentityText(member.remark)
if (remark) return remark
const groupNickname = normalizeSearchIdentityText(member.groupNickname) const groupNickname = normalizeSearchIdentityText(member.groupNickname)
if (groupNickname) return groupNickname if (groupNickname) return groupNickname
const remark = normalizeSearchIdentityText(member.remark)
if (remark) return remark
const nickname = normalizeSearchIdentityText(member.nickname) const nickname = normalizeSearchIdentityText(member.nickname)
if (nickname) return nickname if (nickname) return nickname
@@ -1124,7 +1243,10 @@ async function resolveQuotedSenderDisplayName(options: {
} }
try { try {
const profile = await window.electronAPI.chat.getContactAvatar(normalizedSender) const profile = await window.electronAPI.chat.getContactAvatar(
normalizedSender,
options.isGroupChat ? normalizedSessionId : undefined
)
const profileDisplayName = normalizeSearchIdentityText(profile?.displayName) const profileDisplayName = normalizeSearchIdentityText(profile?.displayName)
if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) { if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) {
quotedSenderDisplayCache.set(cacheKey, { quotedSenderDisplayCache.set(cacheKey, {
@@ -1583,6 +1705,8 @@ function ChatPage(props: ChatPageProps) {
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false) const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null) const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
const [exportPrepareHint, setExportPrepareHint] = useState('') const [exportPrepareHint, setExportPrepareHint] = useState('')
const avatarProfileRequestSeqRef = useRef(0)
const [avatarProfileCard, setAvatarProfileCard] = useState<AvatarProfileCardState | null>(null)
// 消息右键菜单 // 消息右键菜单
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null) const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null)
@@ -7122,6 +7246,126 @@ function ChatPage(props: ChatPageProps) {
} }
}, []) }, [])
const clampAvatarProfilePosition = useCallback((x: number, y: number) => {
const viewportPadding = 12
const estimatedCardWidth = 340
const estimatedCardHeight = 560
const maxLeft = Math.max(viewportPadding, window.innerWidth - estimatedCardWidth - viewportPadding)
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedCardHeight - viewportPadding)
return {
x: Math.min(Math.max(x, viewportPadding), maxLeft),
y: Math.min(Math.max(y, viewportPadding), maxTop)
}
}, [])
const loadAvatarProfileExtras = useCallback(async (target: AvatarProfileTarget, requestSeq: number) => {
const username = String(target.username || '').trim()
if (!username || username === 'self') {
setAvatarProfileCard(prev => prev && requestSeq === avatarProfileRequestSeqRef.current
? { ...prev, detailLoading: false, momentsLoading: false }
: prev)
return
}
const loadMomentPreviews = async (): Promise<AvatarProfileMomentPreview[]> => {
if (!target.canOpenMoments) return []
const previews: AvatarProfileMomentPreview[] = []
let offset = 0
const pageSize = 12
const maxPages = 5
for (let page = 0; page < maxPages && previews.length < 5; page++) {
if (requestSeq !== avatarProfileRequestSeqRef.current) return previews
const timelineResult = await window.electronAPI.sns.getTimeline(pageSize, offset, [username])
if (!timelineResult.success || !Array.isArray(timelineResult.timeline) || timelineResult.timeline.length === 0) {
break
}
offset += timelineResult.timeline.length
for (const post of timelineResult.timeline) {
if (previews.length >= 5) break
const media = post.media?.find(item => item.thumb || item.url)
if (!media) continue
try {
const proxyResult = await window.electronAPI.sns.proxyImage({
url: media.thumb || media.url,
key: media.key
})
if (requestSeq !== avatarProfileRequestSeqRef.current) return previews
if (proxyResult.success && proxyResult.dataUrl) {
previews.push({
id: `${post.id}-${previews.length}`,
src: proxyResult.dataUrl,
kind: 'image'
})
} else if (proxyResult.success && proxyResult.videoPath) {
previews.push({
id: `${post.id}-${previews.length}`,
src: `file://${proxyResult.videoPath.replace(/\\/g, '/')}`,
kind: 'video'
})
}
} catch {
// Try the next recent media item.
}
}
}
return previews
}
const [detailResult, previewsResult] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailFast(username),
loadMomentPreviews()
])
if (requestSeq !== avatarProfileRequestSeqRef.current) return
let detail: AvatarProfileDetail | null = null
let detailError: string | null = null
if (detailResult.status === 'fulfilled' && detailResult.value?.success && detailResult.value.detail) {
const nextDetail = detailResult.value.detail
detail = {
wxid: nextDetail.wxid || username,
displayName: nextDetail.displayName || target.displayName || username,
remark: nextDetail.remark,
nickName: nextDetail.nickName,
alias: nextDetail.alias,
avatarUrl: nextDetail.avatarUrl || target.avatarUrl,
messageCount: nextDetail.messageCount
}
} else if (detailResult.status === 'fulfilled' && detailResult.value && !detailResult.value.success) {
detailError = detailResult.value.error || '资料补全失败'
} else if (detailResult.status === 'rejected') {
detailError = '资料补全失败'
}
const momentPreviews = previewsResult.status === 'fulfilled' ? previewsResult.value : []
setAvatarProfileCard(prev => {
if (!prev || requestSeq !== avatarProfileRequestSeqRef.current) return prev
const preservedDetail = prev.detail
return {
...prev,
detail: detail
? {
...detail,
displayName: isUsefulCurrentUserName(preservedDetail?.displayName, username)
? preservedDetail!.displayName
: detail.displayName,
alias: preservedDetail?.alias || detail.alias,
avatarUrl: preservedDetail?.avatarUrl || detail.avatarUrl
}
: preservedDetail,
detailError,
detailLoading: false,
momentsLoading: false,
momentPreviews
}
})
}, [])
// 消息右键菜单处理 // 消息右键菜单处理
const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => { const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => {
e.preventDefault() e.preventDefault()
@@ -7133,10 +7377,97 @@ function ChatPage(props: ChatPageProps) {
}) })
}, [clampContextMenuPosition]) }, [clampContextMenuPosition])
const handleAvatarContextMenu = useCallback((e: React.MouseEvent, message: Message, profile: MessageAvatarProfile) => {
e.preventDefault()
e.stopPropagation()
setContextMenu(null)
const cachedSelfProfile = profile.isSelf ? readCachedCurrentUserProfile(myWxid || profile.username) : null
const normalizedUsername = normalizeSearchIdentityText(
profile.isSelf
? (cachedSelfProfile?.wxid || myWxid || profile.username)
: profile.username
) || String(profile.username || '').trim()
const username = normalizedUsername || (profile.isSelf ? 'self' : undefined)
const canOpenMoments = Boolean(username && username !== 'self' && isSingleContactSession(username))
const selfDisplayName = isUsefulCurrentUserName(cachedSelfProfile?.displayName, username)
? cachedSelfProfile!.displayName
: undefined
const selfAlias = isUsefulCurrentUserName(cachedSelfProfile?.alias, username)
? cachedSelfProfile!.alias
: undefined
const displayName = profile.isSelf
? (selfDisplayName || selfAlias || profile.displayName || username || '微信用户')
: (normalizeSearchIdentityText(profile.displayName) || profile.displayName || username || '微信用户')
const avatarUrl = normalizeSearchAvatarUrl(profile.avatarUrl) ||
(profile.isSelf ? normalizeSearchAvatarUrl(cachedSelfProfile?.avatarUrl) || myAvatarUrl : undefined)
const sourceLabel = profile.isSelf
? '我发送的消息'
: profile.isGroupMember
? (currentSession?.displayName || currentSessionId || '群聊')
: '当前聊天'
const target: AvatarProfileTarget = {
...profile,
username,
displayName,
avatarUrl,
sourceLabel,
messageTime: message.createTime,
canOpenMoments
}
const initialDetail: AvatarProfileDetail | null = profile.isSelf && username && username !== 'self'
? {
wxid: username,
displayName,
alias: selfAlias,
avatarUrl
}
: null
const nextPos = clampAvatarProfilePosition(e.clientX, e.clientY)
const requestSeq = ++avatarProfileRequestSeqRef.current
setAvatarProfileCard({
x: nextPos.x,
y: nextPos.y,
target,
detail: initialDetail,
detailLoading: Boolean(username && username !== 'self'),
detailError: null,
momentsLoading: canOpenMoments,
momentPreviews: []
})
void loadAvatarProfileExtras(target, requestSeq)
}, [
clampAvatarProfilePosition,
currentSession?.displayName,
currentSessionId,
loadAvatarProfileExtras,
myAvatarUrl,
myWxid
])
const closeAvatarProfileCard = useCallback(() => {
avatarProfileRequestSeqRef.current += 1
setAvatarProfileCard(null)
}, [])
const openAvatarProfileMoments = useCallback(() => {
if (!avatarProfileCard?.target.canOpenMoments || !avatarProfileCard.target.username) return
const detail = avatarProfileCard.detail
setChatSnsTimelineTarget({
username: avatarProfileCard.target.username,
displayName: detail?.remark || detail?.nickName || detail?.displayName || avatarProfileCard.target.displayName || avatarProfileCard.target.username,
avatarUrl: detail?.avatarUrl || avatarProfileCard.target.avatarUrl
})
closeAvatarProfileCard()
}, [avatarProfileCard, closeAvatarProfileCard])
// 关闭右键菜单 // 关闭右键菜单
useEffect(() => { useEffect(() => {
const handleClick = () => { const handleClick = () => {
setContextMenu(null) setContextMenu(null)
setAvatarProfileCard(null)
} }
window.addEventListener('click', handleClick) window.addEventListener('click', handleClick)
return () => { return () => {
@@ -7358,6 +7689,16 @@ function ChatPage(props: ChatPageProps) {
const isSystem = isSystemMessage(msg.localType) const isSystem = isSystemMessage(msg.localType)
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const messageKey = getMessageKey(msg) const messageKey = getMessageKey(msg)
const senderUsername = String(msg.senderUsername || '').trim()
const matchedGroupMember = isCurrentSessionGroup && senderUsername
? groupPanelMembers.find((member) => (
isSameQuotedSenderIdentity(member.username, senderUsername) ||
isSameQuotedSenderIdentity(member.alias, senderUsername)
))
: undefined
const groupSenderDisplayName = matchedGroupMember
? resolveQuotedGroupMemberDisplayName(matchedGroupMember)
: undefined
return ( return (
<div className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}> <div className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
@@ -7373,10 +7714,12 @@ function ChatPage(props: ChatPageProps) {
myAvatarUrl={myAvatarUrl} myAvatarUrl={myAvatarUrl}
myWxid={myWxid} myWxid={myWxid}
isGroupChat={isCurrentSessionGroup} isGroupChat={isCurrentSessionGroup}
groupSenderDisplayName={groupSenderDisplayName}
quoteLayout={quoteLayout} quoteLayout={quoteLayout}
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled} autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
onRequireModelDownload={handleRequireModelDownload} onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onAvatarContextMenu={handleAvatarContextMenu}
onJumpToQuotedMessage={handleJumpToQuotedMessage} onJumpToQuotedMessage={handleJumpToQuotedMessage}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
messageKey={messageKey} messageKey={messageKey}
@@ -7396,10 +7739,12 @@ function ChatPage(props: ChatPageProps) {
myAvatarUrl, myAvatarUrl,
myWxid, myWxid,
isCurrentSessionGroup, isCurrentSessionGroup,
groupPanelMembers,
quoteLayout, quoteLayout,
autoTranscribeVoiceEnabled, autoTranscribeVoiceEnabled,
handleRequireModelDownload, handleRequireModelDownload,
handleContextMenu, handleContextMenu,
handleAvatarContextMenu,
handleJumpToQuotedMessage, handleJumpToQuotedMessage,
isSelectionMode, isSelectionMode,
selectedMessages, selectedMessages,
@@ -8725,6 +9070,23 @@ function ChatPage(props: ChatPageProps) {
document.body document.body
)} )}
{avatarProfileCard && createPortal(
<>
<div
className="avatar-profile-overlay"
onClick={closeAvatarProfileCard}
/>
<AvatarProfileCard
card={avatarProfileCard}
copiedField={copiedField}
onClose={closeAvatarProfileCard}
onOpenMoments={openAvatarProfileMoments}
onCopy={(text, field) => void handleCopyField(text, field)}
/>
</>,
document.body
)}
{/* 消息信息弹窗 */} {/* 消息信息弹窗 */}
{showMessageInfo && createPortal( {showMessageInfo && createPortal(
<div className="message-info-overlay" onClick={() => setShowMessageInfo(null)}> <div className="message-info-overlay" onClick={() => setShowMessageInfo(null)}>
@@ -9127,7 +9489,7 @@ type SharedImageDecryptResult = {
failureKind?: 'not_found' | 'decrypt_failed' failureKind?: 'not_found' | 'decrypt_failed'
} }
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>() const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: string }>({ const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: string; updatedAt?: number }>({
maxEntries: SENDER_AVATAR_CACHE_MAX_ENTRIES maxEntries: SENDER_AVATAR_CACHE_MAX_ENTRIES
}) })
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>() const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
@@ -9397,6 +9759,146 @@ function MessageInsightControl({
) )
} }
function AvatarProfileMomentThumb({ preview }: { preview: AvatarProfileMomentPreview }) {
if (preview.kind === 'video') {
return <video src={preview.src} muted preload="metadata" playsInline />
}
return <img src={preview.src} alt="" loading="lazy" referrerPolicy="no-referrer" />
}
function AvatarProfileCard({
card,
copiedField,
onClose,
onOpenMoments,
onCopy
}: {
card: AvatarProfileCardState
copiedField: string | null
onClose: () => void
onOpenMoments: () => void
onCopy: (text: string, field: string) => void
}) {
const { target, detail } = card
const username = detail?.wxid || target.username || ''
const displayName = detail?.remark || detail?.nickName || detail?.displayName || target.displayName || username || '微信用户'
const nickname = detail?.nickName && detail.nickName !== displayName ? detail.nickName : ''
const alias = detail?.alias || ''
const avatarUrl = detail?.avatarUrl || target.avatarUrl
const momentSummaryText = card.momentsLoading
? '加载最近动态...'
: card.momentPreviews.length > 0
? `最近 ${card.momentPreviews.length} 个媒体`
: (target.canOpenMoments ? '暂无可预览媒体' : '不支持朋友圈')
const messageDate = target.messageTime ? formatYmdHmDateTimeFromSeconds(target.messageTime) : ''
const profileRows = [
nickname ? { label: '昵称', value: nickname } : null,
detail?.remark ? { label: '备注', value: detail.remark } : null,
alias ? { label: '微信号', value: alias, copyField: 'avatarProfileAlias' } : null,
username ? { label: '微信ID', value: username, copyField: 'avatarProfileWxid' } : null
].filter(Boolean) as Array<{ label: string; value: string; copyField?: string }>
const moreRows = [
target.sourceLabel ? { label: '来源', value: target.sourceLabel } : null,
messageDate ? { label: '消息时间', value: messageDate } : null,
Number.isFinite(detail?.messageCount)
? { label: '聊天记录', value: `${Number(detail?.messageCount || 0).toLocaleString('zh-CN')} 条` }
: null
].filter(Boolean) as Array<{ label: string; value: string }>
return (
<div
className="avatar-profile-card"
style={{ top: card.y, left: card.x }}
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-label="个人信息"
>
<button className="avatar-profile-close" type="button" onClick={onClose} title="关闭">
<X size={15} />
</button>
<div className="avatar-profile-hero">
<Avatar src={avatarUrl} name={displayName} size={58} className="avatar-profile-photo" lazy={false} />
<div className="avatar-profile-title">
<h3>{displayName}</h3>
<p>{target.isSelf ? '我' : (target.isGroupMember ? '群聊成员' : '聊天联系人')}</p>
</div>
</div>
<div className="avatar-profile-section">
{card.detailLoading && profileRows.length === 0 ? (
<div className="avatar-profile-loading">
<Loader2 size={14} className="spin" />
<span>正在补全资料...</span>
</div>
) : profileRows.length > 0 ? (
profileRows.map(row => (
<div className="avatar-profile-row" key={row.label}>
<span>{row.label}</span>
<strong title={row.value}>{row.value}</strong>
{row.copyField && (
<button
type="button"
className="avatar-profile-copy"
onClick={() => onCopy(row.value, row.copyField!)}
title="复制"
>
{copiedField === row.copyField ? <Check size={12} /> : <Copy size={12} />}
</button>
)}
</div>
))
) : (
<div className="avatar-profile-empty">暂无更多资料</div>
)}
{card.detailError && <div className="avatar-profile-error">{card.detailError}</div>}
</div>
<button
className={`avatar-profile-moments ${target.canOpenMoments ? '' : 'disabled'}`}
type="button"
onClick={target.canOpenMoments ? onOpenMoments : undefined}
disabled={!target.canOpenMoments}
>
<div className="avatar-profile-moments-head">
<span>朋友圈</span>
<strong>{momentSummaryText}</strong>
</div>
{card.momentsLoading ? (
<div className="avatar-profile-moments-loading">
<Loader2 size={14} className="spin" />
</div>
) : card.momentPreviews.length > 0 ? (
<div className="avatar-profile-moment-strip">
{card.momentPreviews.slice(0, 5).map(preview => (
<div className="avatar-profile-moment-thumb" key={preview.id}>
<AvatarProfileMomentThumb preview={preview} />
</div>
))}
</div>
) : (
<div className="avatar-profile-moments-empty">
{target.canOpenMoments ? '最近动态里暂未找到图片或视频' : '群聊和公众号不显示朋友圈'}
</div>
)}
</button>
{moreRows.length > 0 && (
<div className="avatar-profile-section compact">
{moreRows.map(row => (
<div className="avatar-profile-row" key={row.label}>
<span>{row.label}</span>
<strong title={row.value}>{row.value}</strong>
</div>
))}
</div>
)}
</div>
)
}
function MessageBubble({ function MessageBubble({
message, message,
messageKey, messageKey,
@@ -9405,10 +9907,12 @@ function MessageBubble({
myAvatarUrl, myAvatarUrl,
myWxid, myWxid,
isGroupChat, isGroupChat,
groupSenderDisplayName,
quoteLayout, quoteLayout,
autoTranscribeVoiceEnabled, autoTranscribeVoiceEnabled,
onRequireModelDownload, onRequireModelDownload,
onContextMenu, onContextMenu,
onAvatarContextMenu,
onJumpToQuotedMessage, onJumpToQuotedMessage,
isSelectionMode, isSelectionMode,
isSelected, isSelected,
@@ -9423,10 +9927,12 @@ function MessageBubble({
myAvatarUrl?: string; myAvatarUrl?: string;
myWxid?: string; myWxid?: string;
isGroupChat?: boolean; isGroupChat?: boolean;
groupSenderDisplayName?: string;
quoteLayout: configService.QuoteLayout; quoteLayout: configService.QuoteLayout;
autoTranscribeVoiceEnabled?: boolean; autoTranscribeVoiceEnabled?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void; onRequireModelDownload?: (sessionId: string, messageId: string) => void;
onContextMenu?: (e: React.MouseEvent, message: Message) => void; onContextMenu?: (e: React.MouseEvent, message: Message) => void;
onAvatarContextMenu?: (e: React.MouseEvent, message: Message, profile: MessageAvatarProfile) => void;
onJumpToQuotedMessage?: (target: QuotedMessageJumpTarget) => void; onJumpToQuotedMessage?: (target: QuotedMessageJumpTarget) => void;
isSelectionMode?: boolean; isSelectionMode?: boolean;
isSelected?: boolean; isSelected?: boolean;
@@ -9753,9 +10259,16 @@ function MessageBubble({
// 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取) // 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取)
useEffect(() => { useEffect(() => {
const sender = String(message.senderUsername || '').trim() const sender = String(message.senderUsername || '').trim()
const cached = sender ? senderAvatarCache.get(sender) : undefined const profileCacheKey = sender
setSenderAvatarUrl(cached?.avatarUrl || message.senderAvatarUrl || undefined) ? `${isGroupChat ? session.username : 'contact'}::${sender}`
setSenderName(cached?.displayName || message.senderDisplayName || undefined) : ''
const cached = profileCacheKey ? senderAvatarCache.get(profileCacheKey) : undefined
const cachedFresh = Boolean(
cached &&
(!isGroupChat || Date.now() - (cached.updatedAt || 0) < SENDER_AVATAR_CACHE_TTL_MS)
)
setSenderAvatarUrl((cachedFresh ? cached?.avatarUrl : undefined) || message.senderAvatarUrl || undefined)
setSenderName(groupSenderDisplayName || (cachedFresh ? cached?.displayName : undefined) || message.senderDisplayName || undefined)
if (!sender || !(isGroupChat || (isSent && !myAvatarUrl))) return if (!sender || !(isGroupChat || (isSent && !myAvatarUrl))) return
@@ -9769,14 +10282,14 @@ function MessageBubble({
if (result.displayName) setSenderName(result.displayName) if (result.displayName) setSenderName(result.displayName)
} }
if (cached) { if (cachedFresh && cached) {
applyProfile(cached) applyProfile(cached)
return () => { return () => {
cancelled = true cancelled = true
} }
} }
const pending = senderAvatarLoading.get(sender) const pending = senderAvatarLoading.get(profileCacheKey)
if (pending) { if (pending) {
pending.then(applyProfile).catch(() => { }) pending.then(applyProfile).catch(() => { })
return () => { return () => {
@@ -9784,23 +10297,23 @@ function MessageBubble({
} }
} }
const request = window.electronAPI.chat.getContactAvatar(sender) const request = window.electronAPI.chat.getContactAvatar(sender, isGroupChat ? session.username : undefined)
senderAvatarLoading.set(sender, request) senderAvatarLoading.set(profileCacheKey, request)
request.then((result: { avatarUrl?: string; displayName?: string } | null) => { request.then((result: { avatarUrl?: string; displayName?: string } | null) => {
if (result) { if (result) {
senderAvatarCache.set(sender, result) senderAvatarCache.set(profileCacheKey, { ...result, updatedAt: Date.now() })
} }
applyProfile(result) applyProfile(result)
}).catch(() => { }).finally(() => { }).catch(() => { }).finally(() => {
if (senderAvatarLoading.get(sender) === request) { if (senderAvatarLoading.get(profileCacheKey) === request) {
senderAvatarLoading.delete(sender) senderAvatarLoading.delete(profileCacheKey)
} }
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [isGroupChat, isSent, message.senderAvatarUrl, message.senderDisplayName, message.senderUsername, myAvatarUrl]) }, [groupSenderDisplayName, isGroupChat, isSent, message.senderAvatarUrl, message.senderDisplayName, message.senderUsername, myAvatarUrl, session.username])
// 解析转账消息的付款方和收款方显示名称 // 解析转账消息的付款方和收款方显示名称
useEffect(() => { useEffect(() => {
@@ -10660,11 +11173,38 @@ function MessageBubble({
// - 群聊中对方发的:使用发送者头像 // - 群聊中对方发的:使用发送者头像
// - 私聊中对方发的:使用会话头像 // - 私聊中对方发的:使用会话头像
const fallbackSenderName = String(message.senderDisplayName || message.senderUsername || '').trim() || undefined const fallbackSenderName = String(message.senderDisplayName || message.senderUsername || '').trim() || undefined
const resolvedSenderName = senderName || fallbackSenderName const resolvedSenderName = groupSenderDisplayName || senderName || fallbackSenderName
const resolvedSenderAvatarUrl = senderAvatarUrl || message.senderAvatarUrl const resolvedSenderAvatarUrl = senderAvatarUrl || message.senderAvatarUrl
const avatarUrl = isSent const avatarUrl = isSent
? (myAvatarUrl || resolvedSenderAvatarUrl) ? (myAvatarUrl || resolvedSenderAvatarUrl)
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl) : (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
const avatarProfile = useMemo<MessageAvatarProfile>(() => {
const username = isSent
? (myWxid || message.senderUsername || session.selfWxid || undefined)
: (isGroupChat ? (message.senderUsername || undefined) : session.username)
return {
username: normalizeSearchIdentityText(username) || username,
displayName: isSent
? '我'
: (isGroupChat
? (resolvedSenderName || message.senderDisplayName || message.senderUsername || '群成员')
: (session.displayName || session.username || '微信用户')),
avatarUrl,
isSelf: isSent,
isGroupMember: Boolean(isGroupChat && !isSent)
}
}, [
avatarUrl,
isGroupChat,
isSent,
message.senderDisplayName,
message.senderUsername,
myWxid,
resolvedSenderName,
session.displayName,
session.selfWxid,
session.username
])
const canShowMessageInsight = Boolean( const canShowMessageInsight = Boolean(
aiMessageInsightEnabled && aiMessageInsightEnabled &&
!isSent && !isSent &&
@@ -12037,9 +12577,11 @@ function MessageBubble({
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
isGroupChat={isGroupChat} isGroupChat={isGroupChat}
resolvedSenderName={resolvedSenderName} resolvedSenderName={resolvedSenderName}
avatarProfile={avatarProfile}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
isSelected={isSelected} isSelected={isSelected}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
onAvatarContextMenu={onAvatarContextMenu}
onToggleSelection={onToggleSelection} onToggleSelection={onToggleSelection}
actionNode={messageInsightControl} actionNode={messageInsightControl}
portal={systemAlertPortal} portal={systemAlertPortal}
@@ -12056,12 +12598,14 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
if (prevProps.myWxid !== nextProps.myWxid) return false if (prevProps.myWxid !== nextProps.myWxid) return false
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
if (prevProps.groupSenderDisplayName !== nextProps.groupSenderDisplayName) return false
if (prevProps.quoteLayout !== nextProps.quoteLayout) return false if (prevProps.quoteLayout !== nextProps.quoteLayout) return false
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false
if (prevProps.isSelected !== nextProps.isSelected) return false if (prevProps.isSelected !== nextProps.isSelected) return false
if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
if (prevProps.onAvatarContextMenu !== nextProps.onAvatarContextMenu) return false
if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
if (prevProps.aiMessageInsightEnabled !== nextProps.aiMessageInsightEnabled) return false if (prevProps.aiMessageInsightEnabled !== nextProps.aiMessageInsightEnabled) return false
+46 -16
View File
@@ -20,6 +20,7 @@ const VIRTUAL_ROW_HEIGHT = 64
const VIRTUAL_OVERSCAN = 10 const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000 const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const CONTACTS_CACHE_SCHEMA_VERSION = 'contacts-v5'
interface ContactsLoadSession { interface ContactsLoadSession {
requestId: string requestId: string
@@ -49,8 +50,10 @@ function ContactsPage() {
const [contactTypes, setContactTypes] = useState({ const [contactTypes, setContactTypes] = useState({
friends: true, friends: true,
groups: false, groups: false,
officials: false, officialSubscriptions: false,
deletedFriends: false officialServices: false,
deletedFriends: false,
blocked: false
}) })
// 导出模式与查看详情 // 导出模式与查看详情
@@ -103,8 +106,8 @@ function ContactsPage() {
configService.getMyWxid() configService.getMyWxid()
]) ])
const scopeKey = dbPath || myWxid const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}` ? `${dbPath || ''}::${myWxid || ''}::${CONTACTS_CACHE_SCHEMA_VERSION}`
: 'default' : `default::${CONTACTS_CACHE_SCHEMA_VERSION}`
contactsCacheScopeRef.current = scopeKey contactsCacheScopeRef.current = scopeKey
return scopeKey return scopeKey
}, []) }, [])
@@ -402,7 +405,9 @@ function ContactsPage() {
description: contact.description, description: contact.description,
detailDescription: contact.detailDescription, detailDescription: contact.detailDescription,
region: contact.region, region: contact.region,
type: contact.type type: contact.type,
officialAccountKind: contact.officialAccountKind,
officialAccountType: contact.officialAccountType
})) }))
).catch((error) => { ).catch((error) => {
console.error('写入通讯录缓存失败:', error) console.error('写入通讯录缓存失败:', error)
@@ -550,8 +555,12 @@ function ContactsPage() {
let filtered = contacts.filter(contact => { let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) return false if (contact.type === 'friend' && !contactTypes.friends) return false
if (contact.type === 'group' && !contactTypes.groups) return false if (contact.type === 'group' && !contactTypes.groups) return false
if (contact.type === 'official' && !contactTypes.officials) return false if (contact.type === 'official') {
if (contact.officialAccountKind === 'service') return contactTypes.officialServices
return contactTypes.officialSubscriptions
}
if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false
if (contact.type === 'blocked' && !contactTypes.blocked) return false
return true return true
}) })
@@ -668,7 +677,7 @@ function ContactsPage() {
? { key: 'signature', label: '个性签名', value: selectedContact.detailDescription } ? { key: 'signature', label: '个性签名', value: selectedContact.detailDescription }
: null, : null,
selectedContact.region ? { key: 'region', label: '地区', value: selectedContact.region } : null, selectedContact.region ? { key: 'region', label: '地区', value: selectedContact.region } : null,
{ key: 'type', label: '类型', value: getContactTypeName(selectedContact.type) } { key: 'type', label: '类型', value: getContactTypeName(selectedContact) }
].filter((row): row is { key: string; label: string; value: string } => Boolean(row && row.value)) ].filter((row): row is { key: string; label: string; value: string } => Boolean(row && row.value))
}, [selectedContact]) }, [selectedContact])
@@ -793,16 +802,24 @@ function ContactsPage() {
case 'group': return <Users size={14} /> case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} /> case 'official': return <MessageSquare size={14} />
case 'former_friend': return <UserX size={14} /> case 'former_friend': return <UserX size={14} />
case 'blocked': return <UserX size={14} />
default: return <User size={14} /> default: return <User size={14} />
} }
} }
function getContactTypeName(type: string) { function getContactTypeName(contactOrType: ContactInfo | string) {
const type = typeof contactOrType === 'string' ? contactOrType : contactOrType.type
if (typeof contactOrType !== 'string' && type === 'official') {
if (contactOrType.officialAccountKind === 'service') return '服务号'
if (contactOrType.officialAccountKind === 'enterprise') return '企业号'
return '公众号'
}
switch (type) { switch (type) {
case 'friend': return '好友' case 'friend': return '好友'
case 'group': return '群聊' case 'group': return '群聊'
case 'official': return '公众号' case 'official': return '公众号'
case 'former_friend': return '曾经的好友' case 'former_friend': return '曾经的好友'
case 'blocked': return '黑名单'
default: return '其他' default: return '其他'
} }
} }
@@ -840,7 +857,8 @@ function ContactsPage() {
contactTypes: { contactTypes: {
friends: contactTypes.friends, friends: contactTypes.friends,
groups: contactTypes.groups, groups: contactTypes.groups,
officials: contactTypes.officials officials: contactTypes.officialSubscriptions || contactTypes.officialServices,
blocked: contactTypes.blocked
}, },
selectedUsernames: Array.from(selectedUsernames) selectedUsernames: Array.from(selectedUsernames)
} }
@@ -918,11 +936,17 @@ function ContactsPage() {
<span className="chip-label">群聊</span> <span className="chip-label">群聊</span>
<span className="chip-count">{contactTypeCounts.groups}</span> <span className="chip-count">{contactTypeCounts.groups}</span>
</label> </label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.officialSubscriptions ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} /> <input type="checkbox" checked={contactTypes.officialSubscriptions} onChange={e => setContactTypes({ ...contactTypes, officialSubscriptions: e.target.checked })} />
<MessageSquare size={16} /> <MessageSquare size={16} />
<span className="chip-label">公众号</span> <span className="chip-label">公众号</span>
<span className="chip-count">{contactTypeCounts.officials}</span> <span className="chip-count">{contactTypeCounts.officialSubscriptions}</span>
</label>
<label className={`filter-chip ${contactTypes.officialServices ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officialServices} onChange={e => setContactTypes({ ...contactTypes, officialServices: e.target.checked })} />
<MessageSquare size={16} />
<span className="chip-label">服务号</span>
<span className="chip-count">{contactTypeCounts.officialServices}</span>
</label> </label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} /> <input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
@@ -930,6 +954,12 @@ function ContactsPage() {
<span className="chip-label">曾经的好友</span> <span className="chip-label">曾经的好友</span>
<span className="chip-count">{contactTypeCounts.deletedFriends}</span> <span className="chip-count">{contactTypeCounts.deletedFriends}</span>
</label> </label>
<label className={`filter-chip ${contactTypes.blocked ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.blocked} onChange={e => setContactTypes({ ...contactTypes, blocked: e.target.checked })} />
<UserX size={16} />
<span className="chip-label">黑名单</span>
<span className="chip-count">{contactTypeCounts.blocked}</span>
</label>
</div> </div>
@@ -1038,9 +1068,9 @@ function ContactsPage() {
<div className="contact-remark">备注: {contact.remark}</div> <div className="contact-remark">备注: {contact.remark}</div>
)} )}
</div> </div>
<div className={`contact-type ${contact.type}`}> <div className={`contact-type ${contact.type} ${contact.officialAccountKind || ''}`}>
{getContactTypeIcon(contact.type)} {getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span> <span>{getContactTypeName(contact)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1138,9 +1168,9 @@ function ContactsPage() {
)} )}
</div> </div>
<div className="contact-detail-heading"> <div className="contact-detail-heading">
<div className={`contact-type detail-type ${selectedContact.type}`}> <div className={`contact-type detail-type ${selectedContact.type} ${selectedContact.officialAccountKind || ''}`}>
{getContactTypeIcon(selectedContact.type)} {getContactTypeIcon(selectedContact.type)}
<span>{getContactTypeName(selectedContact.type)}</span> <span>{getContactTypeName(selectedContact)}</span>
</div> </div>
<h2>{selectedContactTitle}</h2> <h2>{selectedContactTitle}</h2>
{selectedContactSubtitle && <p>{selectedContactSubtitle}</p>} {selectedContactSubtitle && <p>{selectedContactSubtitle}</p>}
+15 -5
View File
@@ -1005,7 +1005,9 @@ export interface ContactsListCacheContact {
description?: string description?: string
detailDescription?: string detailDescription?: string
region?: string region?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
officialAccountKind?: 'subscription' | 'service' | 'enterprise' | 'unknown'
officialAccountType?: number
} }
export interface ContactsListCacheItem { export interface ContactsListCacheItem {
@@ -1532,9 +1534,13 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
description: typeof item.description === 'string' ? (item.description.trim() || undefined) : undefined, description: typeof item.description === 'string' ? (item.description.trim() || undefined) : undefined,
detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined, detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined,
region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined, region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined,
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other') type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'blocked' || type === 'other')
? type ? type
: 'other' : 'other',
officialAccountKind: item.officialAccountKind === 'subscription' || item.officialAccountKind === 'service' || item.officialAccountKind === 'enterprise' || item.officialAccountKind === 'unknown'
? item.officialAccountKind
: undefined,
officialAccountType: Number.isFinite(Number(item.officialAccountType)) ? Math.floor(Number(item.officialAccountType)) : undefined
}) })
} }
@@ -1557,7 +1563,7 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
if (!username) continue if (!username) continue
const displayName = String(contact?.displayName || username) const displayName = String(contact?.displayName || username)
const type = contact?.type || 'other' const type = contact?.type || 'other'
if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') { if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'blocked' && type !== 'other') {
continue continue
} }
normalized.push({ normalized.push({
@@ -1572,7 +1578,11 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
description: contact?.description ? (String(contact.description).trim() || undefined) : undefined, description: contact?.description ? (String(contact.description).trim() || undefined) : undefined,
detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined, detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined,
region: contact?.region ? (String(contact.region).trim() || undefined) : undefined, region: contact?.region ? (String(contact.region).trim() || undefined) : undefined,
type type,
officialAccountKind: contact?.officialAccountKind === 'subscription' || contact?.officialAccountKind === 'service' || contact?.officialAccountKind === 'enterprise' || contact?.officialAccountKind === 'unknown'
? contact.officialAccountKind
: undefined,
officialAccountType: Number.isFinite(Number(contact?.officialAccountType)) ? Math.floor(Number(contact.officialAccountType)) : undefined
}) })
} }
+36 -4
View File
@@ -5,21 +5,36 @@ export interface ContactTypeTabCounts {
private: number private: number
group: number group: number
official: number official: number
official_subscription: number
official_service: number
official_enterprise: number
official_unknown: number
former_friend: number former_friend: number
blocked: number
} }
export interface ContactTypeCardCounts { export interface ContactTypeCardCounts {
friends: number friends: number
groups: number groups: number
officials: number officials: number
officialSubscriptions: number
officialServices: number
officialEnterprises: number
officialUnknown: number
deletedFriends: number deletedFriends: number
blocked: number
} }
const emptyTabCounts: ContactTypeTabCounts = { const emptyTabCounts: ContactTypeTabCounts = {
private: 0, private: 0,
group: 0, group: 0,
official: 0, official: 0,
former_friend: 0 official_subscription: 0,
official_service: 0,
official_enterprise: 0,
official_unknown: 0,
former_friend: 0,
blocked: 0
} }
let inflightPromise: Promise<ContactTypeTabCounts> | null = null let inflightPromise: Promise<ContactTypeTabCounts> | null = null
@@ -29,7 +44,12 @@ const normalizeCounts = (counts?: Partial<ContactTypeTabCounts> | null): Contact
private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0, private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0,
group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0, group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0,
official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0, official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0,
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0 official_subscription: Number.isFinite(counts?.official_subscription) ? Math.max(0, Math.floor(Number(counts?.official_subscription))) : 0,
official_service: Number.isFinite(counts?.official_service) ? Math.max(0, Math.floor(Number(counts?.official_service))) : 0,
official_enterprise: Number.isFinite(counts?.official_enterprise) ? Math.max(0, Math.floor(Number(counts?.official_enterprise))) : 0,
official_unknown: Number.isFinite(counts?.official_unknown) ? Math.max(0, Math.floor(Number(counts?.official_unknown))) : 0,
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0,
blocked: Number.isFinite(counts?.blocked) ? Math.max(0, Math.floor(Number(counts?.blocked))) : 0
} }
} }
@@ -38,8 +58,15 @@ export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): Con
for (const contact of contacts || []) { for (const contact of contacts || []) {
if (contact.type === 'friend') next.private += 1 if (contact.type === 'friend') next.private += 1
if (contact.type === 'group') next.group += 1 if (contact.type === 'group') next.group += 1
if (contact.type === 'official') next.official += 1 if (contact.type === 'official') {
next.official += 1
if (contact.officialAccountKind === 'subscription') next.official_subscription += 1
else if (contact.officialAccountKind === 'service') next.official_service += 1
else if (contact.officialAccountKind === 'enterprise') next.official_enterprise += 1
else next.official_unknown += 1
}
if (contact.type === 'former_friend') next.former_friend += 1 if (contact.type === 'former_friend') next.former_friend += 1
if (contact.type === 'blocked') next.blocked += 1
} }
return next return next
} }
@@ -49,7 +76,12 @@ export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTy
friends: counts.private, friends: counts.private,
groups: counts.group, groups: counts.group,
officials: counts.official, officials: counts.official,
deletedFriends: counts.former_friend officialSubscriptions: counts.official_subscription,
officialServices: counts.official_service,
officialEnterprises: counts.official_enterprise,
officialUnknown: counts.official_unknown,
deletedFriends: counts.former_friend,
blocked: counts.blocked
} }
} }
+5 -2
View File
@@ -563,6 +563,7 @@ export interface ElectronAPI {
group: number group: number
official: number official: number
former_friend: number former_friend: number
blocked?: number
} }
error?: string error?: string
}> }>
@@ -573,6 +574,7 @@ export interface ElectronAPI {
group: number group: number
official: number official: number
former_friend: number former_friend: number
blocked?: number
} }
error?: string error?: string
}> }>
@@ -626,7 +628,7 @@ export interface ElectronAPI {
error?: string error?: string
}> }>
getContact: (username: string) => Promise<Contact | null> getContact: (username: string) => Promise<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> getContactAvatar: (username: string, chatroomId?: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }> updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }> deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
checkAntiRevokeTriggers: (sessionIds: string[]) => Promise<{ checkAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
@@ -900,6 +902,7 @@ export interface ElectronAPI {
} }
biz: { biz: {
listAccounts: (account?: string) => Promise<any[]> listAccounts: (account?: string) => Promise<any[]>
listAccountHealth: (account?: string) => Promise<any>
listMessages: (username: string, account?: string, limit?: number, offset?: number) => Promise<any[]> listMessages: (username: string, account?: string, limit?: number, offset?: number) => Promise<any[]>
listPayRecords: (account?: string, limit?: number, offset?: number) => Promise<any[]> listPayRecords: (account?: string, limit?: number, offset?: number) => Promise<any[]>
} }
@@ -1505,7 +1508,7 @@ export interface ElectronAPI {
success: boolean success: boolean
error?: string error?: string
}> }>
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean }; selectedUsernames?: string[] }) => Promise<{ exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean; blocked?: boolean }; selectedUsernames?: string[] }) => Promise<{
success: boolean success: boolean
successCount?: number successCount?: number
error?: string error?: string
+3 -1
View File
@@ -42,7 +42,9 @@ export interface ContactInfo {
detailDescription?: string detailDescription?: string
region?: string region?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
officialAccountKind?: 'subscription' | 'service' | 'enterprise' | 'unknown'
officialAccountType?: number
} }
// 消息 // 消息