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