mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
refactor: 重构一下档案搜索功能 支持拼音微信号等
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+6
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user