#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'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
}
const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
@@ -2781,8 +2781,8 @@ function registerIpcHandlers() {
})
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
return await chatService.getContactAvatar(username)
ipcMain.handle('chat:getContactAvatar', async (_, username: string, chatroomId?: string) => {
return await chatService.getContactAvatar(username, chatroomId)
})
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'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
}
) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
@@ -224,7 +224,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
}) =>
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit, cursor),
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) =>
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
@@ -582,6 +582,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
biz: {
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) =>
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
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 { ConfigService } from './config'
import { chatService, Message } from './chatService'
import { chatService } from './chatService'
import { ipcMain } from 'electron'
import { createHash } from 'crypto'
export interface BizAccount {
username: string
@@ -14,6 +11,24 @@ export interface BizAccount {
last_time: number
formatted_last_time: string
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 {
@@ -39,11 +54,48 @@ export interface BizPayRecord {
export class BizService {
private configService: ConfigService
private readonly builtinOfficialHelpers = new Set([
'gh_f0a92aa7146c' // 微信收款助手不出现在通讯录的公众号/服务号分组里
])
constructor() {
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 {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
const match = regex.exec(xml)
@@ -99,10 +151,9 @@ export class BizService {
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const root = this.configService.get('dbPath')
const myWxid = this.configService.getMyWxidCleaned()
const accountWxid = account || myWxid
if (!root || !accountWxid) return []
if (!accountWxid) return []
const bizLatestTime: Record<string, number> = {}
const bizUnreadCount: Record<string, number> = {}
@@ -129,24 +180,6 @@ export class BizService {
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. 组装数据
const result: BizAccount[] = officialContacts.map(contact => {
const uname = contact.username
@@ -158,20 +191,19 @@ export class BizService {
avatar: info?.avatarUrl || '',
type: 0,
last_time: lastTime,
formatted_last_time: formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0
formatted_last_time: this.formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0,
status: 'active',
stale_level: 'none'
}
})
// 5. 补充公众号类型 (订阅号/服务号)
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
if (existsSync(contactDbPath)) {
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
if (bizInfoRes.success && bizInfoRes.rows) {
const typeMap: Record<string, number> = {}
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]
}
const bizInfoRes = await wcdbService.execQuery('contact', null, 'SELECT username, type FROM biz_info')
if (bizInfoRes.success && bizInfoRes.rows) {
const typeMap: Record<string, number> = {}
for (const r of bizInfoRes.rows as Array<Record<string, any>>) typeMap[String(r.username || '')] = this.toInt(r.type)
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
}
// 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[]> {
try {
// 仅保留核心路径:利用 chatService 的自动路由能力
@@ -242,6 +399,7 @@ export class BizService {
registerHandlers() {
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:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
}
+248 -26
View File
@@ -170,9 +170,16 @@ export interface ContactInfo {
detailDescription?: string
region?: 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 {
lite?: boolean
}
@@ -220,6 +227,7 @@ interface ExportTabCounts {
group: number
official: number
former_friend: number
blocked?: number
}
interface SessionDetailFast {
@@ -359,6 +367,8 @@ class ChatService {
private readonly messageCursorSessionLimit = 8
private avatarCache: Map<string, ContactCacheEntry>
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 contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService
@@ -1677,7 +1687,8 @@ class ChatService {
private: Number(result.counts.private || 0),
group: Number(result.counts.group || 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 }
@@ -2053,7 +2064,7 @@ class ChatService {
private getContactsCacheScope(): string {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const myWxid = String(this.configService.getMyWxidCleaned() || '').trim()
return `${dbPath}::${myWxid}`
return `${dbPath}::${myWxid}::${CONTACT_CLASSIFICATION_CACHE_VERSION}`
}
private cloneContacts(contacts: ContactInfo[]): ContactInfo[] {
@@ -2143,6 +2154,7 @@ class ChatService {
const transformStartedAt = Date.now()
const contacts: (ContactInfo & { lastContactTime: number })[] = []
let contactLabelNameMap = new Map<number, string>()
const officialAccountTypeMap = await this.getOfficialAccountTypeMap()
if (!isLiteMode) {
const labelMapStartedAt = Date.now()
contactLabelNameMap = await this.getContactLabelNameMap()
@@ -2153,11 +2165,18 @@ class ChatService {
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 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 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 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)) {
continue
}
@@ -2165,16 +2184,18 @@ class ChatService {
if (username.endsWith('@chatroom')) {
type = 'group'
} else if (username.startsWith('gh_')) {
} else if (username.startsWith('gh_') && localType === 1 && !BUILTIN_OFFICIAL_HELPER_USERNAMES.has(username)) {
type = 'official'
} else if (!username.startsWith('gh_') && (flag & 8) === 8) {
type = 'blocked'
} else if (isFormerFriendResidual) {
type = 'former_friend'
} else if (isOpenimEnterprise) {
type = 'friend'
} else if (isVisibleWeixinContact) {
type = 'friend'
} else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
type = 'friend'
} else if (localType === 0 && quanPin) {
type = 'former_friend'
} else {
continue
}
@@ -2184,6 +2205,7 @@ class ChatService {
const description = isLiteMode ? '' : this.getContactDescription(row)
const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
const region = isLiteMode ? '' : this.getContactRegion(row)
const officialAccountType = type === 'official' ? officialAccountTypeMap.get(username) : undefined
contacts.push({
username,
@@ -2197,6 +2219,8 @@ class ChatService {
region: region || undefined,
avatarUrl: undefined,
type,
officialAccountType,
officialAccountKind: type === 'official' ? this.getOfficialAccountKind(officialAccountType) : undefined,
lastContactTime: lastContactTimeMap.get(username) || 0
})
}
@@ -2572,6 +2596,7 @@ class ChatService {
if (normalized.length > 0) {
await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId)
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
}
return {
@@ -2657,6 +2682,7 @@ class ChatService {
if (normalized.length > 0) {
await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId)
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
}
return {
@@ -2824,6 +2850,7 @@ class ChatService {
if (fixPromises.length > 0) {
await Promise.allSettled(fixPromises)
}
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
return { success: true, messages: normalized }
} catch (e) {
@@ -2891,6 +2918,7 @@ class ChatService {
if (collected.length > 0) {
await this.repairEmojiMessages(collected)
await this.enrichGroupMessageSenderProfiles(collected, sessionId)
}
return { success: true, messages: collected }
}
@@ -3157,6 +3185,7 @@ class ChatService {
if (normalized.length > 0) {
await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId)
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
}
return {
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 {
for (const key of keys) {
if (row[key] !== undefined && row[key] !== null) return row[key]
@@ -3423,6 +3487,45 @@ class ChatService {
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 {
const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer'])
if (raw === undefined || raw === null) return null
@@ -3972,6 +4075,114 @@ class ChatService {
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): {
isSend: number | null
selfMatched: boolean
@@ -7596,45 +7807,56 @@ class ChatService {
/**
* 获取联系人头像和显示名称(用于群聊消息)
*/
async getContactAvatar(username: string): Promise<{ avatarUrl?: string; displayName?: string } | null> {
if (!username) return null
async getContactAvatar(username: string, chatroomId?: string): Promise<{ avatarUrl?: string; displayName?: string } | null> {
const normalizedUsername = String(username || '').trim()
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedUsername) return null
try {
const connectResult = await this.ensureConnected()
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 格式
const cachedAvatarUrl = this.shouldPersistAvatarUrl(cached?.avatarUrl) ? cached?.avatarUrl : undefined
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 avatarResult = await wcdbService.getAvatarUrls([username])
let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
const contact = await this.getContact(normalizedUsername)
const avatarResult = await wcdbService.getAvatarUrls([normalizedUsername])
let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[normalizedUsername] : undefined
if (!this.isValidAvatarUrl(avatarUrl)) {
avatarUrl = undefined
}
if (!avatarUrl) {
const headImageAvatars = await this.getAvatarsFromHeadImageDb([username])
const fallbackAvatarUrl = headImageAvatars[username]
const headImageAvatars = await this.getAvatarsFromHeadImageDb([normalizedUsername])
const fallbackAvatarUrl = headImageAvatars[normalizedUsername]
if (this.isValidAvatarUrl(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 = {
avatarUrl: this.shouldPersistAvatarUrl(avatarUrl)
? avatarUrl
: this.shouldPersistAvatarUrl(cached?.avatarUrl)
? cached?.avatarUrl
: undefined,
displayName,
avatarUrl: effectiveAvatarUrl,
displayName: contactDisplayName,
updatedAt: Date.now()
}
this.avatarCache.set(username, cacheEntry)
this.contactCacheService.setEntries({ [username]: cacheEntry })
return { avatarUrl, displayName }
this.avatarCache.set(normalizedUsername, cacheEntry)
this.contactCacheService.setEntries({ [normalizedUsername]: cacheEntry })
return { avatarUrl: effectiveAvatarUrl, displayName }
} catch {
return null
}
+15 -3
View File
@@ -9,6 +9,7 @@ interface ContactExportOptions {
friends: boolean
groups: boolean
officials: boolean
blocked?: boolean
}
selectedUsernames?: string[]
}
@@ -38,6 +39,7 @@ class ContactExportService {
if (c.type === 'friend' && !options.contactTypes.friends) return false
if (c.type === 'group' && !options.contactTypes.groups) return false
if (c.type === 'official' && !options.contactTypes.officials) return false
if (c.type === 'blocked' && !options.contactTypes.blocked) return false
return true
})
@@ -99,7 +101,10 @@ class ContactExportService {
signature: c.detailDescription,
detailDescription: c.detailDescription,
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')
@@ -120,7 +125,7 @@ class ContactExportService {
c.description || '',
c.detailDescription || '',
c.region || '',
this.getTypeLabel(c.type)
this.getTypeLabel(c)
])
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')
}
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) {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
case 'blocked': return '黑名单'
default: return '其他'
}
}
+20 -7
View File
@@ -1393,8 +1393,15 @@ export class ExportContext {
if (normalizedSessionId.startsWith('gh_')) return '公众号_'
const rawLocalType = contact?.local_type ?? contact?.localType ?? contact?.WCDB_CT_local_type;
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();
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 '曾经的好友_'
}
@@ -2912,9 +2919,13 @@ export class ExportContext {
destFileName = `${md5}${ext || '.gif'}`
} else if (kind === 'file') {
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 (kind === 'image' || kind === 'emoji' || kind === 'video') {
const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control, options)
@@ -3945,10 +3956,12 @@ export class ExportContext {
return null
}
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
const messageId = String(msg?.localId || Date.now())
const destFileName = `${messageId}_${safeBaseName}`
const destPath = path.join(fileDir, destFileName)
let destFileName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
let destPath = path.join(fileDir, destFileName)
if (this.resolveExportConflictStrategy(options) === 'rename') {
destPath = await reserveUniqueOutputPath(destPath, new Set<string>())
destFileName = path.basename(destPath)
}
const existedBeforeCopy = await pathExists(destPath)
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
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})`
}
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 = {
private: 0,
group: 0,
official: 0,
former_friend: 0
former_friend: 0,
blocked: 0
}
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
const builtinOfficialHelpers = new Set(['gh_f0a92aa7146c'])
for (const row of rows || []) {
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 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 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')) {
counts.group += 1
} else if (username.startsWith('gh_')) {
} else if (username.startsWith('gh_') && localType === 1 && !builtinOfficialHelpers.has(username)) {
counts.official += 1
} else if (localType === 1 && !excludeNames.has(username)) {
counts.private += 1
} else if (localType === 0 && quanPin) {
} else if (!username.startsWith('gh_') && (flag & 8) === 8) {
counts.blocked += 1
} else if (isFormerFriendResidual) {
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 未连接' }
const runFallback = async (reason: string) => {
const contactsResult = await this.getContactsCompact()
@@ -3494,7 +3506,8 @@ export class WcdbCore {
private: Number(raw.private || 0),
group: Number(raw.group || 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) {
+1 -1
View File
@@ -426,7 +426,7 @@ export class WcdbService {
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')
}
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;
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 {
padding: 20px;
text-align: center;
@@ -93,17 +150,28 @@
}
}
.biz-badge-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.biz-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
width: fit-content;
margin-top: 2px;
&.type-service { color: #07c160; background: rgba(7, 193, 96, 0.1); }
&.type-sub { color: var(--primary); background: var(--primary-light); }
&.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); }
&.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 { useThemeStore } from '../stores/themeStore';
import { Newspaper, MessageSquareOff } from 'lucide-react';
import { AlertTriangle, Clock, MessageSquareOff, Newspaper } from 'lucide-react';
import './BizPage.scss';
export interface BizAccount {
username: string;
name: string;
avatar: string;
type: string;
type: number | string;
last_time: number;
formatted_last_time: string;
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<{
onSelect: (account: BizAccount) => void;
selectedUsername?: string;
searchKeyword?: string;
}> = ({ onSelect, selectedUsername, searchKeyword }) => {
const [accounts, setAccounts] = useState<BizAccount[]>([]);
const [health, setHealth] = useState<BizAccountHealth | null>(null);
const [filter, setFilter] = useState<BizFilter>('active');
const [loading, setLoading] = useState(false);
const [myWxid, setMyWxid] = useState<string>('');
@@ -44,8 +66,15 @@ export const BizAccountList: React.FC<{
setLoading(true);
try {
const res = await window.electronAPI.biz.listAccounts(myWxid)
setAccounts(res || []);
if (window.electronAPI.biz.listAccountHealth) {
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) {
console.error('获取服务号列表失败:', err);
} finally {
@@ -76,9 +105,14 @@ export const BizAccountList: React.FC<{
const filtered = useMemo(() => {
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) {
const q = searchKeyword.toLowerCase();
result = accounts.filter(a =>
result = result.filter(a =>
(a.name && a.name.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;
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>;
return (
<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 => (
<div
key={item.username}
@@ -123,13 +184,32 @@ export const BizAccountList: React.FC<{
{/* <div className="biz-badge type-service">微信支付</div>*/}
{/*)}*/}
<div className={`biz-badge ${
item.type === '1' ? 'type-service' :
item.type === '0' ? 'type-sub' :
item.type === '2' ? 'type-enterprise' :
item.type === '3' ? 'type-enterprise' : 'type-unknown'
}`}>
{item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'}
<div className="biz-badge-row">
{(() => {
const typeValue = Number(item.type);
return (
<div className={`biz-badge ${
typeValue === 1 ? 'type-service' :
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>
+23 -1
View File
@@ -20,15 +20,25 @@ export interface ChatMessageBubbleProps {
avatarUrl?: string
isGroupChat?: boolean
resolvedSenderName?: string
avatarProfile?: MessageAvatarProfile
isSelectionMode?: boolean
isSelected?: boolean
onContextMenu?: (event: React.MouseEvent, message: Message) => void
onAvatarContextMenu?: (event: React.MouseEvent, message: Message, profile: MessageAvatarProfile) => void
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void
actionNode?: React.ReactNode
children: 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' }) {
return (
<div className={`chat-selection-checkbox ${side} ${checked ? 'checked' : ''}`}>
@@ -54,9 +64,11 @@ function ChatMessageBubble({
avatarUrl,
isGroupChat,
resolvedSenderName,
avatarProfile,
isSelectionMode,
isSelected,
onContextMenu,
onAvatarContextMenu,
onToggleSelection,
actionNode,
children,
@@ -89,7 +101,15 @@ function ChatMessageBubble({
className={`message-bubble ${bubbleClass} ${isEmoji && emojiHasAsset && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVideo ? 'video' : ''} ${isVoice ? 'voice' : ''}`}
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" />
</div>
<div className="bubble-body">
@@ -137,9 +157,11 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
prev.avatarUrl === next.avatarUrl &&
prev.isGroupChat === next.isGroupChat &&
prev.resolvedSenderName === next.resolvedSenderName &&
prev.avatarProfile === next.avatarProfile &&
prev.isSelectionMode === next.isSelectionMode &&
prev.isSelected === next.isSelected &&
prev.onContextMenu === next.onContextMenu &&
prev.onAvatarContextMenu === next.onAvatarContextMenu &&
prev.onToggleSelection === next.onToggleSelection &&
prev.actionNode === next.actionNode &&
prev.children === next.children &&
+260
View File
@@ -2097,6 +2097,7 @@
align-items: center;
justify-content: center;
overflow: hidden;
cursor: context-menu;
img {
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 {
position: fixed;
+562 -18
View File
@@ -31,7 +31,7 @@ import {
requestExportSessionStatus
} from '../services/exportBridge'
import ChatHeader from './Chat/ChatHeader'
import ChatMessageBubble from './Chat/ChatMessageBubble'
import ChatMessageBubble, { type MessageAvatarProfile } from './Chat/ChatMessageBubble'
import { buildNewMessagesCursor } from './Chat/messageCursor'
import {
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_BYTES = 2 * BYTES_PER_MEGABYTE
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_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 {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp * 1000)
@@ -798,6 +878,11 @@ function formatYmdHmDateTime(timestamp?: number): string {
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 {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
@@ -880,6 +965,40 @@ interface SessionContactProfile {
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'
interface GroupPanelMember {
@@ -984,12 +1103,12 @@ function resolveQuotedSenderUsername(
}
function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined {
const remark = normalizeSearchIdentityText(member.remark)
if (remark) return remark
const groupNickname = normalizeSearchIdentityText(member.groupNickname)
if (groupNickname) return groupNickname
const remark = normalizeSearchIdentityText(member.remark)
if (remark) return remark
const nickname = normalizeSearchIdentityText(member.nickname)
if (nickname) return nickname
@@ -1124,7 +1243,10 @@ async function resolveQuotedSenderDisplayName(options: {
}
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)
if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) {
quotedSenderDisplayCache.set(cacheKey, {
@@ -1583,6 +1705,8 @@ function ChatPage(props: ChatPageProps) {
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
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)
@@ -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) => {
e.preventDefault()
@@ -7133,10 +7377,97 @@ function ChatPage(props: ChatPageProps) {
})
}, [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(() => {
const handleClick = () => {
setContextMenu(null)
setAvatarProfileCard(null)
}
window.addEventListener('click', handleClick)
return () => {
@@ -7358,6 +7689,16 @@ function ChatPage(props: ChatPageProps) {
const isSystem = isSystemMessage(msg.localType)
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
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 (
<div className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
@@ -7373,10 +7714,12 @@ function ChatPage(props: ChatPageProps) {
myAvatarUrl={myAvatarUrl}
myWxid={myWxid}
isGroupChat={isCurrentSessionGroup}
groupSenderDisplayName={groupSenderDisplayName}
quoteLayout={quoteLayout}
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu}
onAvatarContextMenu={handleAvatarContextMenu}
onJumpToQuotedMessage={handleJumpToQuotedMessage}
isSelectionMode={isSelectionMode}
messageKey={messageKey}
@@ -7396,10 +7739,12 @@ function ChatPage(props: ChatPageProps) {
myAvatarUrl,
myWxid,
isCurrentSessionGroup,
groupPanelMembers,
quoteLayout,
autoTranscribeVoiceEnabled,
handleRequireModelDownload,
handleContextMenu,
handleAvatarContextMenu,
handleJumpToQuotedMessage,
isSelectionMode,
selectedMessages,
@@ -8725,6 +9070,23 @@ function ChatPage(props: ChatPageProps) {
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(
<div className="message-info-overlay" onClick={() => setShowMessageInfo(null)}>
@@ -9127,7 +9489,7 @@ type SharedImageDecryptResult = {
failureKind?: 'not_found' | 'decrypt_failed'
}
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
})
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({
message,
messageKey,
@@ -9405,10 +9907,12 @@ function MessageBubble({
myAvatarUrl,
myWxid,
isGroupChat,
groupSenderDisplayName,
quoteLayout,
autoTranscribeVoiceEnabled,
onRequireModelDownload,
onContextMenu,
onAvatarContextMenu,
onJumpToQuotedMessage,
isSelectionMode,
isSelected,
@@ -9423,10 +9927,12 @@ function MessageBubble({
myAvatarUrl?: string;
myWxid?: string;
isGroupChat?: boolean;
groupSenderDisplayName?: string;
quoteLayout: configService.QuoteLayout;
autoTranscribeVoiceEnabled?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
onAvatarContextMenu?: (e: React.MouseEvent, message: Message, profile: MessageAvatarProfile) => void;
onJumpToQuotedMessage?: (target: QuotedMessageJumpTarget) => void;
isSelectionMode?: boolean;
isSelected?: boolean;
@@ -9753,9 +10259,16 @@ function MessageBubble({
// 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取)
useEffect(() => {
const sender = String(message.senderUsername || '').trim()
const cached = sender ? senderAvatarCache.get(sender) : undefined
setSenderAvatarUrl(cached?.avatarUrl || message.senderAvatarUrl || undefined)
setSenderName(cached?.displayName || message.senderDisplayName || undefined)
const profileCacheKey = sender
? `${isGroupChat ? session.username : 'contact'}::${sender}`
: ''
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
@@ -9769,14 +10282,14 @@ function MessageBubble({
if (result.displayName) setSenderName(result.displayName)
}
if (cached) {
if (cachedFresh && cached) {
applyProfile(cached)
return () => {
cancelled = true
}
}
const pending = senderAvatarLoading.get(sender)
const pending = senderAvatarLoading.get(profileCacheKey)
if (pending) {
pending.then(applyProfile).catch(() => { })
return () => {
@@ -9784,23 +10297,23 @@ function MessageBubble({
}
}
const request = window.electronAPI.chat.getContactAvatar(sender)
senderAvatarLoading.set(sender, request)
const request = window.electronAPI.chat.getContactAvatar(sender, isGroupChat ? session.username : undefined)
senderAvatarLoading.set(profileCacheKey, request)
request.then((result: { avatarUrl?: string; displayName?: string } | null) => {
if (result) {
senderAvatarCache.set(sender, result)
senderAvatarCache.set(profileCacheKey, { ...result, updatedAt: Date.now() })
}
applyProfile(result)
}).catch(() => { }).finally(() => {
if (senderAvatarLoading.get(sender) === request) {
senderAvatarLoading.delete(sender)
if (senderAvatarLoading.get(profileCacheKey) === request) {
senderAvatarLoading.delete(profileCacheKey)
}
})
return () => {
cancelled = true
}
}, [isGroupChat, isSent, message.senderAvatarUrl, message.senderDisplayName, message.senderUsername, myAvatarUrl])
}, [groupSenderDisplayName, isGroupChat, isSent, message.senderAvatarUrl, message.senderDisplayName, message.senderUsername, myAvatarUrl, session.username])
// 解析转账消息的付款方和收款方显示名称
useEffect(() => {
@@ -10660,11 +11173,38 @@ function MessageBubble({
// - 群聊中对方发的:使用发送者头像
// - 私聊中对方发的:使用会话头像
const fallbackSenderName = String(message.senderDisplayName || message.senderUsername || '').trim() || undefined
const resolvedSenderName = senderName || fallbackSenderName
const resolvedSenderName = groupSenderDisplayName || senderName || fallbackSenderName
const resolvedSenderAvatarUrl = senderAvatarUrl || message.senderAvatarUrl
const avatarUrl = isSent
? (myAvatarUrl || resolvedSenderAvatarUrl)
: (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(
aiMessageInsightEnabled &&
!isSent &&
@@ -12037,9 +12577,11 @@ function MessageBubble({
avatarUrl={avatarUrl}
isGroupChat={isGroupChat}
resolvedSenderName={resolvedSenderName}
avatarProfile={avatarProfile}
isSelectionMode={isSelectionMode}
isSelected={isSelected}
onContextMenu={onContextMenu}
onAvatarContextMenu={onAvatarContextMenu}
onToggleSelection={onToggleSelection}
actionNode={messageInsightControl}
portal={systemAlertPortal}
@@ -12056,12 +12598,14 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
if (prevProps.myWxid !== nextProps.myWxid) 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.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false
if (prevProps.isSelected !== nextProps.isSelected) return false
if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) 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.onToggleSelection !== nextProps.onToggleSelection) 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 DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const CONTACTS_CACHE_SCHEMA_VERSION = 'contacts-v5'
interface ContactsLoadSession {
requestId: string
@@ -49,8 +50,10 @@ function ContactsPage() {
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: false,
officials: false,
deletedFriends: false
officialSubscriptions: false,
officialServices: false,
deletedFriends: false,
blocked: false
})
// 导出模式与查看详情
@@ -103,8 +106,8 @@ function ContactsPage() {
configService.getMyWxid()
])
const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}`
: 'default'
? `${dbPath || ''}::${myWxid || ''}::${CONTACTS_CACHE_SCHEMA_VERSION}`
: `default::${CONTACTS_CACHE_SCHEMA_VERSION}`
contactsCacheScopeRef.current = scopeKey
return scopeKey
}, [])
@@ -402,7 +405,9 @@ function ContactsPage() {
description: contact.description,
detailDescription: contact.detailDescription,
region: contact.region,
type: contact.type
type: contact.type,
officialAccountKind: contact.officialAccountKind,
officialAccountType: contact.officialAccountType
}))
).catch((error) => {
console.error('写入通讯录缓存失败:', error)
@@ -550,8 +555,12 @@ function ContactsPage() {
let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) 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 === 'blocked' && !contactTypes.blocked) return false
return true
})
@@ -668,7 +677,7 @@ function ContactsPage() {
? { key: 'signature', label: '个性签名', value: selectedContact.detailDescription }
: 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))
}, [selectedContact])
@@ -793,16 +802,24 @@ function ContactsPage() {
case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} />
case 'former_friend': return <UserX size={14} />
case 'blocked': return <UserX 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) {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
case 'former_friend': return '曾经的好友'
case 'blocked': return '黑名单'
default: return '其他'
}
}
@@ -840,7 +857,8 @@ function ContactsPage() {
contactTypes: {
friends: contactTypes.friends,
groups: contactTypes.groups,
officials: contactTypes.officials
officials: contactTypes.officialSubscriptions || contactTypes.officialServices,
blocked: contactTypes.blocked
},
selectedUsernames: Array.from(selectedUsernames)
}
@@ -918,11 +936,17 @@ function ContactsPage() {
<span className="chip-label">群聊</span>
<span className="chip-count">{contactTypeCounts.groups}</span>
</label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<label className={`filter-chip ${contactTypes.officialSubscriptions ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officialSubscriptions} onChange={e => setContactTypes({ ...contactTypes, officialSubscriptions: e.target.checked })} />
<MessageSquare size={16} />
<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 className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<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-count">{contactTypeCounts.deletedFriends}</span>
</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>
@@ -1038,9 +1068,9 @@ function ContactsPage() {
<div className="contact-remark">备注: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
<div className={`contact-type ${contact.type} ${contact.officialAccountKind || ''}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
<span>{getContactTypeName(contact)}</span>
</div>
</div>
</div>
@@ -1138,9 +1168,9 @@ function ContactsPage() {
)}
</div>
<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)}
<span>{getContactTypeName(selectedContact.type)}</span>
<span>{getContactTypeName(selectedContact)}</span>
</div>
<h2>{selectedContactTitle}</h2>
{selectedContactSubtitle && <p>{selectedContactSubtitle}</p>}
+15 -5
View File
@@ -1005,7 +1005,9 @@ export interface ContactsListCacheContact {
description?: string
detailDescription?: 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 {
@@ -1532,9 +1534,13 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
description: typeof item.description === 'string' ? (item.description.trim() || undefined) : undefined,
detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.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
: '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
const displayName = String(contact?.displayName || username)
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
}
normalized.push({
@@ -1572,7 +1578,11 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
description: contact?.description ? (String(contact.description).trim() || undefined) : undefined,
detailDescription: contact?.detailDescription ? (String(contact.detailDescription).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
group: number
official: number
official_subscription: number
official_service: number
official_enterprise: number
official_unknown: number
former_friend: number
blocked: number
}
export interface ContactTypeCardCounts {
friends: number
groups: number
officials: number
officialSubscriptions: number
officialServices: number
officialEnterprises: number
officialUnknown: number
deletedFriends: number
blocked: number
}
const emptyTabCounts: ContactTypeTabCounts = {
private: 0,
group: 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
@@ -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,
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,
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 || []) {
if (contact.type === 'friend') next.private += 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 === 'blocked') next.blocked += 1
}
return next
}
@@ -49,7 +76,12 @@ export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTy
friends: counts.private,
groups: counts.group,
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
official: number
former_friend: number
blocked?: number
}
error?: string
}>
@@ -573,6 +574,7 @@ export interface ElectronAPI {
group: number
official: number
former_friend: number
blocked?: number
}
error?: string
}>
@@ -626,7 +628,7 @@ export interface ElectronAPI {
error?: string
}>
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 }>
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
checkAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
@@ -900,6 +902,7 @@ export interface ElectronAPI {
}
biz: {
listAccounts: (account?: string) => Promise<any[]>
listAccountHealth: (account?: string) => Promise<any>
listMessages: (username: string, 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
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
successCount?: number
error?: string
+3 -1
View File
@@ -42,7 +42,9 @@ export interface ContactInfo {
detailDescription?: string
region?: 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
}
// 消息