refactor: 重构一下档案搜索功能 支持拼音微信号等

This commit is contained in:
电摇小子
2026-09-03 23:38:12 +08:00
parent bde3bc537f
commit 21e299942b
17 changed files with 365 additions and 44 deletions
+2 -1
View File
@@ -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"
]
}
}
}
+6
View File
@@ -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'}
+43 -3
View File
@@ -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<string>()
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<FormattedContact[]> {
@@ -247,6 +281,7 @@ export async function listContactsAsync(filter?: string): Promise<FormattedConta
hydrateDisplayNames: true,
hydrateStatuses: true
})
await dbRef.hydrateContactIdentitiesAsync?.()
return listContacts(filter)
}
@@ -703,13 +738,18 @@ export function resolveMd5(query: string): FormattedContact | null {
(c) =>
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
}
@@ -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,
+91
View File
@@ -15,6 +15,8 @@ export interface Wcdb4Session {
remark?: string
alias?: string
wechatId?: string
wxid?: string
legacyIdentifier?: string
isFolded?: boolean
isMuted?: boolean
raw: Record<string, unknown>
@@ -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<string, string>()
private contactIdentityCache = new Map<string, Wcdb4ContactIdentity>()
private avatarCache = new Map<string, string>()
private sessionStatusCache = new Map<string, { isFolded: boolean; isMuted: boolean }>()
private groupNicknameCache = new Map<string, Map<string, string>>()
@@ -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<void> {
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<Record<string, unknown>[]>(
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<Wcdb4Session[]> {
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<string, unknown>[]): 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<string, string> {
const result = new Map<string, string>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
+19 -1
View File
@@ -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<void> {
const sessions = this.wcdb4Client.getSessions()
await this.wcdb4Client.hydrateContactIdentitiesAsync(
sessions.map((session) => session.username)
)
}
public getAllGroupContacts(): Record<string, string> {
const groupContacts: Record<string, string> = {}
for (const session of this.wcdb4Client.getSessions()) {
+13 -3
View File
@@ -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<Contact[]>([])
const contactSearchIndex = React.useMemo(() => buildContactSearchIndex(contacts), [contacts])
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
const [messages, setMessages] = useState<Message[]>([])
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<void> => 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<void> => {
@@ -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)
}
>
<span className="conversation-item-active-mark" aria-hidden />
<span className="conversation-item-avatar">
@@ -68,6 +73,9 @@ export function ConversationItem({
</span>
<span className="conversation-item-body">
<span className="conversation-item-name">{displayName}</span>
{contact.wechatId && (
<span className="conversation-item-meta">微信号: {contact.wechatId}</span>
)}
</span>
</button>
)
@@ -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<void> => {
@@ -75,7 +75,11 @@ function ExportContactRow({
{name}
</strong>
<small className="text-[11px] text-muted-foreground">
{contact.type === 'group' ? '群聊' : '联系人'}
{contact.type === 'group'
? '群聊'
: contact.wechatId
? `联系人 · 微信号: ${contact.wechatId}`
: '联系人'}
</small>
</span>
{showSelection && (
@@ -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<Contact[]>(() =>
initialSelection ? [initialSelection] : []
)
const contactSearchIndex = useMemo(() => buildContactSearchIndex(contacts), [contacts])
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
const [range, setRange] = useState<ExportRange>(() => (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) ||
@@ -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;
+3 -13
View File
@@ -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)
}
+89
View File
@@ -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)
}
+2
View File
@@ -11,6 +11,8 @@ export interface Contact {
remark?: string
alias?: string
wechatId?: string
wxid?: string
legacyIdentifier?: string
isFolded?: boolean
isMuted?: boolean
}
@@ -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(
<TooltipProvider>
<ConversationSidebar
contacts={[
{
m_nsUsrName: 'wxid_fixture',
m_nsNickName: '微信昵称',
md5: 'contact-md5',
type: 'user',
remark: '小号'
}
]}
selectedContact={null}
onSelectContact={vi.fn()}
onSearch={vi.fn()}
onContentFilter={vi.fn()}
width={320}
selfInfo={null}
dbReady
onOpenSettings={vi.fn()}
onRefresh={vi.fn(async () => undefined)}
/>
</TooltipProvider>
)
expect(screen.queryByText('微信昵称')).not.toBeInTheDocument()
await userEvent.type(screen.getByRole('searchbox', { name: '搜索会话' }), 'xiaohao')
expect(screen.getByText('微信昵称')).toBeInTheDocument()
})
})
+15 -1
View File
@@ -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)
})
})