#1043 #1032;修复增量导出意外添加文件的问题;聊天页面支持右键显示资料卡;优化曾经的好友相关算法并新增黑名单;
This commit is contained in:
1 parent
801ed16d5e
commit
b87444dd64
30 files changed
+1615
-145
No files matched your search
+69
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+5
-2
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
// 消息
|
||||
|
||||
Reference in new issue
Block a user