#1043 #1032;修复增量导出意外添加文件的问题;聊天页面支持右键显示资料卡;优化曾经的好友相关算法并新增黑名单;
This commit is contained in:
1 parent
801ed16d5e
commit
b87444dd64
30 files changed
+1615
-145
No files matched your search
+3
-3
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 '其他'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+69
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+5
-2
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
// 消息
|
||||
|
||||
Reference in new issue
Block a user