From 21e299942b75ce85dd0e27e1f4a0f79a9e165707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <84499436+Wxw-Gu@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:38:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E4=B8=80?= =?UTF-8?q?=E4=B8=8B=E6=A1=A3=E6=A1=88=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E6=8B=BC=E9=9F=B3=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- pnpm-lock.yaml | 6 ++ src/main/services/chat-service.ts | 46 +++++++++- .../services/contact-resolution-service.ts | 5 +- src/main/wcdb4-client.ts | 91 +++++++++++++++++++ src/main/wechat-db.ts | 20 +++- src/renderer/src/App.tsx | 16 +++- .../conversation/ConversationItem.tsx | 28 ++++-- .../conversation/ConversationSidebar.tsx | 9 ++ .../components/export/ExportContactPanel.tsx | 6 +- .../src/components/export/ExportWorkspace.tsx | 16 ++-- src/renderer/src/styles/conversation.scss | 9 ++ src/renderer/src/utils/contact-search.ts | 16 +--- src/shared/contact-search.ts | 89 ++++++++++++++++++ src/shared/types.ts | 2 + tests/component/conversation-sidebar.test.tsx | 31 +++++++ tests/unit/contact-search.test.ts | 16 +++- 17 files changed, 365 insertions(+), 44 deletions(-) create mode 100644 src/shared/contact-search.ts diff --git a/package.json b/package.json index cf8024f..11705e2 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "jsonrepair": "^3.15.0", "koffi": "^3.1.0", "openai": "^6.10.0", + "pinyin-pro": "^3.26.0", "qrcode": "^1.5.4", "sherpa-onnx-node": "1.13.3", "silk-wasm": "^3.7.1", @@ -160,4 +161,4 @@ "ffmpeg-static" ] } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a9db81..e3b1958 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,7 @@ specifiers: jsonrepair: ^3.15.0 koffi: ^3.1.0 openai: ^6.10.0 + pinyin-pro: ^3.26.0 postcss: ^8.5.26 prettier: ^3.7.4 qrcode: ^1.5.4 @@ -108,6 +109,7 @@ dependencies: jsonrepair: 3.15.0 koffi: 3.1.0 openai: 6.10.0 + pinyin-pro: 3.29.3 qrcode: 1.5.4 sherpa-onnx-node: 1.13.3 silk-wasm: 3.7.1 @@ -6835,6 +6837,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /pinyin-pro/3.29.3: + resolution: {integrity: sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==} + dev: false + /pirates/4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 438500a..3b55ef4 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -16,6 +16,11 @@ import { import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service' import type { ExportImageQuality } from '../../shared/image-quality' import { wcdbDebugLog } from '../wcdb-debug' +import { + buildContactSearchIndex, + filterContactSearchIndex, + type ContactSearchIndex +} from '../../shared/contact-search' export function getCurrentKey(): string { if (!dbRef) return '' @@ -46,6 +51,8 @@ export interface FormattedContact { remark?: string alias?: string wechatId?: string + wxid?: string + legacyIdentifier?: string isFolded?: boolean isMuted?: boolean } @@ -137,6 +144,7 @@ function normalizeMsgType(value: string | number | undefined): number { } let dbRef: WechatDb | null = null +let contactSearchIndexCache: { signature: string; index: ContactSearchIndex } | null = null let shutdownRequested = false export interface ImageMessageReference { @@ -156,6 +164,7 @@ export function setChatDb(db: WechatDb | null): boolean { } dbRef?.close() dbRef = db + contactSearchIndexCache = null imageMessageReferences.clear() return true } @@ -188,7 +197,7 @@ export function listContacts(filter?: string): FormattedContact[] { const contacts: FormattedContact[] = [] const groupContacts = dbRef.getAllGroupContacts() - const userList = dbRef.getUserList(filter) + const userList = dbRef.getUserList() const existingMd5s = new Set() for (const user of userList) { @@ -206,6 +215,8 @@ export function listContacts(filter?: string): FormattedContact[] { remark: user.remark, alias: user.alias, wechatId: user.wechatId, + wxid: user.wxid || user.m_nsUsrName, + legacyIdentifier: user.legacyIdentifier, isFolded: user.isFolded, isMuted: user.isMuted }) @@ -236,7 +247,30 @@ export function listContacts(filter?: string): FormattedContact[] { } } } - return contacts + if (!filter) return contacts + const signature = contacts + .map((contact) => + [ + contact.md5, + contact.m_nsNickName, + contact.remark, + contact.wechatNickname, + contact.alias, + contact.wechatId, + contact.wxid, + contact.legacyIdentifier, + contact.avatar, + contact.isFolded, + contact.isMuted + ] + .map((value) => String(value || '')) + .join('\u0001') + ) + .join('\u0002') + if (!contactSearchIndexCache || contactSearchIndexCache.signature !== signature) { + contactSearchIndexCache = { signature, index: buildContactSearchIndex(contacts) } + } + return filterContactSearchIndex(contactSearchIndexCache.index, filter) } export async function listContactsAsync(filter?: string): Promise { @@ -247,6 +281,7 @@ export async function listContactsAsync(filter?: string): Promise c.md5 === trimmed || c.m_nsUsrName.toLowerCase() === lower || + c.wechatId?.toLowerCase() === lower || + c.wxid?.toLowerCase() === lower || c.m_nsNickName.toLowerCase() === lower ) if (exact) return exact const partial = contacts.find( (c) => - c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower) + c.m_nsNickName.toLowerCase().includes(lower) || + c.m_nsUsrName.toLowerCase().includes(lower) || + c.wechatId?.toLowerCase().includes(lower) || + c.wxid?.toLowerCase().includes(lower) ) return partial || null } diff --git a/src/main/services/contact-resolution-service.ts b/src/main/services/contact-resolution-service.ts index 5c3a35a..131eef0 100644 --- a/src/main/services/contact-resolution-service.ts +++ b/src/main/services/contact-resolution-service.ts @@ -22,7 +22,8 @@ const aliases = (contact: Contact): Array<{ value: string; primary: boolean }> = { value: contact.m_nsNickName, primary: true }, { value: contact.remark || '', primary: false }, { value: contact.wechatNickname || '', primary: false }, - { value: contact.m_nsUsrName, primary: false }, + { value: contact.wechatId || contact.alias || '', primary: false }, + { value: contact.wxid || contact.m_nsUsrName, primary: false }, ...safeGroupAlias ].filter((item) => Boolean(normalizeContactName(item.value))) } @@ -81,7 +82,7 @@ export function resolveContact( const contact = matches.get(candidate.conversationId)!.contact return { matched: true, - personId: contact.m_nsUsrName, + personId: contact.wxid || contact.m_nsUsrName, conversationId: contact.md5, canonicalName: displayName(contact), displayName: candidate.displayName, diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 2ffe611..155bf41 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -15,6 +15,8 @@ export interface Wcdb4Session { remark?: string alias?: string wechatId?: string + wxid?: string + legacyIdentifier?: string isFolded?: boolean isMuted?: boolean raw: Record @@ -112,6 +114,13 @@ type Wcdb4ContactMemberNames = { remark: string } +export type Wcdb4ContactIdentity = { + username: string + wechatId?: string + wxid: string + legacyIdentifier?: string +} + export interface Wcdb4ImageHardlink { file_name?: string full_path?: string @@ -385,6 +394,7 @@ export class Wcdb4Client { private koffi: KoffiModule | null = null private handle: number | null = null private displayNameCache = new Map() + private contactIdentityCache = new Map() private avatarCache = new Map() private sessionStatusCache = new Map() private groupNicknameCache = new Map>() @@ -733,6 +743,7 @@ export class Wcdb4Client { this.sessionStatusesInFlight = null this.sessionStatusesUpdatedAt = 0 this.displayNameCache.clear() + this.contactIdentityCache?.clear() this.avatarCache.clear() this.sessionStatusCache.clear() this.groupNicknameCache.clear() @@ -933,6 +944,32 @@ export class Wcdb4Client { return this.cachedSessions } + getContactIdentity(username: string): Wcdb4ContactIdentity | undefined { + return this.contactIdentityCache.get(username) + } + + async hydrateContactIdentitiesAsync(usernames: string[]): Promise { + if (!this.wcdbExecQuery || usernames.length === 0) return + const missing = this.uniq(usernames).filter( + (username) => !this.contactIdentityCache.has(username) + ) + if (missing.length === 0) return + const inList = missing.map((username) => `'${username.replace(/'/g, "''")}'`).join(',') + try { + const rows = await this.callJsonAsync[]>( + this.wcdbExecQuery as unknown as KoffiAsyncFunction, + 'contact', + '', + `SELECT * FROM contact WHERE username IN (${inList})` + ) + for (const identity of this.contactIdentitiesFromRows(rows)) { + this.contactIdentityCache.set(identity.username, identity) + } + } catch { + // Older native builds may not expose the contact table. + } + } + async getSessionsAsync(options: Wcdb4SessionQueryOptions = {}): Promise { const hydrateDisplayNames = options.hydrateDisplayNames !== false if (this.cachedSessions) { @@ -1030,6 +1067,7 @@ export class Wcdb4Client { this.sessionStatusCache.clear() this.sessionStatusesUpdatedAt = 0 this.sessionDisplayNamesHydrated = false + this.contactIdentityCache?.clear() } invalidateAvatarCache(usernames?: string[]): void { @@ -2793,6 +2831,7 @@ export class Wcdb4Client { 'username', 'user_name', 'userName', + 'm_nsUsrName', 'usrName', 'UsrName', 'talker', @@ -2807,6 +2846,7 @@ export class Wcdb4Client { 'displayName', 'display_name', 'remark', + 'm_nsNickName', 'name' ]) const wechatNickname = this.pickString(row, [ @@ -2814,6 +2854,7 @@ export class Wcdb4Client { 'wechat_nickname', 'nickname', 'nickName', + 'm_nsNickName', 'name' ]) const remark = this.pickString(row, [ @@ -2827,10 +2868,22 @@ export class Wcdb4Client { const wechatId = this.pickString(row, [ 'wechatId', 'wechat_id', + 'wechatID', 'wxAccount', 'wx_account', 'account' ]) + const wxid = this.pickString(row, ['wxid', 'wx_id', 'internalWxid']) || username + const legacyIdentifier = this.pickString(row, [ + 'legacyIdentifier', + 'legacy_identifier', + 'originalIdentifier', + 'original_identifier', + 'originalUsername', + 'original_username', + 'initialIdentifier', + 'initial_identifier' + ]) const status = this.sessionStatusCache.get(username) return { username, @@ -2839,6 +2892,8 @@ export class Wcdb4Client { remark, alias, wechatId: wechatId || alias, + wxid, + legacyIdentifier: legacyIdentifier || undefined, isFolded: status?.isFolded, isMuted: status?.isMuted, raw: row @@ -3171,6 +3226,42 @@ export class Wcdb4Client { return result } + private contactIdentitiesFromRows(rows: Record[]): Wcdb4ContactIdentity[] { + if (!Array.isArray(rows)) return [] + return rows.flatMap((row) => { + const username = this.pickString(row, ['username', 'user_name', 'userName', 'm_nsUsrName']) + if (!username) return [] + const alias = this.pickString(row, ['alias', 'aliasName', 'alias_name', 'm_nsAlias']) + const wechatId = this.pickString(row, [ + 'wechatId', + 'wechat_id', + 'wechatID', + 'wxAccount', + 'wx_account', + 'account' + ]) + const wxid = this.pickString(row, ['wxid', 'wx_id', 'internalWxid']) || username + const legacyIdentifier = this.pickString(row, [ + 'legacyIdentifier', + 'legacy_identifier', + 'originalIdentifier', + 'original_identifier', + 'originalUsername', + 'original_username', + 'initialIdentifier', + 'initial_identifier' + ]) + return [ + { + username, + wechatId: wechatId || alias || undefined, + wxid, + legacyIdentifier: legacyIdentifier || undefined + } + ] + }) + } + private readContactAvatarUrls(usernames: string[]): Map { const result = new Map() if (!this.wcdbExecQuery || usernames.length === 0) return result diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index cfaf787..0f92d44 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -9,6 +9,8 @@ export interface UserContact { remark?: string alias?: string wechatId?: string + wxid?: string + legacyIdentifier?: string isFolded?: boolean isMuted?: boolean } @@ -105,7 +107,15 @@ export class WechatDb { wechatNickname: session.wechatNickname, remark: session.remark, alias: session.alias, - wechatId: session.wechatId, + wechatId: + session.wechatId || this.wcdb4Client.getContactIdentity(session.username)?.wechatId, + wxid: + session.wxid || + this.wcdb4Client.getContactIdentity(session.username)?.wxid || + session.username, + legacyIdentifier: + session.legacyIdentifier || + this.wcdb4Client.getContactIdentity(session.username)?.legacyIdentifier, isFolded: session.isFolded, isMuted: session.isMuted })) @@ -117,11 +127,19 @@ export class WechatDb { contact.nickname, contact.alias, contact.wechatId, + contact.wxid, contact.m_nsUsrName ].some((value) => value?.toLocaleLowerCase().includes(keyword)) }) } + public async hydrateContactIdentitiesAsync(): Promise { + const sessions = this.wcdb4Client.getSessions() + await this.wcdb4Client.hydrateContactIdentitiesAsync( + sessions.map((session) => session.username) + ) + } + public getAllGroupContacts(): Record { const groupContacts: Record = {} for (const session of this.wcdb4Client.getSessions()) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5f5a496..4490ed8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -45,7 +45,11 @@ import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt' import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace' import { LogsWorkspace } from './features/logs/LogsWorkspace' import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar' -import { filterContacts } from './utils/contact-search' +import { + buildContactSearchIndex, + filterContactSearchIndex, + type ContactSearchIndex +} from '../../shared/contact-search' const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MAX_WIDTH = 380 @@ -140,6 +144,9 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => { return true } +const filterContactList = (index: ContactSearchIndex, keyword: string): Contact[] => + filterContactSearchIndex(index, keyword) + type GroupSnapshot = { roomId: string memberCount: number @@ -218,6 +225,7 @@ function App(): React.ReactElement { const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false) const [dbKey, setDbKey] = useState(getDevelopmentDatabaseKey) const [contacts, setContacts] = useState([]) + const contactSearchIndex = React.useMemo(() => buildContactSearchIndex(contacts), [contacts]) const [selectedContact, setSelectedContact] = useState(null) const [messages, setMessages] = useState([]) const [isMessagesLoading, setIsMessagesLoading] = useState(false) @@ -591,7 +599,9 @@ function App(): React.ReactElement { options?.onProgress?.('正在加载联系人...', 35) const list = await window.api.getContacts() setContacts(list) - setFilteredContacts(filterContacts(list, options?.filterKeyword || '')) + setFilteredContacts( + filterContactSearchIndex(buildContactSearchIndex(list), options?.filterKeyword || '') + ) const runId = ++contactAvatarHydrationRunRef.current const hydrate = (): Promise => hydrateContactAvatars(list, runId, options?.onProgress) if (options?.waitForAvatars) { @@ -1436,7 +1446,7 @@ function App(): React.ReactElement { ]) const handleSearchContacts = (keyword: string): void => { - setFilteredContacts(filterContacts(contacts, keyword)) + setFilteredContacts(filterContactList(contactSearchIndex, keyword)) } const handleRefreshContacts = async (filterKeyword: string): Promise => { diff --git a/src/renderer/src/components/conversation/ConversationItem.tsx b/src/renderer/src/components/conversation/ConversationItem.tsx index 5deb607..68c37bf 100644 --- a/src/renderer/src/components/conversation/ConversationItem.tsx +++ b/src/renderer/src/components/conversation/ConversationItem.tsx @@ -13,9 +13,10 @@ export function ConversationItem({ onSelect }: ConversationItemProps): React.ReactElement { const nickname = contact.m_nsNickName?.trim() - const wxid = contact.m_nsUsrName - const displayName = nickname || wxid || '未命名会话' - const initial = (displayName || wxid || '?').charAt(0) + const avatarUsername = contact.m_nsUsrName + const internalWxid = contact.wxid || avatarUsername + const displayName = nickname || internalWxid || '未命名会话' + const initial = (displayName || internalWxid || '?').charAt(0) const [repairedAvatar, setRepairedAvatar] = useState<{ username: string source: string @@ -23,22 +24,22 @@ export function ConversationItem({ }>() const [failedAvatar, setFailedAvatar] = useState<{ username: string; source: string }>() const repairedSource = - repairedAvatar?.username === wxid && repairedAvatar.replaces === contact.avatar + repairedAvatar?.username === avatarUsername && repairedAvatar.replaces === contact.avatar ? repairedAvatar.source : undefined const avatar = repairedSource || contact.avatar - const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar + const avatarFailed = failedAvatar?.username === avatarUsername && failedAvatar.source === avatar const handleAvatarError = (): void => { if (!avatar || avatarFailed) return - setFailedAvatar({ username: wxid, source: avatar }) + setFailedAvatar({ username: avatarUsername, source: avatar }) if (avatar.startsWith('data:')) return void window.api - .getContactAvatars([wxid], { refresh: true }) + .getContactAvatars([avatarUsername], { refresh: true }) .then((avatars) => { - const fallback = avatars[wxid] + const fallback = avatars[avatarUsername] if (!fallback || fallback === avatar) return - setRepairedAvatar({ username: wxid, source: fallback, replaces: avatar }) + setRepairedAvatar({ username: avatarUsername, source: fallback, replaces: avatar }) setFailedAvatar(undefined) }) .catch(() => undefined) @@ -49,7 +50,11 @@ export function ConversationItem({ type="button" className={`conversation-item ${active ? 'active' : ''}`} onClick={() => onSelect(contact)} - title={wxid && wxid !== displayName ? wxid : displayName} + title={ + contact.wechatId || + contact.alias || + (internalWxid !== displayName ? internalWxid : displayName) + } > @@ -68,6 +73,9 @@ export function ConversationItem({ {displayName} + {contact.wechatId && ( + 微信号: {contact.wechatId} + )} ) diff --git a/src/renderer/src/components/conversation/ConversationSidebar.tsx b/src/renderer/src/components/conversation/ConversationSidebar.tsx index 64f7d13..b0aa304 100644 --- a/src/renderer/src/components/conversation/ConversationSidebar.tsx +++ b/src/renderer/src/components/conversation/ConversationSidebar.tsx @@ -151,6 +151,15 @@ export function ConversationSidebar({ const handleSearchChange = (term: string): void => { setSearchTerm(term) onSearch(term) + if (term.trim()) { + setExpandedSections((current) => ({ + ...current, + groups: true, + folded: true, + officialAccounts: true, + contacts: true + })) + } } const handleRefresh = async (): Promise => { diff --git a/src/renderer/src/components/export/ExportContactPanel.tsx b/src/renderer/src/components/export/ExportContactPanel.tsx index 0d00719..747a016 100644 --- a/src/renderer/src/components/export/ExportContactPanel.tsx +++ b/src/renderer/src/components/export/ExportContactPanel.tsx @@ -75,7 +75,11 @@ function ExportContactRow({ {name} - {contact.type === 'group' ? '群聊' : '联系人'} + {contact.type === 'group' + ? '群聊' + : contact.wechatId + ? `联系人 · 微信号: ${contact.wechatId}` + : '联系人'} {showSelection && ( diff --git a/src/renderer/src/components/export/ExportWorkspace.tsx b/src/renderer/src/components/export/ExportWorkspace.tsx index a7bfe66..cb03865 100644 --- a/src/renderer/src/components/export/ExportWorkspace.tsx +++ b/src/renderer/src/components/export/ExportWorkspace.tsx @@ -23,6 +23,10 @@ import type { import { displayName } from './exportUtils' import type { VoiceModelStatus } from '../../../../shared/voice-recognition' import { resolveMemberName } from '../../../../shared/member-names' +import { + buildContactSearchIndex, + filterContactSearchIndex +} from '../../../../shared/contact-search' const ALL_CONTACT_TYPES: ExportContactType[] = ['group', 'user'] const contactTypeKey = (types: ExportContactType[] | undefined): string => @@ -57,6 +61,7 @@ export function ExportWorkspace({ const [selectedContacts, setSelectedContacts] = useState(() => initialSelection ? [initialSelection] : [] ) + const contactSearchIndex = useMemo(() => buildContactSearchIndex(contacts), [contacts]) const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '') const [previewByContact, setPreviewByContact] = useState>({}) const [range, setRange] = useState(() => (runningAllTask ? 'all' : 'today')) @@ -109,15 +114,8 @@ export function ExportWorkspace({ }, [exportAll, loadPreviewMessages, previewByContact, selectedContacts]) const filteredContacts = useMemo(() => { - const keyword = contactFilter.trim().toLowerCase() - return contacts.filter((contact) => { - if (contactType !== 'all' && contact.type !== contactType) return false - if (!keyword) return true - return [contact.m_nsNickName, contact.m_nsUsrName].some((value) => - value.toLowerCase().includes(keyword) - ) - }) - }, [contactFilter, contactType, contacts]) + return filterContactSearchIndex(contactSearchIndex, contactFilter, contactType) + }, [contactFilter, contactSearchIndex, contactType]) const activeContact = selectedContacts.find((contact) => contact.md5 === activeContactId) || diff --git a/src/renderer/src/styles/conversation.scss b/src/renderer/src/styles/conversation.scss index bbe4647..d817bf5 100644 --- a/src/renderer/src/styles/conversation.scss +++ b/src/renderer/src/styles/conversation.scss @@ -287,6 +287,15 @@ white-space: nowrap; } +.conversation-item-meta { + overflow: hidden; + color: var(--wxex-text-muted); + font-size: 10px; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + .conversation-sidebar-account { box-sizing: border-box; flex: 0 0 auto; diff --git a/src/renderer/src/utils/contact-search.ts b/src/renderer/src/utils/contact-search.ts index 5ed6cba..a17dd28 100644 --- a/src/renderer/src/utils/contact-search.ts +++ b/src/renderer/src/utils/contact-search.ts @@ -1,17 +1,7 @@ import type { Contact } from '../../../shared/types' +import { buildContactSearchIndex, filterContactSearchIndex } from '../../../shared/contact-search' export function filterContacts(list: Contact[], keyword: string): Contact[] { - const query = keyword.trim().toLocaleLowerCase() - if (!query) return list - - return list.filter((contact) => - [ - contact.remark, - contact.wechatNickname, - contact.m_nsNickName, - contact.alias, - contact.wechatId, - contact.m_nsUsrName - ].some((value) => value?.trim().toLocaleLowerCase().includes(query)) - ) + if (!keyword.trim()) return list + return filterContactSearchIndex(buildContactSearchIndex(list), keyword) } diff --git a/src/shared/contact-search.ts b/src/shared/contact-search.ts new file mode 100644 index 0000000..760ffff --- /dev/null +++ b/src/shared/contact-search.ts @@ -0,0 +1,89 @@ +import { pinyin } from 'pinyin-pro' +import type { Contact } from './types' + +export interface ContactSearchText { + raw: string + normalized: string + pinyin: string + initials: string +} + +export interface ContactSearchIndexEntry { + contact: Contact + fields: ContactSearchText[] +} + +export type ContactSearchIndex = ContactSearchIndexEntry[] + +export function normalizeContactSearchIdentity(value: unknown): string { + return String(value ?? '') + .normalize('NFKC') + .trim() + .toLocaleLowerCase() +} + +function buildPinyin(value: string): { full: string; initials: string } { + if (!value) return { full: '', initials: '' } + try { + const syllables = pinyin(value, { toneType: 'none', type: 'array' }) + .map((item) => normalizeContactSearchIdentity(item)) + .filter(Boolean) + return { + full: syllables.join(''), + initials: syllables.map((item) => item.charAt(0)).join('') + } + } catch { + return { full: '', initials: '' } + } +} + +function searchableText(value: unknown, withPinyin: boolean): ContactSearchText | null { + const raw = String(value ?? '').trim() + const normalized = normalizeContactSearchIdentity(raw) + if (!normalized) return null + const phonetic = withPinyin ? buildPinyin(raw) : { full: '', initials: '' } + return { raw, normalized, pinyin: phonetic.full, initials: phonetic.initials } +} + +export function buildContactSearchIndex(contacts: Contact[]): ContactSearchIndex { + return contacts.map((contact) => ({ + contact, + fields: [ + // The WCDB session shape can expose a contact remark as m_nsNickName + // when the dedicated remark column is unavailable. Index it phonetically + // so both normalized data shapes support the same search behavior. + searchableText(contact.m_nsNickName, true), + searchableText(contact.remark, true), + searchableText(contact.wechatNickname, true), + searchableText(contact.alias, false), + searchableText(contact.wechatId, false), + searchableText( + contact.legacyIdentifier === (contact.wxid || contact.m_nsUsrName) + ? undefined + : contact.wxid || contact.m_nsUsrName, + false + ) + ].filter((field): field is ContactSearchText => Boolean(field)) + })) +} + +export function matchesContactSearchText(field: ContactSearchText, query: string): boolean { + const normalizedQuery = normalizeContactSearchIdentity(query) + if (!normalizedQuery) return true + return [field.normalized, field.pinyin, field.initials].some((value) => + value.includes(normalizedQuery) + ) +} + +export function filterContactSearchIndex( + index: ContactSearchIndex, + query: string, + type: Contact['type'] | 'all' = 'all' +): Contact[] { + return index + .filter(({ contact, fields }) => { + if (type !== 'all' && contact.type !== type) return false + return fields.some((field) => matchesContactSearchText(field, query)) + }) + .map(({ contact }) => contact) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index bd13c71..c0e282b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -11,6 +11,8 @@ export interface Contact { remark?: string alias?: string wechatId?: string + wxid?: string + legacyIdentifier?: string isFolded?: boolean isMuted?: boolean } diff --git a/tests/component/conversation-sidebar.test.tsx b/tests/component/conversation-sidebar.test.tsx index 8f9d02e..74c6ea9 100644 --- a/tests/component/conversation-sidebar.test.tsx +++ b/tests/component/conversation-sidebar.test.tsx @@ -57,4 +57,35 @@ describe('ConversationSidebar', () => { await userEvent.click(screen.getByRole('button', { name: '公众号 (1)' })) expect(screen.getByText('测试公众号')).toBeInTheDocument() }) + + it('reveals contact results when searching a collapsed contact section', async () => { + render( + + undefined)} + /> + + ) + + expect(screen.queryByText('微信昵称')).not.toBeInTheDocument() + await userEvent.type(screen.getByRole('searchbox', { name: '搜索会话' }), 'xiaohao') + expect(screen.getByText('微信昵称')).toBeInTheDocument() + }) }) diff --git a/tests/unit/contact-search.test.ts b/tests/unit/contact-search.test.ts index 43578ae..dc91dc7 100644 --- a/tests/unit/contact-search.test.ts +++ b/tests/unit/contact-search.test.ts @@ -14,7 +14,7 @@ const contact: Contact = { } describe('filterContacts', () => { - it.each(['Shinven', '小号', '工作', 'abc123', 'WECHAT_ABC', 'WXID_xxy']) ( + it.each(['Shinven', '小号', '工作', 'gongzuoshouji', 'gzsj', 'abc123', 'WECHAT_ABC', 'WXID_xxy'])( 'matches all contact identity fields with %s', (query) => { expect(filterContacts([contact], ` ${query} `)).toEqual([contact]) @@ -26,4 +26,18 @@ describe('filterContacts', () => { expect(filterContacts(list, ' ')).toBe(list) expect(filterContacts(list, 'not-found')).toEqual([]) }) + + it('matches pinyin when the Chinese name is stored in m_nsNickName', () => { + const list = [{ ...contact, remark: '', wechatNickname: '' }] + + expect(filterContacts(list, 'xiaohao')).toEqual(list) + expect(filterContacts(list, 'xh')).toEqual(list) + }) + + it('matches pinyin for a dedicated Chinese remark', () => { + const list = [{ ...contact, m_nsNickName: 'Shinven', remark: '小号' }] + + expect(filterContacts(list, 'xiaohao')).toEqual(list) + expect(filterContacts(list, 'xh')).toEqual(list) + }) })