#1043 #1032;修复增量导出意外添加文件的问题;聊天页面支持右键显示资料卡;优化曾经的好友相关算法并新增黑名单;

This commit is contained in:
cc committed 2026-07-03 16:41:37 +08:00
1 parent 801ed16d5e
commit b87444dd64
30 files changed
+1615 -145

No files matched your search

+69 -1
View File
@@ -3,6 +3,63 @@
overflow-y: auto;
background-color: var(--bg-secondary); // 对齐会话列表背景
.biz-health-panel {
position: sticky;
top: 0;
z-index: 2;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
}
.biz-health-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
margin-bottom: 6px;
}
.biz-health-filter {
height: 30px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-bg);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0 8px;
font-size: 12px;
cursor: pointer;
strong {
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
}
&.active {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-light);
strong {
color: var(--primary);
}
}
}
.biz-health-counts {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-tertiary);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
}
.biz-loading {
padding: 20px;
text-align: center;
@@ -93,17 +150,28 @@
}
}
.biz-badge-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.biz-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
width: fit-content;
margin-top: 2px;
&.type-service { color: #07c160; background: rgba(7, 193, 96, 0.1); }
&.type-sub { color: var(--primary); background: var(--primary-light); }
&.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); }
&.type-unknown { color: var(--text-tertiary); background: var(--bg-tertiary); }
&.type-invalid { color: #d46b08; background: rgba(250, 173, 20, 0.14); }
&.type-stale { color: #722ed1; background: rgba(114, 46, 209, 0.1); }
}
}
}
+93 -13
View File
@@ -1,24 +1,46 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useThemeStore } from '../stores/themeStore';
import { Newspaper, MessageSquareOff } from 'lucide-react';
import { AlertTriangle, Clock, MessageSquareOff, Newspaper } from 'lucide-react';
import './BizPage.scss';
export interface BizAccount {
username: string;
name: string;
avatar: string;
type: string;
type: number | string;
last_time: number;
formatted_last_time: string;
unread_count?: number;
status?: 'active' | 'inactive';
health_reason?: string;
stale_level?: 'none' | 'one_year' | 'two_year' | 'unknown';
days_since_last_article?: number;
}
interface BizAccountHealth {
summary: {
active_total: number;
subscription_total: number;
service_total: number;
invalid_total: number;
stale_one_year_total: number;
stale_two_year_total: number;
unknown_last_article_total: number;
};
accounts: BizAccount[];
invalid_accounts: BizAccount[];
}
type BizFilter = 'active' | 'invalid' | 'one_year' | 'two_year' | 'unknown';
export const BizAccountList: React.FC<{
onSelect: (account: BizAccount) => void;
selectedUsername?: string;
searchKeyword?: string;
}> = ({ onSelect, selectedUsername, searchKeyword }) => {
const [accounts, setAccounts] = useState<BizAccount[]>([]);
const [health, setHealth] = useState<BizAccountHealth | null>(null);
const [filter, setFilter] = useState<BizFilter>('active');
const [loading, setLoading] = useState(false);
const [myWxid, setMyWxid] = useState<string>('');
@@ -44,8 +66,15 @@ export const BizAccountList: React.FC<{
setLoading(true);
try {
const res = await window.electronAPI.biz.listAccounts(myWxid)
setAccounts(res || []);
if (window.electronAPI.biz.listAccountHealth) {
const res = await window.electronAPI.biz.listAccountHealth(myWxid) as BizAccountHealth;
setHealth(res || null);
setAccounts([...(res?.accounts || []), ...(res?.invalid_accounts || [])]);
} else {
const res = await window.electronAPI.biz.listAccounts(myWxid)
setHealth(null);
setAccounts(res || []);
}
} catch (err) {
console.error('获取服务号列表失败:', err);
} finally {
@@ -76,9 +105,14 @@ export const BizAccountList: React.FC<{
const filtered = useMemo(() => {
let result = accounts;
if (health) {
if (filter === 'active') result = health.accounts || [];
else if (filter === 'invalid') result = health.invalid_accounts || [];
else result = (health.accounts || []).filter(a => a.stale_level === filter || (filter === 'one_year' && a.stale_level === 'two_year'));
}
if (searchKeyword) {
const q = searchKeyword.toLowerCase();
result = accounts.filter(a =>
result = result.filter(a =>
(a.name && a.name.toLowerCase().includes(q)) ||
(a.username && a.username.toLowerCase().includes(q))
);
@@ -88,13 +122,40 @@ export const BizAccountList: React.FC<{
if (b.username === 'gh_3dfda90e39d6') return 1;
return b.last_time - a.last_time;
});
}, [accounts, searchKeyword]);
}, [accounts, filter, health, searchKeyword]);
const renderFilterButton = (nextFilter: BizFilter, label: string, count: number) => (
<button
type="button"
className={`biz-health-filter ${filter === nextFilter ? 'active' : ''}`}
onClick={() => setFilter(nextFilter)}
>
<span>{label}</span>
<strong>{count}</strong>
</button>
);
if (loading) return <div className="biz-loading">加载中...</div>;
return (
<div className="biz-account-list">
{health && (
<div className="biz-health-panel">
<div className="biz-health-row">
{renderFilterButton('active', '有效', health.summary.active_total)}
{renderFilterButton('invalid', '疑似失效', health.summary.invalid_total)}
</div>
<div className="biz-health-row">
{renderFilterButton('one_year', '1年未发', health.summary.stale_one_year_total)}
{renderFilterButton('two_year', '2年未发', health.summary.stale_two_year_total)}
</div>
<div className="biz-health-counts">
<span>公众号 {health.summary.subscription_total}</span>
<span>服务号 {health.summary.service_total}</span>
<span>无记录 {health.summary.unknown_last_article_total}</span>
</div>
</div>
)}
{filtered.map(item => (
<div
key={item.username}
@@ -123,13 +184,32 @@ export const BizAccountList: React.FC<{
{/* <div className="biz-badge type-service">微信支付</div>*/}
{/*)}*/}
<div className={`biz-badge ${
item.type === '1' ? 'type-service' :
item.type === '0' ? 'type-sub' :
item.type === '2' ? 'type-enterprise' :
item.type === '3' ? 'type-enterprise' : 'type-unknown'
}`}>
{item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'}
<div className="biz-badge-row">
{(() => {
const typeValue = Number(item.type);
return (
<div className={`biz-badge ${
typeValue === 1 ? 'type-service' :
typeValue === 0 ? 'type-sub' :
typeValue === 2 ? 'type-enterprise' :
typeValue === 3 ? 'type-enterprise' : 'type-unknown'
}`}>
{typeValue === 0 ? '公众号' : typeValue === 1 ? '服务号' : typeValue === 2 ? '企业号' : typeValue === 3 ? '企业附属' : '未知'}
</div>
);
})()}
{item.status === 'inactive' && (
<div className="biz-badge type-invalid"><AlertTriangle size={11} />疑似失效</div>
)}
{item.status !== 'inactive' && item.stale_level === 'one_year' && (
<div className="biz-badge type-stale"><Clock size={11} />1年未发</div>
)}
{item.status !== 'inactive' && item.stale_level === 'two_year' && (
<div className="biz-badge type-stale"><Clock size={11} />2年未发</div>
)}
{item.status !== 'inactive' && item.stale_level === 'unknown' && (
<div className="biz-badge type-unknown"><Clock size={11} />无记录</div>
)}
</div>
</div>
+23 -1
View File
@@ -20,15 +20,25 @@ export interface ChatMessageBubbleProps {
avatarUrl?: string
isGroupChat?: boolean
resolvedSenderName?: string
avatarProfile?: MessageAvatarProfile
isSelectionMode?: boolean
isSelected?: boolean
onContextMenu?: (event: React.MouseEvent, message: Message) => void
onAvatarContextMenu?: (event: React.MouseEvent, message: Message, profile: MessageAvatarProfile) => void
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void
actionNode?: React.ReactNode
children: React.ReactNode
portal?: React.ReactNode
}
export interface MessageAvatarProfile {
username?: string
displayName: string
avatarUrl?: string
isSelf?: boolean
isGroupMember?: boolean
}
function SelectionCheckbox({ checked, side }: { checked?: boolean; side: 'left' | 'right' }) {
return (
<div className={`chat-selection-checkbox ${side} ${checked ? 'checked' : ''}`}>
@@ -54,9 +64,11 @@ function ChatMessageBubble({
avatarUrl,
isGroupChat,
resolvedSenderName,
avatarProfile,
isSelectionMode,
isSelected,
onContextMenu,
onAvatarContextMenu,
onToggleSelection,
actionNode,
children,
@@ -89,7 +101,15 @@ function ChatMessageBubble({
className={`message-bubble ${bubbleClass} ${isEmoji && emojiHasAsset && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVideo ? 'video' : ''} ${isVoice ? 'voice' : ''}`}
onContextMenu={(event) => onContextMenu?.(event, message)}
>
<div className="bubble-avatar">
<div
className="bubble-avatar"
onContextMenu={(event) => {
if (!avatarProfile || isSystem) return
event.preventDefault()
event.stopPropagation()
onAvatarContextMenu?.(event, message, avatarProfile)
}}
>
<Avatar src={avatarUrl} name={avatarName} size={36} className="bubble-avatar" />
</div>
<div className="bubble-body">
@@ -137,9 +157,11 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
prev.avatarUrl === next.avatarUrl &&
prev.isGroupChat === next.isGroupChat &&
prev.resolvedSenderName === next.resolvedSenderName &&
prev.avatarProfile === next.avatarProfile &&
prev.isSelectionMode === next.isSelectionMode &&
prev.isSelected === next.isSelected &&
prev.onContextMenu === next.onContextMenu &&
prev.onAvatarContextMenu === next.onAvatarContextMenu &&
prev.onToggleSelection === next.onToggleSelection &&
prev.actionNode === next.actionNode &&
prev.children === next.children &&
+260
View File
@@ -2097,6 +2097,7 @@
align-items: center;
justify-content: center;
overflow: hidden;
cursor: context-menu;
img {
width: 100%;
@@ -4955,6 +4956,265 @@
}
}
.avatar-profile-overlay {
position: fixed;
inset: 0;
z-index: 12060;
background: transparent;
}
.avatar-profile-card {
position: fixed;
z-index: 12070;
width: min(340px, calc(100vw - 24px));
max-height: calc(100vh - 24px);
overflow: hidden auto;
padding: 14px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
background: color-mix(in srgb, var(--card-bg) 96%, transparent);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.18), 0 4px 14px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(18px);
animation: avatarProfileIn 0.14s ease-out;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text-tertiary) 58%, transparent);
border-radius: 999px;
}
}
.avatar-profile-close {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
color: var(--text-tertiary);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.avatar-profile-hero {
display: flex;
align-items: center;
gap: 12px;
padding: 2px 28px 14px 2px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
.avatar-profile-photo {
flex: 0 0 auto;
border-radius: 12px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
}
}
.avatar-profile-title {
min-width: 0;
h3 {
margin: 0;
color: var(--text-primary);
font-size: 18px;
line-height: 1.25;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
margin: 5px 0 0;
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.35;
}
}
.avatar-profile-section {
padding: 12px 0;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
&.compact {
padding-bottom: 10px;
}
}
.avatar-profile-row {
min-height: 30px;
display: grid;
grid-template-columns: 58px minmax(0, 1fr) 24px;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
span {
color: var(--text-secondary);
}
strong {
color: var(--text-primary);
font-weight: 500;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:has(.avatar-profile-copy)) {
grid-template-columns: 58px minmax(0, 1fr);
}
}
.avatar-profile-copy {
width: 24px;
height: 24px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.avatar-profile-loading,
.avatar-profile-empty,
.avatar-profile-error,
.avatar-profile-moments-empty,
.avatar-profile-moments-loading {
min-height: 34px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-tertiary);
font-size: 12px;
}
.avatar-profile-error {
min-height: 26px;
color: var(--danger);
}
.avatar-profile-moments {
width: 100%;
padding: 12px 0;
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: transparent;
text-align: left;
cursor: pointer;
&:not(.disabled):hover {
.avatar-profile-moment-thumb {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--primary) 32%, var(--border-color));
}
.avatar-profile-moments-head strong {
color: var(--primary);
}
}
&.disabled {
cursor: default;
}
}
.avatar-profile-moments-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
span {
color: var(--text-secondary);
font-size: 13px;
}
strong {
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
transition: color 0.16s ease;
}
}
.avatar-profile-moment-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.avatar-profile-moment-thumb {
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent);
background: var(--bg-secondary);
transition: transform 0.16s ease, border-color 0.16s ease;
img,
video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.avatar-profile-moment-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
color: var(--text-secondary);
background: linear-gradient(135deg, var(--bg-secondary), var(--card-bg));
font-size: 16px;
font-weight: 600;
&.failed {
color: var(--text-tertiary);
}
}
@keyframes avatarProfileIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// Modal Overlay
.modal-overlay {
position: fixed;
+562 -18
View File
@@ -31,7 +31,7 @@ import {
requestExportSessionStatus
} from '../services/exportBridge'
import ChatHeader from './Chat/ChatHeader'
import ChatMessageBubble from './Chat/ChatMessageBubble'
import ChatMessageBubble, { type MessageAvatarProfile } from './Chat/ChatMessageBubble'
import { buildNewMessagesCursor } from './Chat/messageCursor'
import {
subscribeSharedImageCacheResolved,
@@ -114,6 +114,7 @@ const VOICE_CACHE_MAX_BYTES = 24 * BYTES_PER_MEGABYTE
const VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES = 1800
const VOICE_TRANSCRIPT_CACHE_MAX_BYTES = 2 * BYTES_PER_MEGABYTE
const SENDER_AVATAR_CACHE_MAX_ENTRIES = 2000
const SENDER_AVATAR_CACHE_TTL_MS = 5 * 60 * 1000
const AUTO_MEDIA_TASK_MAX_CONCURRENCY = 2
const AUTO_MEDIA_TASK_MAX_QUEUE = 80
@@ -778,6 +779,85 @@ function safeParseJson<T>(raw: string | null): T | null {
}
}
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
interface CachedCurrentUserProfile {
wxid?: string
displayName?: string
alias?: string
avatarUrl?: string
}
function normalizeAccountIdForProfile(value?: string | null): 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
}
function isUsefulCurrentUserName(value?: string | null, wxid?: string | null): value is string {
const name = String(value || '').trim()
if (!name) return false
const lower = name.toLowerCase()
if (name === '我' || name === '微信用户' || lower === 'self') return false
if (lower.startsWith('wxid_')) return false
const normalizedWxid = normalizeAccountIdForProfile(wxid).toLowerCase()
if (normalizedWxid && normalizeAccountIdForProfile(name).toLowerCase() === normalizedWxid) return false
return true
}
function readCachedCurrentUserProfile(preferredWxid?: string | null): CachedCurrentUserProfile | null {
if (typeof window === 'undefined') return null
const preferredRaw = String(preferredWxid || '').trim()
const preferredNormalized = normalizeAccountIdForProfile(preferredRaw)
const isSameAccount = (candidate?: string | null): boolean => {
const raw = String(candidate || '').trim()
if (!raw) return !preferredRaw && !preferredNormalized
if (!preferredRaw && !preferredNormalized) return true
return raw.toLowerCase() === preferredRaw.toLowerCase() ||
normalizeAccountIdForProfile(raw).toLowerCase() === preferredNormalized.toLowerCase()
}
const sidebarProfile = safeParseJson<CachedCurrentUserProfile & { updatedAt?: number }>(
window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
)
if (sidebarProfile?.wxid && isSameAccount(sidebarProfile.wxid)) {
return {
wxid: normalizeAccountIdForProfile(sidebarProfile.wxid) || sidebarProfile.wxid,
displayName: sidebarProfile.displayName,
alias: sidebarProfile.alias,
avatarUrl: sidebarProfile.avatarUrl
}
}
const accountProfiles = safeParseJson<Record<string, CachedCurrentUserProfile & { updatedAt?: number }>>(
window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
) || {}
const candidates = [
preferredRaw,
preferredNormalized,
...Object.keys(accountProfiles)
].filter(Boolean)
for (const key of candidates) {
const profile = accountProfiles[key]
if (!profile || !isSameAccount(key)) continue
return {
wxid: preferredNormalized || normalizeAccountIdForProfile(key) || key,
displayName: profile.displayName,
alias: profile.alias,
avatarUrl: profile.avatarUrl
}
}
return null
}
function formatYmdDateFromSeconds(timestamp?: number): string {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp * 1000)
@@ -798,6 +878,11 @@ function formatYmdHmDateTime(timestamp?: number): string {
return `${y}-${m}-${day} ${h}:${min}`
}
function formatYmdHmDateTimeFromSeconds(timestamp?: number): string {
if (!timestamp || !Number.isFinite(timestamp)) return ''
return formatYmdHmDateTime(timestamp * 1000)
}
function formatDateInputLocal(date: Date): string {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
@@ -880,6 +965,40 @@ interface SessionContactProfile {
updatedAt: number
}
interface AvatarProfileTarget extends MessageAvatarProfile {
username?: string
sourceLabel?: string
messageTime?: number
canOpenMoments: boolean
}
interface AvatarProfileDetail {
wxid: string
displayName: string
remark?: string
nickName?: string
alias?: string
avatarUrl?: string
messageCount?: number
}
interface AvatarProfileMomentPreview {
id: string
src: string
kind: 'image' | 'video'
}
interface AvatarProfileCardState {
x: number
y: number
target: AvatarProfileTarget
detail?: AvatarProfileDetail | null
detailLoading: boolean
detailError?: string | null
momentsLoading: boolean
momentPreviews: AvatarProfileMomentPreview[]
}
type GroupMessageCountStatus = 'loading' | 'ready' | 'failed'
interface GroupPanelMember {
@@ -984,12 +1103,12 @@ function resolveQuotedSenderUsername(
}
function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined {
const remark = normalizeSearchIdentityText(member.remark)
if (remark) return remark
const groupNickname = normalizeSearchIdentityText(member.groupNickname)
if (groupNickname) return groupNickname
const remark = normalizeSearchIdentityText(member.remark)
if (remark) return remark
const nickname = normalizeSearchIdentityText(member.nickname)
if (nickname) return nickname
@@ -1124,7 +1243,10 @@ async function resolveQuotedSenderDisplayName(options: {
}
try {
const profile = await window.electronAPI.chat.getContactAvatar(normalizedSender)
const profile = await window.electronAPI.chat.getContactAvatar(
normalizedSender,
options.isGroupChat ? normalizedSessionId : undefined
)
const profileDisplayName = normalizeSearchIdentityText(profile?.displayName)
if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) {
quotedSenderDisplayCache.set(cacheKey, {
@@ -1583,6 +1705,8 @@ function ChatPage(props: ChatPageProps) {
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
const [exportPrepareHint, setExportPrepareHint] = useState('')
const avatarProfileRequestSeqRef = useRef(0)
const [avatarProfileCard, setAvatarProfileCard] = useState<AvatarProfileCardState | null>(null)
// 消息右键菜单
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null)
@@ -7122,6 +7246,126 @@ function ChatPage(props: ChatPageProps) {
}
}, [])
const clampAvatarProfilePosition = useCallback((x: number, y: number) => {
const viewportPadding = 12
const estimatedCardWidth = 340
const estimatedCardHeight = 560
const maxLeft = Math.max(viewportPadding, window.innerWidth - estimatedCardWidth - viewportPadding)
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedCardHeight - viewportPadding)
return {
x: Math.min(Math.max(x, viewportPadding), maxLeft),
y: Math.min(Math.max(y, viewportPadding), maxTop)
}
}, [])
const loadAvatarProfileExtras = useCallback(async (target: AvatarProfileTarget, requestSeq: number) => {
const username = String(target.username || '').trim()
if (!username || username === 'self') {
setAvatarProfileCard(prev => prev && requestSeq === avatarProfileRequestSeqRef.current
? { ...prev, detailLoading: false, momentsLoading: false }
: prev)
return
}
const loadMomentPreviews = async (): Promise<AvatarProfileMomentPreview[]> => {
if (!target.canOpenMoments) return []
const previews: AvatarProfileMomentPreview[] = []
let offset = 0
const pageSize = 12
const maxPages = 5
for (let page = 0; page < maxPages && previews.length < 5; page++) {
if (requestSeq !== avatarProfileRequestSeqRef.current) return previews
const timelineResult = await window.electronAPI.sns.getTimeline(pageSize, offset, [username])
if (!timelineResult.success || !Array.isArray(timelineResult.timeline) || timelineResult.timeline.length === 0) {
break
}
offset += timelineResult.timeline.length
for (const post of timelineResult.timeline) {
if (previews.length >= 5) break
const media = post.media?.find(item => item.thumb || item.url)
if (!media) continue
try {
const proxyResult = await window.electronAPI.sns.proxyImage({
url: media.thumb || media.url,
key: media.key
})
if (requestSeq !== avatarProfileRequestSeqRef.current) return previews
if (proxyResult.success && proxyResult.dataUrl) {
previews.push({
id: `${post.id}-${previews.length}`,
src: proxyResult.dataUrl,
kind: 'image'
})
} else if (proxyResult.success && proxyResult.videoPath) {
previews.push({
id: `${post.id}-${previews.length}`,
src: `file://${proxyResult.videoPath.replace(/\\/g, '/')}`,
kind: 'video'
})
}
} catch {
// Try the next recent media item.
}
}
}
return previews
}
const [detailResult, previewsResult] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailFast(username),
loadMomentPreviews()
])
if (requestSeq !== avatarProfileRequestSeqRef.current) return
let detail: AvatarProfileDetail | null = null
let detailError: string | null = null
if (detailResult.status === 'fulfilled' && detailResult.value?.success && detailResult.value.detail) {
const nextDetail = detailResult.value.detail
detail = {
wxid: nextDetail.wxid || username,
displayName: nextDetail.displayName || target.displayName || username,
remark: nextDetail.remark,
nickName: nextDetail.nickName,
alias: nextDetail.alias,
avatarUrl: nextDetail.avatarUrl || target.avatarUrl,
messageCount: nextDetail.messageCount
}
} else if (detailResult.status === 'fulfilled' && detailResult.value && !detailResult.value.success) {
detailError = detailResult.value.error || '资料补全失败'
} else if (detailResult.status === 'rejected') {
detailError = '资料补全失败'
}
const momentPreviews = previewsResult.status === 'fulfilled' ? previewsResult.value : []
setAvatarProfileCard(prev => {
if (!prev || requestSeq !== avatarProfileRequestSeqRef.current) return prev
const preservedDetail = prev.detail
return {
...prev,
detail: detail
? {
...detail,
displayName: isUsefulCurrentUserName(preservedDetail?.displayName, username)
? preservedDetail!.displayName
: detail.displayName,
alias: preservedDetail?.alias || detail.alias,
avatarUrl: preservedDetail?.avatarUrl || detail.avatarUrl
}
: preservedDetail,
detailError,
detailLoading: false,
momentsLoading: false,
momentPreviews
}
})
}, [])
// 消息右键菜单处理
const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => {
e.preventDefault()
@@ -7133,10 +7377,97 @@ function ChatPage(props: ChatPageProps) {
})
}, [clampContextMenuPosition])
const handleAvatarContextMenu = useCallback((e: React.MouseEvent, message: Message, profile: MessageAvatarProfile) => {
e.preventDefault()
e.stopPropagation()
setContextMenu(null)
const cachedSelfProfile = profile.isSelf ? readCachedCurrentUserProfile(myWxid || profile.username) : null
const normalizedUsername = normalizeSearchIdentityText(
profile.isSelf
? (cachedSelfProfile?.wxid || myWxid || profile.username)
: profile.username
) || String(profile.username || '').trim()
const username = normalizedUsername || (profile.isSelf ? 'self' : undefined)
const canOpenMoments = Boolean(username && username !== 'self' && isSingleContactSession(username))
const selfDisplayName = isUsefulCurrentUserName(cachedSelfProfile?.displayName, username)
? cachedSelfProfile!.displayName
: undefined
const selfAlias = isUsefulCurrentUserName(cachedSelfProfile?.alias, username)
? cachedSelfProfile!.alias
: undefined
const displayName = profile.isSelf
? (selfDisplayName || selfAlias || profile.displayName || username || '微信用户')
: (normalizeSearchIdentityText(profile.displayName) || profile.displayName || username || '微信用户')
const avatarUrl = normalizeSearchAvatarUrl(profile.avatarUrl) ||
(profile.isSelf ? normalizeSearchAvatarUrl(cachedSelfProfile?.avatarUrl) || myAvatarUrl : undefined)
const sourceLabel = profile.isSelf
? '我发送的消息'
: profile.isGroupMember
? (currentSession?.displayName || currentSessionId || '群聊')
: '当前聊天'
const target: AvatarProfileTarget = {
...profile,
username,
displayName,
avatarUrl,
sourceLabel,
messageTime: message.createTime,
canOpenMoments
}
const initialDetail: AvatarProfileDetail | null = profile.isSelf && username && username !== 'self'
? {
wxid: username,
displayName,
alias: selfAlias,
avatarUrl
}
: null
const nextPos = clampAvatarProfilePosition(e.clientX, e.clientY)
const requestSeq = ++avatarProfileRequestSeqRef.current
setAvatarProfileCard({
x: nextPos.x,
y: nextPos.y,
target,
detail: initialDetail,
detailLoading: Boolean(username && username !== 'self'),
detailError: null,
momentsLoading: canOpenMoments,
momentPreviews: []
})
void loadAvatarProfileExtras(target, requestSeq)
}, [
clampAvatarProfilePosition,
currentSession?.displayName,
currentSessionId,
loadAvatarProfileExtras,
myAvatarUrl,
myWxid
])
const closeAvatarProfileCard = useCallback(() => {
avatarProfileRequestSeqRef.current += 1
setAvatarProfileCard(null)
}, [])
const openAvatarProfileMoments = useCallback(() => {
if (!avatarProfileCard?.target.canOpenMoments || !avatarProfileCard.target.username) return
const detail = avatarProfileCard.detail
setChatSnsTimelineTarget({
username: avatarProfileCard.target.username,
displayName: detail?.remark || detail?.nickName || detail?.displayName || avatarProfileCard.target.displayName || avatarProfileCard.target.username,
avatarUrl: detail?.avatarUrl || avatarProfileCard.target.avatarUrl
})
closeAvatarProfileCard()
}, [avatarProfileCard, closeAvatarProfileCard])
// 关闭右键菜单
useEffect(() => {
const handleClick = () => {
setContextMenu(null)
setAvatarProfileCard(null)
}
window.addEventListener('click', handleClick)
return () => {
@@ -7358,6 +7689,16 @@ function ChatPage(props: ChatPageProps) {
const isSystem = isSystemMessage(msg.localType)
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const messageKey = getMessageKey(msg)
const senderUsername = String(msg.senderUsername || '').trim()
const matchedGroupMember = isCurrentSessionGroup && senderUsername
? groupPanelMembers.find((member) => (
isSameQuotedSenderIdentity(member.username, senderUsername) ||
isSameQuotedSenderIdentity(member.alias, senderUsername)
))
: undefined
const groupSenderDisplayName = matchedGroupMember
? resolveQuotedGroupMemberDisplayName(matchedGroupMember)
: undefined
return (
<div className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
@@ -7373,10 +7714,12 @@ function ChatPage(props: ChatPageProps) {
myAvatarUrl={myAvatarUrl}
myWxid={myWxid}
isGroupChat={isCurrentSessionGroup}
groupSenderDisplayName={groupSenderDisplayName}
quoteLayout={quoteLayout}
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu}
onAvatarContextMenu={handleAvatarContextMenu}
onJumpToQuotedMessage={handleJumpToQuotedMessage}
isSelectionMode={isSelectionMode}
messageKey={messageKey}
@@ -7396,10 +7739,12 @@ function ChatPage(props: ChatPageProps) {
myAvatarUrl,
myWxid,
isCurrentSessionGroup,
groupPanelMembers,
quoteLayout,
autoTranscribeVoiceEnabled,
handleRequireModelDownload,
handleContextMenu,
handleAvatarContextMenu,
handleJumpToQuotedMessage,
isSelectionMode,
selectedMessages,
@@ -8725,6 +9070,23 @@ function ChatPage(props: ChatPageProps) {
document.body
)}
{avatarProfileCard && createPortal(
<>
<div
className="avatar-profile-overlay"
onClick={closeAvatarProfileCard}
/>
<AvatarProfileCard
card={avatarProfileCard}
copiedField={copiedField}
onClose={closeAvatarProfileCard}
onOpenMoments={openAvatarProfileMoments}
onCopy={(text, field) => void handleCopyField(text, field)}
/>
</>,
document.body
)}
{/* 消息信息弹窗 */}
{showMessageInfo && createPortal(
<div className="message-info-overlay" onClick={() => setShowMessageInfo(null)}>
@@ -9127,7 +9489,7 @@ type SharedImageDecryptResult = {
failureKind?: 'not_found' | 'decrypt_failed'
}
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: string }>({
const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: string; updatedAt?: number }>({
maxEntries: SENDER_AVATAR_CACHE_MAX_ENTRIES
})
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
@@ -9397,6 +9759,146 @@ function MessageInsightControl({
)
}
function AvatarProfileMomentThumb({ preview }: { preview: AvatarProfileMomentPreview }) {
if (preview.kind === 'video') {
return <video src={preview.src} muted preload="metadata" playsInline />
}
return <img src={preview.src} alt="" loading="lazy" referrerPolicy="no-referrer" />
}
function AvatarProfileCard({
card,
copiedField,
onClose,
onOpenMoments,
onCopy
}: {
card: AvatarProfileCardState
copiedField: string | null
onClose: () => void
onOpenMoments: () => void
onCopy: (text: string, field: string) => void
}) {
const { target, detail } = card
const username = detail?.wxid || target.username || ''
const displayName = detail?.remark || detail?.nickName || detail?.displayName || target.displayName || username || '微信用户'
const nickname = detail?.nickName && detail.nickName !== displayName ? detail.nickName : ''
const alias = detail?.alias || ''
const avatarUrl = detail?.avatarUrl || target.avatarUrl
const momentSummaryText = card.momentsLoading
? '加载最近动态...'
: card.momentPreviews.length > 0
? `最近 ${card.momentPreviews.length} 个媒体`
: (target.canOpenMoments ? '暂无可预览媒体' : '不支持朋友圈')
const messageDate = target.messageTime ? formatYmdHmDateTimeFromSeconds(target.messageTime) : ''
const profileRows = [
nickname ? { label: '昵称', value: nickname } : null,
detail?.remark ? { label: '备注', value: detail.remark } : null,
alias ? { label: '微信号', value: alias, copyField: 'avatarProfileAlias' } : null,
username ? { label: '微信ID', value: username, copyField: 'avatarProfileWxid' } : null
].filter(Boolean) as Array<{ label: string; value: string; copyField?: string }>
const moreRows = [
target.sourceLabel ? { label: '来源', value: target.sourceLabel } : null,
messageDate ? { label: '消息时间', value: messageDate } : null,
Number.isFinite(detail?.messageCount)
? { label: '聊天记录', value: `${Number(detail?.messageCount || 0).toLocaleString('zh-CN')} 条` }
: null
].filter(Boolean) as Array<{ label: string; value: string }>
return (
<div
className="avatar-profile-card"
style={{ top: card.y, left: card.x }}
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-label="个人信息"
>
<button className="avatar-profile-close" type="button" onClick={onClose} title="关闭">
<X size={15} />
</button>
<div className="avatar-profile-hero">
<Avatar src={avatarUrl} name={displayName} size={58} className="avatar-profile-photo" lazy={false} />
<div className="avatar-profile-title">
<h3>{displayName}</h3>
<p>{target.isSelf ? '我' : (target.isGroupMember ? '群聊成员' : '聊天联系人')}</p>
</div>
</div>
<div className="avatar-profile-section">
{card.detailLoading && profileRows.length === 0 ? (
<div className="avatar-profile-loading">
<Loader2 size={14} className="spin" />
<span>正在补全资料...</span>
</div>
) : profileRows.length > 0 ? (
profileRows.map(row => (
<div className="avatar-profile-row" key={row.label}>
<span>{row.label}</span>
<strong title={row.value}>{row.value}</strong>
{row.copyField && (
<button
type="button"
className="avatar-profile-copy"
onClick={() => onCopy(row.value, row.copyField!)}
title="复制"
>
{copiedField === row.copyField ? <Check size={12} /> : <Copy size={12} />}
</button>
)}
</div>
))
) : (
<div className="avatar-profile-empty">暂无更多资料</div>
)}
{card.detailError && <div className="avatar-profile-error">{card.detailError}</div>}
</div>
<button
className={`avatar-profile-moments ${target.canOpenMoments ? '' : 'disabled'}`}
type="button"
onClick={target.canOpenMoments ? onOpenMoments : undefined}
disabled={!target.canOpenMoments}
>
<div className="avatar-profile-moments-head">
<span>朋友圈</span>
<strong>{momentSummaryText}</strong>
</div>
{card.momentsLoading ? (
<div className="avatar-profile-moments-loading">
<Loader2 size={14} className="spin" />
</div>
) : card.momentPreviews.length > 0 ? (
<div className="avatar-profile-moment-strip">
{card.momentPreviews.slice(0, 5).map(preview => (
<div className="avatar-profile-moment-thumb" key={preview.id}>
<AvatarProfileMomentThumb preview={preview} />
</div>
))}
</div>
) : (
<div className="avatar-profile-moments-empty">
{target.canOpenMoments ? '最近动态里暂未找到图片或视频' : '群聊和公众号不显示朋友圈'}
</div>
)}
</button>
{moreRows.length > 0 && (
<div className="avatar-profile-section compact">
{moreRows.map(row => (
<div className="avatar-profile-row" key={row.label}>
<span>{row.label}</span>
<strong title={row.value}>{row.value}</strong>
</div>
))}
</div>
)}
</div>
)
}
function MessageBubble({
message,
messageKey,
@@ -9405,10 +9907,12 @@ function MessageBubble({
myAvatarUrl,
myWxid,
isGroupChat,
groupSenderDisplayName,
quoteLayout,
autoTranscribeVoiceEnabled,
onRequireModelDownload,
onContextMenu,
onAvatarContextMenu,
onJumpToQuotedMessage,
isSelectionMode,
isSelected,
@@ -9423,10 +9927,12 @@ function MessageBubble({
myAvatarUrl?: string;
myWxid?: string;
isGroupChat?: boolean;
groupSenderDisplayName?: string;
quoteLayout: configService.QuoteLayout;
autoTranscribeVoiceEnabled?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
onAvatarContextMenu?: (e: React.MouseEvent, message: Message, profile: MessageAvatarProfile) => void;
onJumpToQuotedMessage?: (target: QuotedMessageJumpTarget) => void;
isSelectionMode?: boolean;
isSelected?: boolean;
@@ -9753,9 +10259,16 @@ function MessageBubble({
// 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取)
useEffect(() => {
const sender = String(message.senderUsername || '').trim()
const cached = sender ? senderAvatarCache.get(sender) : undefined
setSenderAvatarUrl(cached?.avatarUrl || message.senderAvatarUrl || undefined)
setSenderName(cached?.displayName || message.senderDisplayName || undefined)
const profileCacheKey = sender
? `${isGroupChat ? session.username : 'contact'}::${sender}`
: ''
const cached = profileCacheKey ? senderAvatarCache.get(profileCacheKey) : undefined
const cachedFresh = Boolean(
cached &&
(!isGroupChat || Date.now() - (cached.updatedAt || 0) < SENDER_AVATAR_CACHE_TTL_MS)
)
setSenderAvatarUrl((cachedFresh ? cached?.avatarUrl : undefined) || message.senderAvatarUrl || undefined)
setSenderName(groupSenderDisplayName || (cachedFresh ? cached?.displayName : undefined) || message.senderDisplayName || undefined)
if (!sender || !(isGroupChat || (isSent && !myAvatarUrl))) return
@@ -9769,14 +10282,14 @@ function MessageBubble({
if (result.displayName) setSenderName(result.displayName)
}
if (cached) {
if (cachedFresh && cached) {
applyProfile(cached)
return () => {
cancelled = true
}
}
const pending = senderAvatarLoading.get(sender)
const pending = senderAvatarLoading.get(profileCacheKey)
if (pending) {
pending.then(applyProfile).catch(() => { })
return () => {
@@ -9784,23 +10297,23 @@ function MessageBubble({
}
}
const request = window.electronAPI.chat.getContactAvatar(sender)
senderAvatarLoading.set(sender, request)
const request = window.electronAPI.chat.getContactAvatar(sender, isGroupChat ? session.username : undefined)
senderAvatarLoading.set(profileCacheKey, request)
request.then((result: { avatarUrl?: string; displayName?: string } | null) => {
if (result) {
senderAvatarCache.set(sender, result)
senderAvatarCache.set(profileCacheKey, { ...result, updatedAt: Date.now() })
}
applyProfile(result)
}).catch(() => { }).finally(() => {
if (senderAvatarLoading.get(sender) === request) {
senderAvatarLoading.delete(sender)
if (senderAvatarLoading.get(profileCacheKey) === request) {
senderAvatarLoading.delete(profileCacheKey)
}
})
return () => {
cancelled = true
}
}, [isGroupChat, isSent, message.senderAvatarUrl, message.senderDisplayName, message.senderUsername, myAvatarUrl])
}, [groupSenderDisplayName, isGroupChat, isSent, message.senderAvatarUrl, message.senderDisplayName, message.senderUsername, myAvatarUrl, session.username])
// 解析转账消息的付款方和收款方显示名称
useEffect(() => {
@@ -10660,11 +11173,38 @@ function MessageBubble({
// - 群聊中对方发的:使用发送者头像
// - 私聊中对方发的:使用会话头像
const fallbackSenderName = String(message.senderDisplayName || message.senderUsername || '').trim() || undefined
const resolvedSenderName = senderName || fallbackSenderName
const resolvedSenderName = groupSenderDisplayName || senderName || fallbackSenderName
const resolvedSenderAvatarUrl = senderAvatarUrl || message.senderAvatarUrl
const avatarUrl = isSent
? (myAvatarUrl || resolvedSenderAvatarUrl)
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
const avatarProfile = useMemo<MessageAvatarProfile>(() => {
const username = isSent
? (myWxid || message.senderUsername || session.selfWxid || undefined)
: (isGroupChat ? (message.senderUsername || undefined) : session.username)
return {
username: normalizeSearchIdentityText(username) || username,
displayName: isSent
? '我'
: (isGroupChat
? (resolvedSenderName || message.senderDisplayName || message.senderUsername || '群成员')
: (session.displayName || session.username || '微信用户')),
avatarUrl,
isSelf: isSent,
isGroupMember: Boolean(isGroupChat && !isSent)
}
}, [
avatarUrl,
isGroupChat,
isSent,
message.senderDisplayName,
message.senderUsername,
myWxid,
resolvedSenderName,
session.displayName,
session.selfWxid,
session.username
])
const canShowMessageInsight = Boolean(
aiMessageInsightEnabled &&
!isSent &&
@@ -12037,9 +12577,11 @@ function MessageBubble({
avatarUrl={avatarUrl}
isGroupChat={isGroupChat}
resolvedSenderName={resolvedSenderName}
avatarProfile={avatarProfile}
isSelectionMode={isSelectionMode}
isSelected={isSelected}
onContextMenu={onContextMenu}
onAvatarContextMenu={onAvatarContextMenu}
onToggleSelection={onToggleSelection}
actionNode={messageInsightControl}
portal={systemAlertPortal}
@@ -12056,12 +12598,14 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
if (prevProps.myWxid !== nextProps.myWxid) return false
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
if (prevProps.groupSenderDisplayName !== nextProps.groupSenderDisplayName) return false
if (prevProps.quoteLayout !== nextProps.quoteLayout) return false
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false
if (prevProps.isSelected !== nextProps.isSelected) return false
if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
if (prevProps.onAvatarContextMenu !== nextProps.onAvatarContextMenu) return false
if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
if (prevProps.aiMessageInsightEnabled !== nextProps.aiMessageInsightEnabled) return false
+46 -16
View File
@@ -20,6 +20,7 @@ const VIRTUAL_ROW_HEIGHT = 64
const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const CONTACTS_CACHE_SCHEMA_VERSION = 'contacts-v5'
interface ContactsLoadSession {
requestId: string
@@ -49,8 +50,10 @@ function ContactsPage() {
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: false,
officials: false,
deletedFriends: false
officialSubscriptions: false,
officialServices: false,
deletedFriends: false,
blocked: false
})
// 导出模式与查看详情
@@ -103,8 +106,8 @@ function ContactsPage() {
configService.getMyWxid()
])
const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}`
: 'default'
? `${dbPath || ''}::${myWxid || ''}::${CONTACTS_CACHE_SCHEMA_VERSION}`
: `default::${CONTACTS_CACHE_SCHEMA_VERSION}`
contactsCacheScopeRef.current = scopeKey
return scopeKey
}, [])
@@ -402,7 +405,9 @@ function ContactsPage() {
description: contact.description,
detailDescription: contact.detailDescription,
region: contact.region,
type: contact.type
type: contact.type,
officialAccountKind: contact.officialAccountKind,
officialAccountType: contact.officialAccountType
}))
).catch((error) => {
console.error('写入通讯录缓存失败:', error)
@@ -550,8 +555,12 @@ function ContactsPage() {
let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) return false
if (contact.type === 'group' && !contactTypes.groups) return false
if (contact.type === 'official' && !contactTypes.officials) return false
if (contact.type === 'official') {
if (contact.officialAccountKind === 'service') return contactTypes.officialServices
return contactTypes.officialSubscriptions
}
if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false
if (contact.type === 'blocked' && !contactTypes.blocked) return false
return true
})
@@ -668,7 +677,7 @@ function ContactsPage() {
? { key: 'signature', label: '个性签名', value: selectedContact.detailDescription }
: null,
selectedContact.region ? { key: 'region', label: '地区', value: selectedContact.region } : null,
{ key: 'type', label: '类型', value: getContactTypeName(selectedContact.type) }
{ key: 'type', label: '类型', value: getContactTypeName(selectedContact) }
].filter((row): row is { key: string; label: string; value: string } => Boolean(row && row.value))
}, [selectedContact])
@@ -793,16 +802,24 @@ function ContactsPage() {
case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} />
case 'former_friend': return <UserX size={14} />
case 'blocked': return <UserX size={14} />
default: return <User size={14} />
}
}
function getContactTypeName(type: string) {
function getContactTypeName(contactOrType: ContactInfo | string) {
const type = typeof contactOrType === 'string' ? contactOrType : contactOrType.type
if (typeof contactOrType !== 'string' && type === 'official') {
if (contactOrType.officialAccountKind === 'service') return '服务号'
if (contactOrType.officialAccountKind === 'enterprise') return '企业号'
return '公众号'
}
switch (type) {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
case 'former_friend': return '曾经的好友'
case 'blocked': return '黑名单'
default: return '其他'
}
}
@@ -840,7 +857,8 @@ function ContactsPage() {
contactTypes: {
friends: contactTypes.friends,
groups: contactTypes.groups,
officials: contactTypes.officials
officials: contactTypes.officialSubscriptions || contactTypes.officialServices,
blocked: contactTypes.blocked
},
selectedUsernames: Array.from(selectedUsernames)
}
@@ -918,11 +936,17 @@ function ContactsPage() {
<span className="chip-label">群聊</span>
<span className="chip-count">{contactTypeCounts.groups}</span>
</label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<label className={`filter-chip ${contactTypes.officialSubscriptions ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officialSubscriptions} onChange={e => setContactTypes({ ...contactTypes, officialSubscriptions: e.target.checked })} />
<MessageSquare size={16} />
<span className="chip-label">公众号</span>
<span className="chip-count">{contactTypeCounts.officials}</span>
<span className="chip-count">{contactTypeCounts.officialSubscriptions}</span>
</label>
<label className={`filter-chip ${contactTypes.officialServices ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officialServices} onChange={e => setContactTypes({ ...contactTypes, officialServices: e.target.checked })} />
<MessageSquare size={16} />
<span className="chip-label">服务号</span>
<span className="chip-count">{contactTypeCounts.officialServices}</span>
</label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
@@ -930,6 +954,12 @@ function ContactsPage() {
<span className="chip-label">曾经的好友</span>
<span className="chip-count">{contactTypeCounts.deletedFriends}</span>
</label>
<label className={`filter-chip ${contactTypes.blocked ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.blocked} onChange={e => setContactTypes({ ...contactTypes, blocked: e.target.checked })} />
<UserX size={16} />
<span className="chip-label">黑名单</span>
<span className="chip-count">{contactTypeCounts.blocked}</span>
</label>
</div>
@@ -1038,9 +1068,9 @@ function ContactsPage() {
<div className="contact-remark">备注: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
<div className={`contact-type ${contact.type} ${contact.officialAccountKind || ''}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
<span>{getContactTypeName(contact)}</span>
</div>
</div>
</div>
@@ -1138,9 +1168,9 @@ function ContactsPage() {
)}
</div>
<div className="contact-detail-heading">
<div className={`contact-type detail-type ${selectedContact.type}`}>
<div className={`contact-type detail-type ${selectedContact.type} ${selectedContact.officialAccountKind || ''}`}>
{getContactTypeIcon(selectedContact.type)}
<span>{getContactTypeName(selectedContact.type)}</span>
<span>{getContactTypeName(selectedContact)}</span>
</div>
<h2>{selectedContactTitle}</h2>
{selectedContactSubtitle && <p>{selectedContactSubtitle}</p>}
+15 -5
View File
@@ -1005,7 +1005,9 @@ export interface ContactsListCacheContact {
description?: string
detailDescription?: string
region?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
type: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
officialAccountKind?: 'subscription' | 'service' | 'enterprise' | 'unknown'
officialAccountType?: number
}
export interface ContactsListCacheItem {
@@ -1532,9 +1534,13 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
description: typeof item.description === 'string' ? (item.description.trim() || undefined) : undefined,
detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined,
region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined,
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'blocked' || type === 'other')
? type
: 'other'
: 'other',
officialAccountKind: item.officialAccountKind === 'subscription' || item.officialAccountKind === 'service' || item.officialAccountKind === 'enterprise' || item.officialAccountKind === 'unknown'
? item.officialAccountKind
: undefined,
officialAccountType: Number.isFinite(Number(item.officialAccountType)) ? Math.floor(Number(item.officialAccountType)) : undefined
})
}
@@ -1557,7 +1563,7 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
if (!username) continue
const displayName = String(contact?.displayName || username)
const type = contact?.type || 'other'
if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') {
if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'blocked' && type !== 'other') {
continue
}
normalized.push({
@@ -1572,7 +1578,11 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
description: contact?.description ? (String(contact.description).trim() || undefined) : undefined,
detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined,
region: contact?.region ? (String(contact.region).trim() || undefined) : undefined,
type
type,
officialAccountKind: contact?.officialAccountKind === 'subscription' || contact?.officialAccountKind === 'service' || contact?.officialAccountKind === 'enterprise' || contact?.officialAccountKind === 'unknown'
? contact.officialAccountKind
: undefined,
officialAccountType: Number.isFinite(Number(contact?.officialAccountType)) ? Math.floor(Number(contact.officialAccountType)) : undefined
})
}
+36 -4
View File
@@ -5,21 +5,36 @@ export interface ContactTypeTabCounts {
private: number
group: number
official: number
official_subscription: number
official_service: number
official_enterprise: number
official_unknown: number
former_friend: number
blocked: number
}
export interface ContactTypeCardCounts {
friends: number
groups: number
officials: number
officialSubscriptions: number
officialServices: number
officialEnterprises: number
officialUnknown: number
deletedFriends: number
blocked: number
}
const emptyTabCounts: ContactTypeTabCounts = {
private: 0,
group: 0,
official: 0,
former_friend: 0
official_subscription: 0,
official_service: 0,
official_enterprise: 0,
official_unknown: 0,
former_friend: 0,
blocked: 0
}
let inflightPromise: Promise<ContactTypeTabCounts> | null = null
@@ -29,7 +44,12 @@ const normalizeCounts = (counts?: Partial<ContactTypeTabCounts> | null): Contact
private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0,
group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0,
official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0,
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0
official_subscription: Number.isFinite(counts?.official_subscription) ? Math.max(0, Math.floor(Number(counts?.official_subscription))) : 0,
official_service: Number.isFinite(counts?.official_service) ? Math.max(0, Math.floor(Number(counts?.official_service))) : 0,
official_enterprise: Number.isFinite(counts?.official_enterprise) ? Math.max(0, Math.floor(Number(counts?.official_enterprise))) : 0,
official_unknown: Number.isFinite(counts?.official_unknown) ? Math.max(0, Math.floor(Number(counts?.official_unknown))) : 0,
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0,
blocked: Number.isFinite(counts?.blocked) ? Math.max(0, Math.floor(Number(counts?.blocked))) : 0
}
}
@@ -38,8 +58,15 @@ export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): Con
for (const contact of contacts || []) {
if (contact.type === 'friend') next.private += 1
if (contact.type === 'group') next.group += 1
if (contact.type === 'official') next.official += 1
if (contact.type === 'official') {
next.official += 1
if (contact.officialAccountKind === 'subscription') next.official_subscription += 1
else if (contact.officialAccountKind === 'service') next.official_service += 1
else if (contact.officialAccountKind === 'enterprise') next.official_enterprise += 1
else next.official_unknown += 1
}
if (contact.type === 'former_friend') next.former_friend += 1
if (contact.type === 'blocked') next.blocked += 1
}
return next
}
@@ -49,7 +76,12 @@ export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTy
friends: counts.private,
groups: counts.group,
officials: counts.official,
deletedFriends: counts.former_friend
officialSubscriptions: counts.official_subscription,
officialServices: counts.official_service,
officialEnterprises: counts.official_enterprise,
officialUnknown: counts.official_unknown,
deletedFriends: counts.former_friend,
blocked: counts.blocked
}
}
+5 -2
View File
@@ -563,6 +563,7 @@ export interface ElectronAPI {
group: number
official: number
former_friend: number
blocked?: number
}
error?: string
}>
@@ -573,6 +574,7 @@ export interface ElectronAPI {
group: number
official: number
former_friend: number
blocked?: number
}
error?: string
}>
@@ -626,7 +628,7 @@ export interface ElectronAPI {
error?: string
}>
getContact: (username: string) => Promise<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
getContactAvatar: (username: string, chatroomId?: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
checkAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
@@ -900,6 +902,7 @@ export interface ElectronAPI {
}
biz: {
listAccounts: (account?: string) => Promise<any[]>
listAccountHealth: (account?: string) => Promise<any>
listMessages: (username: string, account?: string, limit?: number, offset?: number) => Promise<any[]>
listPayRecords: (account?: string, limit?: number, offset?: number) => Promise<any[]>
}
@@ -1505,7 +1508,7 @@ export interface ElectronAPI {
success: boolean
error?: string
}>
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean }; selectedUsernames?: string[] }) => Promise<{
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean; blocked?: boolean }; selectedUsernames?: string[] }) => Promise<{
success: boolean
successCount?: number
error?: string
+3 -1
View File
@@ -42,7 +42,9 @@ export interface ContactInfo {
detailDescription?: string
region?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
type: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
officialAccountKind?: 'subscription' | 'service' | 'enterprise' | 'unknown'
officialAccountType?: number
}
// 消息