From 7be2c692562f373d9a07074cd32bcfb4f04eff20 Mon Sep 17 00:00:00 2001 From: hicccc77 <98377878+hicccc77@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:15:51 +0800 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20getAvatarUrls?= =?UTF-8?q?=20=E7=AB=9E=E6=80=81=E5=AF=BC=E8=87=B4=20handle=20=E4=B8=BA=20?= =?UTF-8?q?null=20=E7=9A=84=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 await setImmediate 让出控制权前先捕获 handle, await 后重新校验 handle 是否仍有效,避免连接关闭后 向 koffi DLL 传入 null 导致 TypeError。 --- electron/services/wcdbCore.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 7e69caa..517fedf 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1488,10 +1488,19 @@ export class WcdbCore { } // 让出控制权,避免阻塞事件循环 + const handle = this.handle await new Promise(resolve => setImmediate(resolve)) + // await 后 handle 可能已被关闭,需重新检查 + if (handle === null || this.handle !== handle) { + if (Object.keys(resultMap).length > 0) { + return { success: true, map: resultMap, error: '连接已断开' } + } + return { success: false, error: '连接已断开' } + } + const outPtr = [null as any] - const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) + const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr) // DLL 调用后再次让出控制权 await new Promise(resolve => setImmediate(resolve)) From 6741a94c1b9d9ceb73365973a497856ed95a5ccf Mon Sep 17 00:00:00 2001 From: pisauvage <8958673+pisauvage@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:29:54 +0900 Subject: [PATCH 02/11] fix(mac): support non-wxid account dirs for image keys --- electron/services/keyServiceMac.ts | 197 ++++++++++++++++++++++------- 1 file changed, 148 insertions(+), 49 deletions(-) diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 5f1e34f..9f1e9da 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -488,26 +488,39 @@ export class KeyServiceMac { const wxidCandidates = this.collectWxidCandidates(accountPath, wxid) if (wxidCandidates.length === 0) { - return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' } + return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' } } + const accountPathCandidates = this.collectAccountPathCandidates(accountPath) + // 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错 - let verifyCiphertext: Buffer | null = null - if (accountPath && existsSync(accountPath)) { - const template = await this._findTemplateData(accountPath, 32) - verifyCiphertext = template.ciphertext - } - if (verifyCiphertext) { + if (accountPathCandidates.length > 0) { onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) - for (const candidateWxid of wxidCandidates) { - for (const code of codes) { - const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) - if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue - onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) - return { success: true, xorKey, aesKey } + for (const candidateAccountPath of accountPathCandidates) { + if (!existsSync(candidateAccountPath)) continue + const template = await this._findTemplateData(candidateAccountPath, 32) + if (!template.ciphertext) continue + + const accountDirWxid = basename(candidateAccountPath) + const orderedWxids: string[] = [] + this.pushAccountIdCandidates(orderedWxids, accountDirWxid) + for (const candidate of wxidCandidates) { + this.pushAccountIdCandidates(orderedWxids, candidate) + } + + for (const candidateWxid of orderedWxids) { + for (const code of codes) { + const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) + if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue + onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) + return { success: true, xorKey, aesKey } + } } } - return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } + return { + success: false, + error: '缓存 code 与当前账号 wxid 未匹配。若数据库密钥获取后微信刚刚崩溃并重启,可能当前选中的账号目录已经不是最新会话;请先重新扫描 wxid,或直接使用内存扫描。' + } } // 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code) @@ -542,16 +555,21 @@ export class KeyServiceMac { onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) - // 2. 找微信 PID - const pid = await this.findWeChatPid() - if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } - - onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) - - // 3. 持续轮询内存扫描 + // 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化 const deadline = Date.now() + 60_000 let scanCount = 0 + let lastPid: number | null = null while (Date.now() < deadline) { + const pid = await this.findWeChatPid() + if (!pid) { + onProgress?.('暂未检测到微信主进程,请确认微信已经重新打开...') + await new Promise(r => setTimeout(r, 2000)) + continue + } + if (lastPid !== pid) { + lastPid = pid + onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) + } scanCount++ onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) @@ -764,7 +782,7 @@ export class KeyServiceMac { } const current = chunk.subarray(0, bytesRead) - const data = trailing ? Buffer.concat([trailing, current]) : current + const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext) if (key) return key // 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中) @@ -793,8 +811,8 @@ export class KeyServiceMac { } const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]' let stdout = '', stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) - child.stderr.on('data', (chunk: Buffer) => { + child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() console.log(tag, chunk.toString().trim()) }) @@ -819,11 +837,8 @@ export class KeyServiceMac { } private async findWeChatPid(): Promise { - const { execSync } = await import('child_process') try { - const output = execSync('pgrep -x WeChat', { encoding: 'utf8' }) - const pid = parseInt(output.trim()) - return isNaN(pid) ? null : pid + return await this.getWeChatPid() } catch { return null } @@ -840,12 +855,70 @@ export class KeyServiceMac { this.machPortDeallocate = null } + private normalizeAccountId(value: string): string { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + + private isIgnoredAccountName(value: string): boolean { + const lowered = String(value || '').trim().toLowerCase() + if (!lowered) return true + return lowered === 'xwechat_files' || + lowered === 'all_users' || + lowered === 'backup' || + lowered === 'wmpf' || + lowered === 'app_data' + } + + private isReasonableAccountId(value: string): boolean { + const trimmed = String(value || '').trim() + if (!trimmed) return false + if (trimmed.includes('/') || trimmed.includes('\\')) return false + return !this.isIgnoredAccountName(trimmed) + } + + private isAccountDirPath(entryPath: string): boolean { + return existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'msg')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) + } + + private resolveXwechatRootFromPath(accountPath?: string): string | null { + const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '') + if (!normalized) return null + const marker = '/xwechat_files' + const markerIdx = normalized.indexOf(marker) + if (markerIdx < 0) return null + return normalized.slice(0, markerIdx + marker.length) + } + + private pushAccountIdCandidates(candidates: string[], value?: string): void { + const pushUnique = (item: string) => { + const trimmed = String(item || '').trim() + if (!trimmed || candidates.includes(trimmed)) return + candidates.push(trimmed) + } + + const raw = String(value || '').trim() + if (!this.isReasonableAccountId(raw)) return + pushUnique(raw) + const normalized = this.normalizeAccountId(raw) + if (normalized && normalized !== raw && this.isReasonableAccountId(normalized)) { + pushUnique(normalized) + } + } + private cleanWxid(wxid: string): string { - const first = wxid.indexOf('_') - if (first === -1) return wxid - const second = wxid.indexOf('_', first + 1) - if (second === -1) return wxid - return wxid.substring(0, second) + return this.normalizeAccountId(wxid) } private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { @@ -858,32 +931,59 @@ export class KeyServiceMac { private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] { const candidates: string[] = [] - const pushUnique = (value: string) => { - const v = String(value || '').trim() - if (!v || candidates.includes(v)) return - candidates.push(v) - } // 1) 显式传参优先 - if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam) + this.pushAccountIdCandidates(candidates, wxidParam) if (accountPath) { const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') const dirName = basename(normalized) - // 2) 当前目录名为 wxid_* - if (dirName.startsWith('wxid_')) pushUnique(dirName) + // 2) 当前目录名本身就是账号目录 + this.pushAccountIdCandidates(candidates, dirName) - // 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录 - const marker = '/xwechat_files' - const markerIdx = normalized.indexOf(marker) - if (markerIdx >= 0) { - const root = normalized.slice(0, markerIdx + marker.length) + // 3) 从 xwechat_files 根目录枚举全部账号目录 + const root = this.resolveXwechatRootFromPath(accountPath) + if (root) { if (existsSync(root)) { try { for (const entry of readdirSync(root, { withFileTypes: true })) { if (!entry.isDirectory()) continue - if (!entry.name.startsWith('wxid_')) continue - pushUnique(entry.name) + const entryPath = join(root, entry.name) + if (!this.isAccountDirPath(entryPath)) continue + this.pushAccountIdCandidates(candidates, entry.name) + } + } catch { + // ignore + } + } + } + } + + if (candidates.length === 0) candidates.push('unknown') + return candidates + } + + private collectAccountPathCandidates(accountPath?: string): string[] { + const candidates: string[] = [] + const pushUnique = (value?: string) => { + const v = String(value || '').trim() + if (!v || candidates.includes(v)) return + candidates.push(v) + } + + if (accountPath) pushUnique(accountPath) + + if (accountPath) { + const root = this.resolveXwechatRootFromPath(accountPath) + if (root) { + if (existsSync(root)) { + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const entryPath = join(root, entry.name) + if (!this.isAccountDirPath(entryPath)) continue + if (!this.isReasonableAccountId(entry.name)) continue + pushUnique(entryPath) } } catch { // ignore @@ -892,7 +992,6 @@ export class KeyServiceMac { } } - pushUnique('unknown') return candidates } From f4fd5bb797ce57dc864e35525457e10f3e0abee5 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Sun, 15 Mar 2026 14:45:17 +0800 Subject: [PATCH 03/11] fix: support self sns filter and export --- src/components/Sns/SnsFilterPanel.tsx | 35 ++++++-- src/pages/SnsPage.scss | 106 +++++++++++++++++++---- src/pages/SnsPage.tsx | 117 ++++++++++++++++++++------ 3 files changed, 208 insertions(+), 50 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 654d543..ed7e3de 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -8,6 +8,7 @@ interface Contact { avatarUrl?: string postCount?: number postCountStatus?: 'idle' | 'loading' | 'ready' + isCurrentUser?: boolean } interface ContactsCountProgress { @@ -68,7 +69,7 @@ export const SnsFilterPanel: React.FC = ({ return '正在加载联系人...' } if (contacts.length === 0) { - return '暂无好友或曾经的好友' + return '暂无可筛选联系人' } return '没有找到联系人' } @@ -118,7 +119,7 @@ export const SnsFilterPanel: React.FC = ({
setContactSearch(e.target.value)} /> @@ -143,18 +144,26 @@ export const SnsFilterPanel: React.FC = ({
{filteredContacts.map(contact => { const isPostCountReady = contact.postCountStatus === 'ready' + const isPostCountLoading = contact.postCountStatus === 'loading' const isSelected = selectedContactLookup.has(contact.username) const isActive = activeContactUsername === contact.username + const displayName = contact.displayName || (contact.isCurrentUser ? '我' : contact.username) + const selectTitle = contact.isCurrentUser + ? (isSelected ? '取消选择当前账号的朋友圈' : '选择当前账号的朋友圈') + : (isSelected ? `取消选择 ${displayName}` : `选择 ${displayName}`) + const openTitle = contact.isCurrentUser + ? '查看我发的朋友圈' + : `查看 ${displayName} 的朋友圈` return (
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 45fcfbc..f8a8c90 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1295,6 +1295,26 @@ color: var(--text-primary); } + &.is-current-user .contact-main-btn { + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + border-color: color-mix(in srgb, var(--primary) 18%, var(--border-color)); + } + + &.is-current-user.is-selected .contact-main-btn { + background: rgba(var(--primary-rgb), 0.1); + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); + } + + &.is-current-user.is-active .contact-main-btn { + background: rgba(var(--primary-rgb), 0.15); + border-color: color-mix(in srgb, var(--primary) 54%, var(--border-color)); + } + + &.is-current-user .contact-name { + color: var(--text-primary); + font-weight: 600; + } + .contact-select-btn { width: 32px; height: 32px; @@ -1314,6 +1334,11 @@ color: var(--primary); } + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 72%, transparent); + outline-offset: 2px; + } + &.checked { color: var(--primary); } @@ -1336,6 +1361,11 @@ &:hover { background: var(--hover-bg); } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 72%, transparent); + outline-offset: 2px; + } } .contact-meta { @@ -1343,15 +1373,49 @@ min-width: 0; display: flex; flex-direction: column; - gap: 2px; + gap: 4px; + + .contact-name-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } .contact-name { + display: block; + flex: 1 1 auto; + min-width: 0; font-size: 14px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + + .contact-self-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 18px; + padding: 0 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + border: 1px solid rgba(var(--primary-rgb), 0.18); + } + + .contact-subtitle { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } } .contact-post-count-wrap { @@ -1368,6 +1432,10 @@ color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; + + &.is-empty { + letter-spacing: 0.08em; + } } .contact-post-count-loading { @@ -1751,8 +1819,9 @@ background: var(--bg-secondary); border-radius: var(--sns-border-radius-lg); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); - width: 480px; + width: 460px; max-width: 92vw; + max-height: calc(100vh - 48px); display: flex; flex-direction: column; border: 1px solid var(--border-color); @@ -1760,7 +1829,7 @@ animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); .export-dialog-header { - padding: 16px 20px; + padding: 14px 18px; border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary); display: flex; @@ -1796,10 +1865,13 @@ } .export-dialog-body { - padding: 20px; + padding: 16px 18px 18px; display: flex; flex-direction: column; - gap: 18px; + gap: 14px; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; } } @@ -1808,7 +1880,7 @@ align-items: center; gap: 8px; flex-wrap: wrap; - padding: 10px 14px; + padding: 8px 12px; background: var(--bg-tertiary); border-radius: 8px; border: 1px solid var(--border-color); @@ -1823,10 +1895,10 @@ } .filter-tag { - font-size: 13px; + font-size: 12px; color: var(--text-secondary); background: var(--bg-primary); - padding: 2px 10px; + padding: 2px 8px; border-radius: 6px; border: 1px solid var(--border-color); display: flex; @@ -1848,7 +1920,7 @@ .export-section { display: flex; flex-direction: column; - gap: 8px; + gap: 6px; } .export-section-header { @@ -1893,14 +1965,14 @@ .export-format-options { display: grid; grid-template-columns: 1fr 1fr; - gap: 10px; + gap: 8px; .format-option { display: flex; flex-direction: column; align-items: center; gap: 4px; - padding: 14px 10px; + padding: 12px 8px; border-radius: 10px; border: 2px solid var(--border-color); background: var(--bg-primary); @@ -2424,7 +2496,7 @@ .export-media-check-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); - gap: 8px; + gap: 6px; label { display: inline-flex; @@ -2432,7 +2504,7 @@ gap: 6px; font-size: 13px; color: var(--text-primary); - padding: 8px 10px; + padding: 7px 9px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary); @@ -2550,10 +2622,10 @@ display: flex; align-items: center; gap: 8px; - padding: 10px 12px; + padding: 8px 10px; background: var(--bg-secondary); border-radius: 8px; - margin: 8px 0; + margin: 4px 0; color: var(--text-tertiary); font-size: 12px; border: 1px dashed var(--border-color); @@ -2566,8 +2638,8 @@ .export-actions { display: flex; - gap: 12px; - margin-top: 24px; + gap: 10px; + margin-top: 14px; button { flex: 1; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index fb8f009..07e7d43 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -39,6 +39,7 @@ interface Contact { lastSessionTimestamp?: number postCount?: number postCountStatus?: ContactPostCountStatus + isCurrentUser?: boolean } interface SidebarUserProfile { @@ -193,11 +194,15 @@ export default function SnsPage() { }, [contacts]) useEffect(() => { const contactLookup = new Set(contacts.map((contact) => contact.username)) + const currentUserUsername = normalizeAccountId(currentUserProfile.wxid) + if (currentUserUsername) { + contactLookup.add(currentUserUsername) + } setSelectedContactUsernames((prev) => { const next = prev.filter((username) => contactLookup.has(username)) return next.length === prev.length ? prev : next }) - }, [contacts]) + }, [contacts, currentUserProfile.wxid]) useEffect(() => { overviewStatsRef.current = overviewStats }, [overviewStats]) @@ -385,27 +390,88 @@ export default function SnsPage() { }) || null }, [contacts, currentUserProfile.alias, currentUserProfile.displayName, currentUserProfile.wxid]) + const currentUserFilterContact = useMemo(() => { + const normalizedWxid = normalizeAccountId(currentUserProfile.wxid) + const fallbackDisplayName = String( + resolvedCurrentUserContact?.displayName + || currentUserProfile.displayName + || '我' + ).trim() + const fallbackAvatarUrl = resolvedCurrentUserContact?.avatarUrl || currentUserProfile.avatarUrl + const fallbackPostCount = typeof resolvedCurrentUserContact?.postCount === 'number' + ? normalizePostCount(resolvedCurrentUserContact.postCount) + : (typeof overviewStats.myPosts === 'number' && Number.isFinite(overviewStats.myPosts) + ? Math.max(0, Math.floor(overviewStats.myPosts)) + : undefined) + const fallbackPostStatus: ContactPostCountStatus = resolvedCurrentUserContact + ? (resolvedCurrentUserContact.postCountStatus || (fallbackPostCount !== undefined ? 'ready' : 'idle')) + : (fallbackPostCount !== undefined + ? 'ready' + : (contactsLoading || overviewStatsStatus === 'loading' ? 'loading' : 'idle')) + + if (resolvedCurrentUserContact) { + return { + ...resolvedCurrentUserContact, + displayName: resolvedCurrentUserContact.displayName || fallbackDisplayName, + avatarUrl: resolvedCurrentUserContact.avatarUrl || fallbackAvatarUrl, + postCount: typeof resolvedCurrentUserContact.postCount === 'number' + ? normalizePostCount(resolvedCurrentUserContact.postCount) + : fallbackPostCount, + postCountStatus: resolvedCurrentUserContact.postCountStatus || fallbackPostStatus, + isCurrentUser: true + } + } + + if (!normalizedWxid) return null + + return { + username: normalizedWxid, + displayName: fallbackDisplayName || normalizedWxid, + avatarUrl: fallbackAvatarUrl, + type: 'sns_only', + postCount: fallbackPostCount, + postCountStatus: fallbackPostStatus, + isCurrentUser: true + } + }, [ + contactsLoading, + currentUserProfile.avatarUrl, + currentUserProfile.displayName, + currentUserProfile.wxid, + normalizePostCount, + overviewStats.myPosts, + overviewStatsStatus, + resolvedCurrentUserContact + ]) + + const filterableContacts = useMemo(() => { + if (!currentUserFilterContact) return contacts + const currentUserKey = normalizeAccountId(currentUserFilterContact.username) + const otherContacts = contacts.filter((contact) => normalizeAccountId(contact.username) !== currentUserKey) + return [currentUserFilterContact, ...otherContacts] + }, [contacts, currentUserFilterContact]) + const currentTimelineTargetContact = useMemo(() => { const normalizedTargetUsername = String(authorTimelineTarget?.username || '').trim() if (!normalizedTargetUsername) return null - return contacts.find((contact) => contact.username === normalizedTargetUsername) || null - }, [authorTimelineTarget, contacts]) + return filterableContacts.find((contact) => contact.username === normalizedTargetUsername) || null + }, [authorTimelineTarget, filterableContacts]) const exportSelectedContactsSummary = useMemo(() => { if (exportScope.kind !== 'selected' || exportScope.usernames.length === 0) return '' - const contactMap = new Map(contacts.map((contact) => [contact.username, contact])) + const contactMap = new Map(filterableContacts.map((contact) => [contact.username, contact])) const names = exportScope.usernames.map((username) => contactMap.get(username)?.displayName || username) if (names.length <= 2) return names.join('、') return `${names.slice(0, 2).join('、')} 等 ${names.length} 位联系人` - }, [contacts, exportScope]) + }, [exportScope, filterableContacts]) const selectedFeedContactsSummary = useMemo(() => { if (selectedContactUsernames.length === 0) return '' - const contactMap = new Map(contacts.map((contact) => [contact.username, contact])) + const contactMap = new Map(filterableContacts.map((contact) => [contact.username, contact])) const names = selectedContactUsernames.map((username) => contactMap.get(username)?.displayName || username) if (names.length <= 2) return names.join('、') return `${names.slice(0, 2).join('、')} 等 ${names.length} 人` - }, [contacts, selectedContactUsernames]) + }, [filterableContacts, selectedContactUsernames]) const selectedContactUsernameSet = useMemo(() => ( new Set(selectedContactUsernames.map((username) => normalizeAccountId(username))) @@ -417,30 +483,31 @@ export default function SnsPage() { }, [posts, selectedContactUsernameSet]) const myTimelineCount = useMemo(() => { - if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { - return normalizePostCount(resolvedCurrentUserContact.postCount) + if (typeof currentUserFilterContact?.postCount === 'number') { + return normalizePostCount(currentUserFilterContact.postCount) } return null - }, [normalizePostCount, resolvedCurrentUserContact]) + }, [currentUserFilterContact, normalizePostCount]) const myTimelineCountLoading = Boolean( - resolvedCurrentUserContact - ? resolvedCurrentUserContact.postCountStatus !== 'ready' + currentUserFilterContact + ? currentUserFilterContact.postCountStatus === 'loading' : overviewStatsStatus === 'loading' || contactsLoading ) + const currentUserTimelineUsername = String(currentUserFilterContact?.username || '').trim() const canStartExport = Boolean(exportFolder) && !isExporting && ( exportScope.kind === 'all' || exportScope.usernames.length > 0 ) const openCurrentUserTimeline = useCallback(() => { - if (!resolvedCurrentUserContact) return + if (!currentUserFilterContact) return setAuthorTimelineTarget({ - username: resolvedCurrentUserContact.username, - displayName: resolvedCurrentUserContact.displayName || currentUserProfile.displayName || resolvedCurrentUserContact.username, - avatarUrl: resolvedCurrentUserContact.avatarUrl || currentUserProfile.avatarUrl + username: currentUserFilterContact.username, + displayName: currentUserFilterContact.displayName || currentUserProfile.displayName || currentUserFilterContact.username, + avatarUrl: currentUserFilterContact.avatarUrl || currentUserProfile.avatarUrl }) - }, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact]) + }, [currentUserFilterContact, currentUserProfile.avatarUrl, currentUserProfile.displayName]) const isDefaultViewNow = useCallback(() => { return ( @@ -1263,12 +1330,12 @@ export default function SnsPage() {
Date: Sun, 15 Mar 2026 17:14:41 +0800 Subject: [PATCH 05/11] fix(chat): replace exited-group filter icon --- src/pages/ChatPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b6ec3db..d4a9d0a 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, LogOut } from 'lucide-react' +import { Search, MessageSquare, MessageSquareOff, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' @@ -3915,7 +3915,7 @@ function ChatPage(props: ChatPageProps) { aria-label={hideExitedGroups ? '显示已退出群聊' : '隐藏已退出群聊'} aria-pressed={hideExitedGroups} > - +
From 53a52d8561ebaf2b5b34a438bba4ee414a5464b5 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Sun, 15 Mar 2026 17:42:45 +0800 Subject: [PATCH 06/11] fix(export): keep appmsg type-4 links clickable --- electron/services/exportService.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index dcf3956..caf9fda 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1127,7 +1127,7 @@ class ExportService { if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' if (xmlType === '57') return title || '[引用消息]' - if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' + if (xmlType === '4' || xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title if (title) return title @@ -1200,6 +1200,7 @@ class ExportService { const typeMatch = /(\d+)<\/type>/i.exec(normalized) const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') + const linkUrl = this.extractAppMessageLinkUrl(normalized) // 群公告消息(type 87) if (subType === 87) { @@ -1258,6 +1259,9 @@ class ExportService { if (subType === 57) { return title || '[引用消息]' } + if (linkUrl) { + return title ? `[链接]${title}\n${linkUrl}` : linkUrl + } if (title) { return `[链接]${title}` } @@ -1810,6 +1814,7 @@ class ExportService { normalized.includes('') const hasReferMsg = normalized.includes('') const xmlType = this.extractAppMessageType(normalized) + const appMsgUrl = this.extractAppMessageLinkUrl(normalized) const isFinder = xmlType === '51' || normalized.includes(' Date: Sun, 15 Mar 2026 18:31:57 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=88=B0=E7=94=B5=E8=84=91=E4=B8=8A=E7=9A=84=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E8=A7=A3=E5=AF=86=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/imageDecryptService.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index a78b7ed..9ad2c25 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -436,6 +436,10 @@ export class ImageDecryptService { if (imageMd5) { const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) if (res) return res + if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) { + const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) + if (datNameRes) return datNameRes + } } // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 @@ -889,7 +893,8 @@ export class ImageDecryptService { const now = new Date() const months: string[] = [] - for (let i = 0; i < 2; i++) { + // Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months". + for (let i = 0; i < 24; i++) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1) const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` months.push(mStr) From 845d6b2e2c95d84fc9de513e865bbfd19ecf7f3c Mon Sep 17 00:00:00 2001 From: xuncha <102988462+xunchahaha@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:58:41 +0800 Subject: [PATCH 08/11] =?UTF-8?q?Revert=20"fix(chat):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=B7=B2=E9=80=80=E5=87=BA=E7=BE=A4=E8=81=8A=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E5=BC=80=E5=85=B3"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 27 +++----------------- src/pages/ChatPage.tsx | 55 ++++++----------------------------------- 2 files changed, 11 insertions(+), 71 deletions(-) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 3c47e24..e00feb5 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -304,12 +304,11 @@ } } - .refresh-btn, - .session-filter-btn { + .refresh-btn { width: 32px; height: 32px; padding: 0; - border: 1px solid transparent; + border: none; background: var(--bg-tertiary); border-radius: 8px; color: var(--text-secondary); @@ -325,12 +324,6 @@ color: var(--text-primary); } - &.active { - background: var(--primary-light); - border-color: var(--primary-light); - color: var(--primary); - } - &:disabled { opacity: 0.4; cursor: not-allowed; @@ -819,7 +812,7 @@ .icon-btn { width: 32px; height: 32px; - border: 1px solid transparent; + border: none; background: transparent; border-radius: 6px; cursor: pointer; @@ -842,20 +835,6 @@ } } - .session-filter-btn { - background: var(--bg-primary); - - &:hover { - background: var(--bg-hover); - } - - &.active { - background: var(--primary-light); - border-color: var(--primary-light); - color: var(--primary); - } - } - .search-row { flex: 1; display: flex; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index d4a9d0a..50e1534 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, MessageSquareOff, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' @@ -34,16 +34,6 @@ const SYSTEM_MESSAGE_TYPES = [ 266287972401, // 拍一拍 ] -const EXITED_GROUP_SUMMARY_EXACT = '你已退出该群聊' - -function isExitedGroupSession(session: Pick): boolean { - return ( - session.username.endsWith('@chatroom') && - session.lastMsgType === 10000 && - session.summary.trim() === EXITED_GROUP_SUMMARY_EXACT - ) -} - interface XmlField { key: string; value: string; @@ -522,7 +512,6 @@ function ChatPage(props: ChatPageProps) { const [groupMembersLoadingHint, setGroupMembersLoadingHint] = useState('') const [isRefreshingGroupMembers, setIsRefreshingGroupMembers] = useState(false) const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('') - const [hideExitedGroups, setHideExitedGroups] = useState(true) const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) @@ -592,25 +581,6 @@ function ChatPage(props: ChatPageProps) { const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) - const exitedGroupCount = useMemo(() => { - if (!Array.isArray(sessions)) return 0 - return sessions.filter(isExitedGroupSession).length - }, [sessions]) - const sessionsAfterExitedGroupFilter = useMemo(() => { - if (!Array.isArray(sessions)) return [] - if (!hideExitedGroups) return sessions - return sessions.filter((session) => !isExitedGroupSession(session)) - }, [sessions, hideExitedGroups]) - const exitedGroupFilterTitle = useMemo(() => { - if (hideExitedGroups) { - return exitedGroupCount > 0 - ? `已隐藏 ${exitedGroupCount} 个已退出群聊,点击显示` - : '已开启:隐藏已退出群聊' - } - return exitedGroupCount > 0 - ? `当前显示全部群聊(含 ${exitedGroupCount} 个已退出群聊),点击隐藏` - : '当前显示全部群聊,点击隐藏已退出群聊' - }, [exitedGroupCount, hideExitedGroups]) const messageKeySetRef = useRef>(new Set()) const lastMessageTimeRef = useRef(0) const sessionMapRef = useRef>(new Map()) @@ -2993,16 +2963,16 @@ function ChatPage(props: ChatPageProps) { // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 useEffect(() => { - if (!Array.isArray(sessionsAfterExitedGroupFilter)) { + if (!Array.isArray(sessions)) { setFilteredSessions([]) return } // 检查是否有折叠的群聊 - const foldedGroups = sessionsAfterExitedGroupFilter.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) + const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) const hasFoldedGroups = foldedGroups.length > 0 - let visible = sessionsAfterExitedGroupFilter.filter(s => { + let visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false return true }) @@ -3052,12 +3022,12 @@ function ChatPage(props: ChatPageProps) { s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) )) - }, [sessionsAfterExitedGroupFilter, searchKeyword, setFilteredSessions]) + }, [sessions, searchKeyword, setFilteredSessions]) // 折叠群列表(独立计算,供折叠 panel 使用) const foldedSessions = useMemo(() => { - if (!Array.isArray(sessionsAfterExitedGroupFilter)) return [] - const folded = sessionsAfterExitedGroupFilter.filter(s => s.isFolded) + if (!Array.isArray(sessions)) return [] + const folded = sessions.filter(s => s.isFolded) if (!searchKeyword.trim() || !foldedView) return folded const lower = searchKeyword.toLowerCase() return folded.filter(s => @@ -3065,7 +3035,7 @@ function ChatPage(props: ChatPageProps) { s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) ) - }, [sessionsAfterExitedGroupFilter, searchKeyword, foldedView]) + }, [sessions, searchKeyword, foldedView]) const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0 const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords @@ -3908,15 +3878,6 @@ function ChatPage(props: ChatPageProps) { {/* 普通 header */}
-
Date: Sun, 15 Mar 2026 18:59:38 +0800 Subject: [PATCH 09/11] =?UTF-8?q?Revert=20"fix(export):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=AF=BC=E5=87=BA=E5=90=8E=E9=93=BE=E6=8E=A5=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E7=82=B9=E5=87=BB"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index caf9fda..dcf3956 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1127,7 +1127,7 @@ class ExportService { if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' if (xmlType === '57') return title || '[引用消息]' - if (xmlType === '4' || xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' + if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title if (title) return title @@ -1200,7 +1200,6 @@ class ExportService { const typeMatch = /(\d+)<\/type>/i.exec(normalized) const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') - const linkUrl = this.extractAppMessageLinkUrl(normalized) // 群公告消息(type 87) if (subType === 87) { @@ -1259,9 +1258,6 @@ class ExportService { if (subType === 57) { return title || '[引用消息]' } - if (linkUrl) { - return title ? `[链接]${title}\n${linkUrl}` : linkUrl - } if (title) { return `[链接]${title}` } @@ -1814,7 +1810,6 @@ class ExportService { normalized.includes('') const hasReferMsg = normalized.includes('') const xmlType = this.extractAppMessageType(normalized) - const appMsgUrl = this.extractAppMessageLinkUrl(normalized) const isFinder = xmlType === '51' || normalized.includes(' Date: Sun, 15 Mar 2026 19:00:04 +0800 Subject: [PATCH 10/11] =?UTF-8?q?Revert=20"fix:=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=9C=8B=E5=8F=8B=E5=9C=88=E4=BB=85=E7=9C=8B=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E5=92=8C=E5=AF=BC=E5=87=BA=E8=87=AA=E5=B7=B1"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sns/SnsFilterPanel.tsx | 35 ++------ src/pages/SnsPage.scss | 106 ++++------------------- src/pages/SnsPage.tsx | 117 ++++++-------------------- 3 files changed, 50 insertions(+), 208 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index ed7e3de..654d543 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -8,7 +8,6 @@ interface Contact { avatarUrl?: string postCount?: number postCountStatus?: 'idle' | 'loading' | 'ready' - isCurrentUser?: boolean } interface ContactsCountProgress { @@ -69,7 +68,7 @@ export const SnsFilterPanel: React.FC = ({ return '正在加载联系人...' } if (contacts.length === 0) { - return '暂无可筛选联系人' + return '暂无好友或曾经的好友' } return '没有找到联系人' } @@ -119,7 +118,7 @@ export const SnsFilterPanel: React.FC = ({
setContactSearch(e.target.value)} /> @@ -144,26 +143,18 @@ export const SnsFilterPanel: React.FC = ({
{filteredContacts.map(contact => { const isPostCountReady = contact.postCountStatus === 'ready' - const isPostCountLoading = contact.postCountStatus === 'loading' const isSelected = selectedContactLookup.has(contact.username) const isActive = activeContactUsername === contact.username - const displayName = contact.displayName || (contact.isCurrentUser ? '我' : contact.username) - const selectTitle = contact.isCurrentUser - ? (isSelected ? '取消选择当前账号的朋友圈' : '选择当前账号的朋友圈') - : (isSelected ? `取消选择 ${displayName}` : `选择 ${displayName}`) - const openTitle = contact.isCurrentUser - ? '查看我发的朋友圈' - : `查看 ${displayName} 的朋友圈` return (
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index f8a8c90..45fcfbc 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1295,26 +1295,6 @@ color: var(--text-primary); } - &.is-current-user .contact-main-btn { - background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); - border-color: color-mix(in srgb, var(--primary) 18%, var(--border-color)); - } - - &.is-current-user.is-selected .contact-main-btn { - background: rgba(var(--primary-rgb), 0.1); - border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); - } - - &.is-current-user.is-active .contact-main-btn { - background: rgba(var(--primary-rgb), 0.15); - border-color: color-mix(in srgb, var(--primary) 54%, var(--border-color)); - } - - &.is-current-user .contact-name { - color: var(--text-primary); - font-weight: 600; - } - .contact-select-btn { width: 32px; height: 32px; @@ -1334,11 +1314,6 @@ color: var(--primary); } - &:focus-visible { - outline: 2px solid color-mix(in srgb, var(--primary) 72%, transparent); - outline-offset: 2px; - } - &.checked { color: var(--primary); } @@ -1361,11 +1336,6 @@ &:hover { background: var(--hover-bg); } - - &:focus-visible { - outline: 2px solid color-mix(in srgb, var(--primary) 72%, transparent); - outline-offset: 2px; - } } .contact-meta { @@ -1373,49 +1343,15 @@ min-width: 0; display: flex; flex-direction: column; - gap: 4px; - - .contact-name-row { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - } + gap: 2px; .contact-name { - display: block; - flex: 1 1 auto; - min-width: 0; font-size: 14px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - - .contact-self-badge { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 18px; - padding: 0 8px; - border-radius: 999px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.02em; - color: var(--primary); - background: rgba(var(--primary-rgb), 0.12); - border: 1px solid rgba(var(--primary-rgb), 0.18); - } - - .contact-subtitle { - font-size: 11px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } } .contact-post-count-wrap { @@ -1432,10 +1368,6 @@ color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; - - &.is-empty { - letter-spacing: 0.08em; - } } .contact-post-count-loading { @@ -1819,9 +1751,8 @@ background: var(--bg-secondary); border-radius: var(--sns-border-radius-lg); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); - width: 460px; + width: 480px; max-width: 92vw; - max-height: calc(100vh - 48px); display: flex; flex-direction: column; border: 1px solid var(--border-color); @@ -1829,7 +1760,7 @@ animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); .export-dialog-header { - padding: 14px 18px; + padding: 16px 20px; border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary); display: flex; @@ -1865,13 +1796,10 @@ } .export-dialog-body { - padding: 16px 18px 18px; + padding: 20px; display: flex; flex-direction: column; - gap: 14px; - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; + gap: 18px; } } @@ -1880,7 +1808,7 @@ align-items: center; gap: 8px; flex-wrap: wrap; - padding: 8px 12px; + padding: 10px 14px; background: var(--bg-tertiary); border-radius: 8px; border: 1px solid var(--border-color); @@ -1895,10 +1823,10 @@ } .filter-tag { - font-size: 12px; + font-size: 13px; color: var(--text-secondary); background: var(--bg-primary); - padding: 2px 8px; + padding: 2px 10px; border-radius: 6px; border: 1px solid var(--border-color); display: flex; @@ -1920,7 +1848,7 @@ .export-section { display: flex; flex-direction: column; - gap: 6px; + gap: 8px; } .export-section-header { @@ -1965,14 +1893,14 @@ .export-format-options { display: grid; grid-template-columns: 1fr 1fr; - gap: 8px; + gap: 10px; .format-option { display: flex; flex-direction: column; align-items: center; gap: 4px; - padding: 12px 8px; + padding: 14px 10px; border-radius: 10px; border: 2px solid var(--border-color); background: var(--bg-primary); @@ -2496,7 +2424,7 @@ .export-media-check-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); - gap: 6px; + gap: 8px; label { display: inline-flex; @@ -2504,7 +2432,7 @@ gap: 6px; font-size: 13px; color: var(--text-primary); - padding: 7px 9px; + padding: 8px 10px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary); @@ -2622,10 +2550,10 @@ display: flex; align-items: center; gap: 8px; - padding: 8px 10px; + padding: 10px 12px; background: var(--bg-secondary); border-radius: 8px; - margin: 4px 0; + margin: 8px 0; color: var(--text-tertiary); font-size: 12px; border: 1px dashed var(--border-color); @@ -2638,8 +2566,8 @@ .export-actions { display: flex; - gap: 10px; - margin-top: 14px; + gap: 12px; + margin-top: 24px; button { flex: 1; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 07e7d43..fb8f009 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -39,7 +39,6 @@ interface Contact { lastSessionTimestamp?: number postCount?: number postCountStatus?: ContactPostCountStatus - isCurrentUser?: boolean } interface SidebarUserProfile { @@ -194,15 +193,11 @@ export default function SnsPage() { }, [contacts]) useEffect(() => { const contactLookup = new Set(contacts.map((contact) => contact.username)) - const currentUserUsername = normalizeAccountId(currentUserProfile.wxid) - if (currentUserUsername) { - contactLookup.add(currentUserUsername) - } setSelectedContactUsernames((prev) => { const next = prev.filter((username) => contactLookup.has(username)) return next.length === prev.length ? prev : next }) - }, [contacts, currentUserProfile.wxid]) + }, [contacts]) useEffect(() => { overviewStatsRef.current = overviewStats }, [overviewStats]) @@ -390,88 +385,27 @@ export default function SnsPage() { }) || null }, [contacts, currentUserProfile.alias, currentUserProfile.displayName, currentUserProfile.wxid]) - const currentUserFilterContact = useMemo(() => { - const normalizedWxid = normalizeAccountId(currentUserProfile.wxid) - const fallbackDisplayName = String( - resolvedCurrentUserContact?.displayName - || currentUserProfile.displayName - || '我' - ).trim() - const fallbackAvatarUrl = resolvedCurrentUserContact?.avatarUrl || currentUserProfile.avatarUrl - const fallbackPostCount = typeof resolvedCurrentUserContact?.postCount === 'number' - ? normalizePostCount(resolvedCurrentUserContact.postCount) - : (typeof overviewStats.myPosts === 'number' && Number.isFinite(overviewStats.myPosts) - ? Math.max(0, Math.floor(overviewStats.myPosts)) - : undefined) - const fallbackPostStatus: ContactPostCountStatus = resolvedCurrentUserContact - ? (resolvedCurrentUserContact.postCountStatus || (fallbackPostCount !== undefined ? 'ready' : 'idle')) - : (fallbackPostCount !== undefined - ? 'ready' - : (contactsLoading || overviewStatsStatus === 'loading' ? 'loading' : 'idle')) - - if (resolvedCurrentUserContact) { - return { - ...resolvedCurrentUserContact, - displayName: resolvedCurrentUserContact.displayName || fallbackDisplayName, - avatarUrl: resolvedCurrentUserContact.avatarUrl || fallbackAvatarUrl, - postCount: typeof resolvedCurrentUserContact.postCount === 'number' - ? normalizePostCount(resolvedCurrentUserContact.postCount) - : fallbackPostCount, - postCountStatus: resolvedCurrentUserContact.postCountStatus || fallbackPostStatus, - isCurrentUser: true - } - } - - if (!normalizedWxid) return null - - return { - username: normalizedWxid, - displayName: fallbackDisplayName || normalizedWxid, - avatarUrl: fallbackAvatarUrl, - type: 'sns_only', - postCount: fallbackPostCount, - postCountStatus: fallbackPostStatus, - isCurrentUser: true - } - }, [ - contactsLoading, - currentUserProfile.avatarUrl, - currentUserProfile.displayName, - currentUserProfile.wxid, - normalizePostCount, - overviewStats.myPosts, - overviewStatsStatus, - resolvedCurrentUserContact - ]) - - const filterableContacts = useMemo(() => { - if (!currentUserFilterContact) return contacts - const currentUserKey = normalizeAccountId(currentUserFilterContact.username) - const otherContacts = contacts.filter((contact) => normalizeAccountId(contact.username) !== currentUserKey) - return [currentUserFilterContact, ...otherContacts] - }, [contacts, currentUserFilterContact]) - const currentTimelineTargetContact = useMemo(() => { const normalizedTargetUsername = String(authorTimelineTarget?.username || '').trim() if (!normalizedTargetUsername) return null - return filterableContacts.find((contact) => contact.username === normalizedTargetUsername) || null - }, [authorTimelineTarget, filterableContacts]) + return contacts.find((contact) => contact.username === normalizedTargetUsername) || null + }, [authorTimelineTarget, contacts]) const exportSelectedContactsSummary = useMemo(() => { if (exportScope.kind !== 'selected' || exportScope.usernames.length === 0) return '' - const contactMap = new Map(filterableContacts.map((contact) => [contact.username, contact])) + const contactMap = new Map(contacts.map((contact) => [contact.username, contact])) const names = exportScope.usernames.map((username) => contactMap.get(username)?.displayName || username) if (names.length <= 2) return names.join('、') return `${names.slice(0, 2).join('、')} 等 ${names.length} 位联系人` - }, [exportScope, filterableContacts]) + }, [contacts, exportScope]) const selectedFeedContactsSummary = useMemo(() => { if (selectedContactUsernames.length === 0) return '' - const contactMap = new Map(filterableContacts.map((contact) => [contact.username, contact])) + const contactMap = new Map(contacts.map((contact) => [contact.username, contact])) const names = selectedContactUsernames.map((username) => contactMap.get(username)?.displayName || username) if (names.length <= 2) return names.join('、') return `${names.slice(0, 2).join('、')} 等 ${names.length} 人` - }, [filterableContacts, selectedContactUsernames]) + }, [contacts, selectedContactUsernames]) const selectedContactUsernameSet = useMemo(() => ( new Set(selectedContactUsernames.map((username) => normalizeAccountId(username))) @@ -483,31 +417,30 @@ export default function SnsPage() { }, [posts, selectedContactUsernameSet]) const myTimelineCount = useMemo(() => { - if (typeof currentUserFilterContact?.postCount === 'number') { - return normalizePostCount(currentUserFilterContact.postCount) + if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { + return normalizePostCount(resolvedCurrentUserContact.postCount) } return null - }, [currentUserFilterContact, normalizePostCount]) + }, [normalizePostCount, resolvedCurrentUserContact]) const myTimelineCountLoading = Boolean( - currentUserFilterContact - ? currentUserFilterContact.postCountStatus === 'loading' + resolvedCurrentUserContact + ? resolvedCurrentUserContact.postCountStatus !== 'ready' : overviewStatsStatus === 'loading' || contactsLoading ) - const currentUserTimelineUsername = String(currentUserFilterContact?.username || '').trim() const canStartExport = Boolean(exportFolder) && !isExporting && ( exportScope.kind === 'all' || exportScope.usernames.length > 0 ) const openCurrentUserTimeline = useCallback(() => { - if (!currentUserFilterContact) return + if (!resolvedCurrentUserContact) return setAuthorTimelineTarget({ - username: currentUserFilterContact.username, - displayName: currentUserFilterContact.displayName || currentUserProfile.displayName || currentUserFilterContact.username, - avatarUrl: currentUserFilterContact.avatarUrl || currentUserProfile.avatarUrl + username: resolvedCurrentUserContact.username, + displayName: resolvedCurrentUserContact.displayName || currentUserProfile.displayName || resolvedCurrentUserContact.username, + avatarUrl: resolvedCurrentUserContact.avatarUrl || currentUserProfile.avatarUrl }) - }, [currentUserFilterContact, currentUserProfile.avatarUrl, currentUserProfile.displayName]) + }, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact]) const isDefaultViewNow = useCallback(() => { return ( @@ -1330,12 +1263,12 @@ export default function SnsPage() {