支持联系人签名、标签分组、地区获取;优化导出效果

This commit is contained in:
cc
2026-03-23 21:46:15 +08:00
parent 1655b5ae78
commit 64995c25a8
10 changed files with 10070 additions and 34 deletions

View File

@@ -1545,8 +1545,8 @@ function registerIpcHandlers() {
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername) return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
}) })
ipcMain.handle('chat:getContacts', async () => { ipcMain.handle('chat:getContacts', async (_, options?: { lite?: boolean }) => {
return await chatService.getContacts() return await chatService.getContacts(options)
}) })
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => { ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {

View File

@@ -225,7 +225,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('chat:voiceTranscriptPartial', listener) ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
}, },
getContacts: () => ipcRenderer.invoke('chat:getContacts'), getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
getMessage: (sessionId: string, localId: number) => getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId), ipcRenderer.invoke('chat:getMessage', sessionId, localId),
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>

View File

@@ -16,6 +16,7 @@ import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from '
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService' import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
import { voiceTranscribeService } from './voiceTranscribeService' import { voiceTranscribeService } from './voiceTranscribeService'
import { ImageDecryptService } from './imageDecryptService' import { ImageDecryptService } from './imageDecryptService'
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
import { LRUCache } from '../utils/LRUCache.js' import { LRUCache } from '../utils/LRUCache.js'
export interface ChatSession { export interface ChatSession {
@@ -155,10 +156,15 @@ export interface ContactInfo {
alias?: string alias?: string
labels?: string[] labels?: string[]
detailDescription?: string detailDescription?: string
region?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
interface GetContactsOptions {
lite?: boolean
}
interface ExportSessionStats { interface ExportSessionStats {
totalMessages: number totalMessages: number
voiceMessages: number voiceMessages: number
@@ -295,6 +301,21 @@ class ChatService {
private groupMyMessageCountCacheScope = '' private groupMyMessageCountCacheScope = ''
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>() private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
private initFailureDialogShown = false private initFailureDialogShown = false
private readonly contactExtendedFieldCandidates = [
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList',
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription', 'signature', 'sign',
'country', 'province', 'city', 'region',
'profile', 'introduction', 'phone', 'mobile', 'telephone', 'tel', 'vcard', 'card_info', 'cardInfo',
'extra_buffer', 'extraBuffer'
]
private readonly contactExtendedFieldCandidateSet = new Set(this.contactExtendedFieldCandidates.map((name) => name.toLowerCase()))
private contactExtendedSelectableColumns: string[] | null = null
private contactLabelNameMapCache: Map<number, string> | null = null
private contactLabelNameMapCacheAt = 0
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
private readonly slowGetContactsLogThresholdMs = 1200
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
@@ -1269,25 +1290,61 @@ class ChatService {
/** /**
* 获取通讯录列表 * 获取通讯录列表
*/ */
async getContacts(): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> { async getContacts(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const mode: 'lite' | 'full' = options?.lite ? 'lite' : 'full'
const inFlight = this.contactsLoadInFlight
if (inFlight && (inFlight.mode === mode || (mode === 'lite' && inFlight.mode === 'full'))) {
return await inFlight.promise
}
const promise = this.getContactsInternal(options)
this.contactsLoadInFlight = { mode, promise }
try { try {
return await promise
} finally {
if (this.contactsLoadInFlight?.promise === promise) {
this.contactsLoadInFlight = null
}
}
}
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const isLiteMode = options?.lite === true
const startedAt = Date.now()
const stageDurations: Array<{ stage: string; ms: number }> = []
const captureStage = (stage: string, stageStartedAt: number) => {
stageDurations.push({ stage, ms: Date.now() - stageStartedAt })
}
try {
const connectStartedAt = Date.now()
const connectResult = await this.ensureConnected() const connectResult = await this.ensureConnected()
captureStage('ensureConnected', connectStartedAt)
if (!connectResult.success) { if (!connectResult.success) {
return { success: false, error: connectResult.error } return { success: false, error: connectResult.error }
} }
const contactsCompactStartedAt = Date.now()
const contactResult = await wcdbService.getContactsCompact() const contactResult = await wcdbService.getContactsCompact()
captureStage('getContactsCompact', contactsCompactStartedAt)
if (!contactResult.success || !contactResult.contacts) { if (!contactResult.success || !contactResult.contacts) {
console.error('查询联系人失败:', contactResult.error) console.error('查询联系人失败:', contactResult.error)
return { success: false, error: contactResult.error || '查询联系人失败' } return { success: false, error: contactResult.error || '查询联系人失败' }
} }
let rows = contactResult.contacts as Record<string, any>[]
if (!isLiteMode) {
const hydrateStartedAt = Date.now()
rows = await this.hydrateContactsWithExtendedFields(rows)
captureStage('hydrateContactsWithExtendedFields', hydrateStartedAt)
}
const rows = contactResult.contacts as Record<string, any>[]
// 获取会话表的最后联系时间用于排序 // 获取会话表的最后联系时间用于排序
const sessionsStartedAt = Date.now()
const lastContactTimeMap = new Map<string, number>() const lastContactTimeMap = new Map<string, number>()
const sessionResult = await wcdbService.getSessions() const sessionResult = await wcdbService.getSessions()
captureStage('getSessions', sessionsStartedAt)
if (sessionResult.success && sessionResult.sessions) { if (sessionResult.success && sessionResult.sessions) {
for (const session of sessionResult.sessions as any[]) { for (const session of sessionResult.sessions as any[]) {
const username = session.username || session.user_name || session.userName || '' const username = session.username || session.user_name || session.userName || ''
@@ -1299,9 +1356,14 @@ class ChatService {
} }
// 转换为ContactInfo // 转换为ContactInfo
const transformStartedAt = Date.now()
const contacts: (ContactInfo & { lastContactTime: number })[] = [] const contacts: (ContactInfo & { lastContactTime: number })[] = []
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) let contactLabelNameMap = new Map<number, string>()
if (!isLiteMode) {
const labelMapStartedAt = Date.now()
contactLabelNameMap = await this.getContactLabelNameMap()
captureStage('getContactLabelNameMap', labelMapStartedAt)
}
for (const row of rows) { for (const row of rows) {
const username = String(row.username || '').trim() const username = String(row.username || '').trim()
@@ -1315,7 +1377,7 @@ class ChatService {
type = 'group' type = 'group'
} else if (username.startsWith('gh_')) { } else if (username.startsWith('gh_')) {
type = 'official' type = 'official'
} else if (localType === 1 && !excludeNames.has(username)) { } else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
type = 'friend' type = 'friend'
} else if (localType === 0 && quanPin) { } else if (localType === 0 && quanPin) {
type = 'former_friend' type = 'former_friend'
@@ -1323,9 +1385,10 @@ class ChatService {
continue continue
} }
const labels = this.parseContactLabels(row)
const detailDescription = this.getContactDetailDescription(row)
const displayName = row.remark || row.nick_name || row.alias || username const displayName = row.remark || row.nick_name || row.alias || username
const labels = isLiteMode ? [] : this.parseContactLabels(row, contactLabelNameMap)
const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
const region = isLiteMode ? '' : this.getContactRegion(row)
contacts.push({ contacts.push({
username, username,
@@ -1335,16 +1398,17 @@ class ChatService {
alias: row.alias || undefined, alias: row.alias || undefined,
labels: labels.length > 0 ? labels : undefined, labels: labels.length > 0 ? labels : undefined,
detailDescription: detailDescription || undefined, detailDescription: detailDescription || undefined,
region: region || undefined,
avatarUrl: undefined, avatarUrl: undefined,
type, type,
lastContactTime: lastContactTimeMap.get(username) || 0 lastContactTime: lastContactTimeMap.get(username) || 0
}) })
} }
captureStage('transformContacts', transformStartedAt)
// 按最近联系时间排序 // 按最近联系时间排序
const sortStartedAt = Date.now()
contacts.sort((a, b) => { contacts.sort((a, b) => {
const timeA = a.lastContactTime || 0 const timeA = a.lastContactTime || 0
const timeB = b.lastContactTime || 0 const timeB = b.lastContactTime || 0
@@ -1353,13 +1417,22 @@ class ChatService {
} }
if (timeA && !timeB) return -1 if (timeA && !timeB) return -1
if (!timeA && timeB) return 1 if (!timeA && timeB) return 1
return a.displayName.localeCompare(b.displayName, 'zh-CN') return this.contactDisplayNameCollator.compare(a.displayName, b.displayName)
}) })
captureStage('sortContacts', sortStartedAt)
// 移除临时的lastContactTime字段 // 移除临时的lastContactTime字段
const finalizeStartedAt = Date.now()
const result = contacts.map(({ lastContactTime, ...rest }) => rest) const result = contacts.map(({ lastContactTime, ...rest }) => rest)
captureStage('finalizeResult', finalizeStartedAt)
const totalMs = Date.now() - startedAt
if (totalMs >= this.slowGetContactsLogThresholdMs) {
const stageSummary = stageDurations
.map((item) => `${item.stage}=${item.ms}ms`)
.join(', ')
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
}
return { success: true, contacts: result } return { success: true, contacts: result }
} catch (e) { } catch (e) {
console.error('ChatService: 获取通讯录失败:', e) console.error('ChatService: 获取通讯录失败:', e)
@@ -1886,7 +1959,261 @@ class ChatService {
return Number.isFinite(parsed) ? parsed : fallback return Number.isFinite(parsed) ? parsed : fallback
} }
private parseContactLabels(row: Record<string, any>): string[] { private hasAnyContactExtendedFieldKey(row: Record<string, any>): boolean {
for (const key of Object.keys(row || {})) {
if (this.contactExtendedFieldCandidateSet.has(String(key || '').toLowerCase())) {
return true
}
}
return false
}
private async hydrateContactsWithExtendedFields(rows: Record<string, any>[]): Promise<Record<string, any>[]> {
if (!Array.isArray(rows) || rows.length === 0) return rows
const hasAnyExtendedFieldKey = rows.some((row) => this.hasAnyContactExtendedFieldKey(row || {}))
if (hasAnyExtendedFieldKey) {
// wcdb_get_contacts_compact 可能只给“部分联系人”返回 extra_buffer。
// 只有在每一行都能拿到可解析的 extra_buffer 时才跳过补偿查询。
const allRowsHaveUsableExtraBuffer = rows.every((row) => this.toExtraBufferBytes(row || {}) !== null)
if (allRowsHaveUsableExtraBuffer) return rows
}
try {
let selectableColumns = this.contactExtendedSelectableColumns
if (!selectableColumns) {
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)')
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows)) {
return rows
}
const availableColumns = new Map<string, string>()
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
const name = String(rawName || '').trim()
if (!name) continue
availableColumns.set(name.toLowerCase(), name)
}
const resolvedColumns: string[] = []
const seenColumns = new Set<string>()
for (const candidate of this.contactExtendedFieldCandidates) {
const actual = availableColumns.get(candidate.toLowerCase())
if (!actual) continue
const normalized = actual.toLowerCase()
if (seenColumns.has(normalized)) continue
seenColumns.add(normalized)
resolvedColumns.push(actual)
}
this.contactExtendedSelectableColumns = resolvedColumns
selectableColumns = resolvedColumns
}
if (!selectableColumns || selectableColumns.length === 0) return rows
const selectColumns = ['username', ...selectableColumns]
const sql = `SELECT ${selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ')} FROM contact WHERE username IS NOT NULL AND username != ''`
const extendedResult = await wcdbService.execQuery('contact', null, sql)
if (!extendedResult.success || !Array.isArray(extendedResult.rows) || extendedResult.rows.length === 0) {
return rows
}
const extendedByUsername = new Map<string, Record<string, any>>()
for (const extendedRow of extendedResult.rows as Record<string, any>[]) {
const username = String(extendedRow.username || '').trim()
if (!username) continue
extendedByUsername.set(username, extendedRow)
}
if (extendedByUsername.size === 0) return rows
return rows.map((row) => {
const username = String(row.username || row.user_name || row.userName || '').trim()
if (!username) return row
const extended = extendedByUsername.get(username)
if (!extended) return row
return {
...extended,
...row
}
})
} catch (error) {
console.warn('联系人扩展字段补偿查询失败:', error)
return rows
}
}
private async getContactLabelNameMap(): Promise<Map<number, string>> {
const now = Date.now()
if (this.contactLabelNameMapCache && now - this.contactLabelNameMapCacheAt <= this.contactLabelNameMapCacheTtlMs) {
return new Map(this.contactLabelNameMapCache)
}
const labelMap = new Map<number, string>()
try {
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact_label)')
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows) || tableInfoResult.rows.length === 0) {
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return labelMap
}
const availableColumns = new Map<string, string>()
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
const name = String(rawName || '').trim()
if (!name) continue
availableColumns.set(name.toLowerCase(), name)
}
const pickColumn = (candidates: string[]): string | null => {
for (const candidate of candidates) {
const actual = availableColumns.get(candidate.toLowerCase())
if (actual) return actual
}
return null
}
const idColumn = pickColumn(['label_id_', 'label_id', 'labelId', 'labelid', 'id'])
const nameColumn = pickColumn(['label_name_', 'label_name', 'labelName', 'labelname', 'name'])
if (!idColumn || !nameColumn) {
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return labelMap
}
const sql = `SELECT ${this.quoteSqlIdentifier(idColumn)} AS label_id, ${this.quoteSqlIdentifier(nameColumn)} AS label_name FROM contact_label`
const result = await wcdbService.execQuery('contact', null, sql)
if (result.success && Array.isArray(result.rows)) {
for (const row of result.rows as Record<string, any>[]) {
const id = Number(String(row.label_id ?? row.labelId ?? '').trim())
const name = String(row.label_name ?? row.labelName ?? '').trim()
if (Number.isFinite(id) && id > 0 && name) {
labelMap.set(Math.floor(id), name)
}
}
}
} catch (error) {
console.warn('读取 contact_label 失败:', error)
}
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return new Map(labelMap)
}
private toExtraBufferBytes(row: Record<string, any>): Buffer | null {
const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer'])
if (raw === undefined || raw === null) return null
if (Buffer.isBuffer(raw)) return raw.length > 0 ? raw : null
if (raw instanceof Uint8Array) return raw.length > 0 ? Buffer.from(raw) : null
if (Array.isArray(raw)) {
const bytes = Buffer.from(raw)
return bytes.length > 0 ? bytes : null
}
const text = String(raw || '').trim()
if (!text) return null
const compact = text.replace(/\s+/g, '')
if (compact.length >= 2 && compact.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(compact)) {
try {
const bytes = Buffer.from(compact, 'hex')
return bytes.length > 0 ? bytes : null
} catch {
return null
}
}
return null
}
private readProtoVarint(buffer: Buffer, offset: number): { value: number; nextOffset: number } | null {
if (!buffer || offset < 0 || offset >= buffer.length) return null
let value = 0
let shift = 0
let index = offset
while (index < buffer.length) {
const byte = buffer[index]
index += 1
value += (byte & 0x7f) * Math.pow(2, shift)
if ((byte & 0x80) === 0) {
return { value, nextOffset: index }
}
shift += 7
if (shift > 56) return null
}
return null
}
private extractExtraBufferTopLevelFieldStrings(row: Record<string, any>, targetField: number): string[] {
const bytes = this.toExtraBufferBytes(row)
if (!bytes || !Number.isFinite(targetField) || targetField <= 0) return []
const values: string[] = []
let offset = 0
while (offset < bytes.length) {
const tagResult = this.readProtoVarint(bytes, offset)
if (!tagResult) break
offset = tagResult.nextOffset
const fieldNumber = Math.floor(tagResult.value / 8)
const wireType = tagResult.value & 0x07
if (wireType === 0) {
const varint = this.readProtoVarint(bytes, offset)
if (!varint) break
offset = varint.nextOffset
continue
}
if (wireType === 1) {
if (offset + 8 > bytes.length) break
offset += 8
continue
}
if (wireType === 2) {
const lengthResult = this.readProtoVarint(bytes, offset)
if (!lengthResult) break
const payloadLength = Math.floor(lengthResult.value)
offset = lengthResult.nextOffset
if (payloadLength < 0 || offset + payloadLength > bytes.length) break
const payload = bytes.subarray(offset, offset + payloadLength)
offset += payloadLength
if (fieldNumber === targetField) {
const text = payload.toString('utf-8').replace(/\u0000/g, '').trim()
if (text) values.push(text)
}
continue
}
if (wireType === 5) {
if (offset + 4 > bytes.length) break
offset += 4
continue
}
break
}
return values
}
private parseContactLabelsFromExtraBuffer(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
const labelNames: string[] = []
const seen = new Set<string>()
const texts = this.extractExtraBufferTopLevelFieldStrings(row, 30)
for (const text of texts) {
const matches = text.match(/\d+/g) || []
for (const match of matches) {
const id = Number(match)
if (!Number.isFinite(id) || id <= 0) continue
const labelName = labelNameMap?.get(Math.floor(id))
if (!labelName) continue
if (seen.has(labelName)) continue
seen.add(labelName)
labelNames.push(labelName)
}
}
return labelNames
}
private parseContactLabels(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
const raw = this.getRowField(row, [ const raw = this.getRowField(row, [
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList' 'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList'
]) ])
@@ -1916,29 +2243,284 @@ class ChatService {
if (fallback.length > 0) return fallback if (fallback.length > 0) return fallback
} }
const extraBufferLabels = this.parseContactLabelsFromExtraBuffer(row, labelNameMap)
if (extraBufferLabels.length > 0) return extraBufferLabels
return [] return []
} }
private getContactDetailDescription(row: Record<string, any>): string { private getContactSignature(row: Record<string, any>): string {
const normalize = (raw: unknown): string => {
const text = String(raw || '').replace(/\u0000/g, '').trim()
if (!text) return ''
const lower = text.toLowerCase()
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
return ''
}
return text
}
const value = this.getRowField(row, [ const value = this.getRowField(row, [
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription', 'signature', 'sign', 'personal_signature', 'personalSignature', 'profile', 'introduction',
'profile', 'introduction', 'phone', 'mobile', 'telephone', 'tel', 'vcard', 'card_info', 'cardInfo' 'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription'
]) ])
const direct = String(value || '').trim() const direct = normalize(value)
if (direct) return direct if (direct) return direct
for (const [key, rawValue] of Object.entries(row)) { for (const [key, rawValue] of Object.entries(row)) {
const normalizedKey = key.toLowerCase() const normalizedKey = key.toLowerCase()
const isCandidate = normalizedKey.includes('detail') || normalizedKey.includes('desc') || normalizedKey.includes('description') || normalizedKey.includes('profile') || normalizedKey.includes('intro') || normalizedKey.includes('phone') || normalizedKey.includes('mobile') || normalizedKey.includes('tel') || normalizedKey.includes('vcard') || normalizedKey.includes('card') const isCandidate =
normalizedKey.includes('sign') ||
normalizedKey.includes('signature') ||
normalizedKey.includes('profile') ||
normalizedKey.includes('intro') ||
normalizedKey.includes('description') ||
normalizedKey.includes('detail') ||
normalizedKey.includes('desc')
if (!isCandidate) continue if (!isCandidate) continue
if (normalizedKey.includes('avatar') || normalizedKey.includes('img') || normalizedKey.includes('head')) continue if (
const text = String(rawValue || '').trim() normalizedKey.includes('avatar') ||
normalizedKey.includes('img') ||
normalizedKey.includes('head') ||
normalizedKey.includes('label') ||
normalizedKey.includes('tag')
) continue
const text = normalize(rawValue)
if (text) return text if (text) return text
} }
// contact.extra_buffer field 4: 个性签名兜底
const signatures = this.extractExtraBufferTopLevelFieldStrings(row, 4)
for (const signature of signatures) {
const text = normalize(signature)
if (!text) continue
return text
}
return '' return ''
} }
private normalizeContactRegionPart(raw: unknown): string {
const text = String(raw || '').replace(/\u0000/g, '').trim()
if (!text) return ''
const lower = text.toLowerCase()
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
return ''
}
return text
}
private normalizeRegionLookupKey(raw: string): string {
return String(raw || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '')
}
private buildRegionLookupCandidates(raw: string): string[] {
const normalized = this.normalizeRegionLookupKey(raw)
if (!normalized) return []
const candidates = new Set<string>([normalized])
const withoutTrailingDigits = normalized.replace(/\d+$/g, '')
if (withoutTrailingDigits) candidates.add(withoutTrailingDigits)
return Array.from(candidates)
}
private normalizeChineseProvinceName(raw: string): string {
const text = String(raw || '').trim()
if (!text) return ''
return text
.replace(/特别行政区$/g, '')
.replace(/维吾尔自治区$/g, '')
.replace(/壮族自治区$/g, '')
.replace(/回族自治区$/g, '')
.replace(/自治区$/g, '')
.replace(/省$/g, '')
.replace(/市$/g, '')
.trim()
}
private normalizeChineseCityName(raw: string): string {
const text = String(raw || '').trim()
if (!text) return ''
return text
.replace(/特别行政区$/g, '')
.replace(/自治州$/g, '')
.replace(/地区$/g, '')
.replace(/盟$/g, '')
.replace(/林区$/g, '')
.replace(/市$/g, '')
.trim()
}
private resolveProvinceLookupKey(raw: string): string {
const candidates = this.buildRegionLookupCandidates(raw)
if (candidates.length === 0) return ''
for (const candidate of candidates) {
const byName = CONTACT_REGION_LOOKUP_DATA.provinceKeyByName[candidate]
if (byName) return byName
if (CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate]) return candidate
}
return candidates[0]
}
private toChineseCountryName(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
for (const candidate of candidates) {
const mapped = CONTACT_REGION_LOOKUP_DATA.countryNameByKey[candidate]
if (mapped) return mapped
}
return text
}
private toChineseProvinceName(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
if (candidates.length === 0) return text
const provinceKey = this.resolveProvinceLookupKey(text)
const mappedFromCandidates = candidates
.map((candidate) => CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate])
.find(Boolean)
const mapped = CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[provinceKey] || mappedFromCandidates
if (mapped) return mapped
if (/[\u4e00-\u9fa5]/.test(text)) {
return this.normalizeChineseProvinceName(text) || text
}
return text
}
private toChineseCityName(raw: string, provinceRaw?: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
if (candidates.length === 0) return text
const provinceKey = this.resolveProvinceLookupKey(String(provinceRaw || ''))
if (provinceKey) {
const byProvince = CONTACT_REGION_LOOKUP_DATA.cityNameByProvinceKey[provinceKey]
if (byProvince) {
for (const candidate of candidates) {
const mappedInProvince = byProvince[candidate]
if (mappedInProvince) return mappedInProvince
}
}
}
for (const candidate of candidates) {
const mapped = CONTACT_REGION_LOOKUP_DATA.cityNameByKey[candidate]
if (mapped) return mapped
}
if (/[\u4e00-\u9fa5]/.test(text)) {
return this.normalizeChineseCityName(text) || text
}
return text
}
private toChineseRegionText(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const tokens = text
.split(/[\s,,、/|·]+/)
.map((item) => this.normalizeContactRegionPart(item))
.filter(Boolean)
if (tokens.length === 0) return text
let provinceContext = ''
const mapped = tokens.map((token) => {
const country = this.toChineseCountryName(token)
if (country !== token) return country
const province = this.toChineseProvinceName(token)
if (province !== token) {
provinceContext = province
return province
}
const city = this.toChineseCityName(token, provinceContext)
if (city !== token) return city
return token
})
return mapped.join(' ').trim()
}
private shouldHideCountryInRegion(country: string, hasProvinceOrCity: boolean): boolean {
if (!country) return true
const normalized = country.toLowerCase()
if (normalized === 'cn' || normalized === 'chn' || normalized === 'china' || normalized === '中国') {
return hasProvinceOrCity
}
return false
}
private getContactRegion(row: Record<string, any>): string {
const pickByTokens = (tokens: string[]): string => {
for (const [key, value] of Object.entries(row || {})) {
const normalizedKey = String(key || '').toLowerCase()
if (!normalizedKey) continue
if (normalizedKey.includes('avatar') || normalizedKey.includes('img') || normalizedKey.includes('head')) continue
if (!tokens.some((token) => normalizedKey.includes(token))) continue
const text = this.normalizeContactRegionPart(value)
if (text) return text
}
return ''
}
const directCountry = this.normalizeContactRegionPart(this.getRowField(row, ['country', 'Country'])) || pickByTokens(['country'])
const directProvince = this.normalizeContactRegionPart(this.getRowField(row, ['province', 'Province'])) || pickByTokens(['province'])
const directCity = this.normalizeContactRegionPart(this.getRowField(row, ['city', 'City'])) || pickByTokens(['city'])
const directRegion =
this.normalizeContactRegionPart(this.getRowField(row, ['region', 'Region', 'location', 'area'])) ||
pickByTokens(['region', 'location', 'area', 'addr', 'address'])
if (directRegion) {
const normalizedRegion = this.toChineseRegionText(directRegion)
const parts = normalizedRegion
.split(/\s+/)
.map((item) => this.normalizeContactRegionPart(item))
.filter(Boolean)
if (parts.length > 1 && this.shouldHideCountryInRegion(parts[0], true)) {
return parts.slice(1).join(' ').trim()
}
return normalizedRegion
}
const fallbackCountry = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 5)[0] || '')
const fallbackProvince = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 6)[0] || '')
const fallbackCity = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 7)[0] || '')
const country = this.toChineseCountryName(directCountry || fallbackCountry)
const province = this.toChineseProvinceName(directProvince || fallbackProvince)
const city = this.toChineseCityName(directCity || fallbackCity, directProvince || fallbackProvince)
const hasProvinceOrCity = Boolean(province || city)
const parts: string[] = []
if (!this.shouldHideCountryInRegion(country, hasProvinceOrCity)) {
parts.push(country)
}
if (province) {
parts.push(province)
}
if (city && city !== province) {
parts.push(city)
}
return parts.join(' ').trim()
}
private normalizeUnsignedIntegerToken(raw: any): string | undefined { private normalizeUnsignedIntegerToken(raw: any): string | undefined {
if (raw === undefined || raw === null || raw === '') return undefined if (raw === undefined || raw === null || raw === '') return undefined

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -18,7 +18,7 @@ const AVATAR_ENRICH_BATCH_SIZE = 80
const SEARCH_DEBOUNCE_MS = 120 const SEARCH_DEBOUNCE_MS = 120
const VIRTUAL_ROW_HEIGHT = 76 const VIRTUAL_ROW_HEIGHT = 76
const VIRTUAL_OVERSCAN = 10 const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 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
interface ContactsLoadSession { interface ContactsLoadSession {
@@ -400,6 +400,7 @@ function ContactsPage() {
alias: contact.alias, alias: contact.alias,
labels: contact.labels, labels: contact.labels,
detailDescription: contact.detailDescription, detailDescription: contact.detailDescription,
region: contact.region,
type: contact.type type: contact.type
})) }))
).catch((error) => { ).catch((error) => {
@@ -1114,8 +1115,15 @@ function ContactsPage() {
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div> <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>} {selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
{selectedContact.alias && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.alias}</span></div>} {selectedContact.alias && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.alias}</span></div>}
{selectedContact.labels && selectedContact.labels.length > 0 && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.labels.join('、')}</span></div>} {selectedContact.labels && selectedContact.labels.length > 0 && (
{selectedContact.detailDescription && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.detailDescription}</span></div>} <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.labels.join('、')}</span></div>
)}
{selectedContact.detailDescription && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.detailDescription}</span></div>
)}
{selectedContact.region && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.region}</span></div>
)}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div> <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
{selectedContactSupportsSns && ( {selectedContactSupportsSns && (
<div className="detail-row"> <div className="detail-row">

View File

@@ -568,7 +568,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(
const CONTACT_ENRICH_TIMEOUT_MS = 7000 const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000 const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000 const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000 const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
@@ -1928,7 +1928,7 @@ function ExportPage() {
setIsContactsListLoading(true) setIsContactsListLoading(true)
try { try {
const contactsResult = await window.electronAPI.chat.getContacts() const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
if (contactsLoadVersionRef.current !== loadVersion) return if (contactsLoadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) { if (contactsResult.success && contactsResult.contacts) {
@@ -3782,7 +3782,7 @@ function ExportPage() {
if (isStale()) return if (isStale()) return
if (detailStatsPriorityRef.current) return if (detailStatsPriorityRef.current) return
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) const contactsResult = await withTimeout(window.electronAPI.chat.getContacts({ lite: true }), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return if (isStale()) return
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []

View File

@@ -665,6 +665,7 @@ export interface ContactsListCacheContact {
alias?: string alias?: string
labels?: string[] labels?: string[]
detailDescription?: string detailDescription?: string
region?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
@@ -1139,16 +1140,18 @@ export async function setSnsPageCache(
export async function getContactsLoadTimeoutMs(): Promise<number> { export async function getContactsLoadTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS) const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) { if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
return Math.floor(value) const normalized = Math.floor(value)
// 兼容历史默认值 3000ms自动提升到新的更稳妥阈值。
return normalized === 3000 ? 10000 : normalized
} }
return 3000 return 10000
} }
// 设置通讯录加载超时阈值(毫秒) // 设置通讯录加载超时阈值(毫秒)
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> { export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
const normalized = Number.isFinite(timeoutMs) const normalized = Number.isFinite(timeoutMs)
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs))) ? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
: 3000 : 10000
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized) await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
} }
@@ -1181,7 +1184,8 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
labels: Array.isArray(item.labels) labels: Array.isArray(item.labels)
? Array.from(new Set(item.labels.map((label) => String(label || '').trim()).filter(Boolean))) ? Array.from(new Set(item.labels.map((label) => String(label || '').trim()).filter(Boolean)))
: undefined, : undefined,
detailDescription: typeof item.detailDescription === 'string' ? item.detailDescription : undefined, detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined,
region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined,
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other') type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
? type ? type
: 'other' : 'other'
@@ -1219,7 +1223,8 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
labels: Array.isArray(contact?.labels) labels: Array.isArray(contact?.labels)
? Array.from(new Set(contact.labels.map((label) => String(label || '').trim()).filter(Boolean))) ? Array.from(new Set(contact.labels.map((label) => String(label || '').trim()).filter(Boolean)))
: undefined, : undefined,
detailDescription: contact?.detailDescription ? String(contact.detailDescription) : undefined, detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined,
region: contact?.region ? (String(contact.region).trim() || undefined) : undefined,
type type
}) })
} }

View File

@@ -219,7 +219,7 @@ export interface ElectronAPI {
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 }>
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }> resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
getContacts: () => Promise<{ getContacts: (options?: { lite?: boolean }) => Promise<{
success: boolean success: boolean
contacts?: ContactInfo[] contacts?: ContactInfo[]
error?: string error?: string

View File

@@ -39,6 +39,7 @@ export interface ContactInfo {
alias?: string alias?: string
labels?: string[] labels?: string[]
detailDescription?: string detailDescription?: string
region?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }