* fix:尝试修复闪退的问题

* hhhhh

* fix(chatService): 优化头像加载兜底机制:收集无 URL 的用户名,从 head_image.db 批量获取并转换为 base64 格式,更新头像缓存并添加错误处理,避免聊天界面头像缺失。(解决了部分,我电脑上有几个不显示)

* 优化表诉

* 导出优化

* fix: 尝试修复运行库缺失的问题

* 优化表述

* feat: 实现朋友圈获取; 实现聊天页面跳转到指定日期

* fix:修复了头像加载失败的问题

* Bump version from 1.3.1 to 1.3.2

---------

Co-authored-by: Forrest <jin648862@gmail.com>
Co-authored-by: cc <98377878+hicccc77@users.noreply.github.com>
This commit is contained in:
xuncha
2026-01-23 10:06:16 +08:00
committed by GitHub
parent 49614bf6d8
commit 6436c39c90
30 changed files with 2170 additions and 153 deletions

View File

@@ -489,8 +489,21 @@
}
.load-more-trigger {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0;
color: var(--text-tertiary);
font-size: 12px;
font-size: 13px;
&.later {
padding: 24px 0 12px;
}
svg {
animation: spin 1s linear infinite;
}
}
.empty-chat {
@@ -1660,7 +1673,7 @@
max-width: 100%;
min-width: 0; // 允许收缩
-webkit-app-region: no-drag;
// 让气泡宽度由内容决定,而不是被父容器撑开
width: fit-content;
}

View File

@@ -7,6 +7,7 @@ import { getEmojiPath } from 'wechat-emojis'
import { ImagePreview } from '../components/ImagePreview'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import JumpToDateDialog from '../components/JumpToDateDialog'
import * as configService from '../services/config'
import './ChatPage.scss'
@@ -132,15 +133,25 @@ function ChatPage(_props: ChatPageProps) {
setLoadingMessages,
setLoadingMore,
setHasMoreMessages,
hasMoreLater,
setHasMoreLater,
setSearchKeyword
} = useChatStore()
const messageListRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null)
const getMessageKey = useCallback((msg: Message): string => {
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
}, [])
const initialRevealTimerRef = useRef<number | null>(null)
const sessionListRef = useRef<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0)
const [jumpStartTime, setJumpStartTime] = useState(0)
const [jumpEndTime, setJumpEndTime] = useState(0)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
@@ -477,6 +488,9 @@ function ChatPage(_props: ChatPageProps) {
// 刷新会话列表
const handleRefresh = async () => {
setJumpStartTime(0)
setJumpEndTime(0)
setHasMoreLater(false)
await loadSessions({ silent: true })
}
@@ -484,6 +498,9 @@ function ChatPage(_props: ChatPageProps) {
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
const handleRefreshMessages = async () => {
if (!currentSessionId || isRefreshingMessages) return
setJumpStartTime(0)
setJumpEndTime(0)
setHasMoreLater(false)
setIsRefreshingMessages(true)
try {
// 获取最新消息并增量添加
@@ -518,7 +535,7 @@ function ChatPage(_props: ChatPageProps) {
}
// 加载消息
const loadMessages = async (sessionId: string, offset = 0) => {
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {
const listEl = messageListRef.current
const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0
@@ -535,7 +552,7 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try {
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit)
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime)
if (result.success && result.messages) {
if (offset === 0) {
setMessages(result.messages)
@@ -601,6 +618,14 @@ function ChatPage(_props: ChatPageProps) {
}
}
setHasMoreMessages(result.hasMore ?? false)
// 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息
if (offset === 0) {
if (endTime > 0) {
setHasMoreLater(true)
} else {
setHasMoreLater(false)
}
}
setCurrentOffset(offset + result.messages.length)
} else if (!result.success) {
setConnectionError(result.error || '加载消息失败')
@@ -616,12 +641,41 @@ function ChatPage(_props: ChatPageProps) {
}
}
// 加载更晚的消息
const loadLaterMessages = useCallback(async () => {
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
setLoadingMore(true)
try {
const lastMsg = messages[messages.length - 1]
// 从最后一条消息的时间开始往后找
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true)
if (result.success && result.messages) {
// 过滤掉已经在列表中的重复消息
const existingKeys = messageKeySetRef.current
const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
if (newMsgs.length > 0) {
appendMessages(newMsgs, false)
}
setHasMoreLater(result.hasMore ?? false)
}
} catch (e) {
console.error('加载后续消息失败:', e)
} finally {
setLoadingMore(false)
}
}, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore])
// 选择会话
const handleSelectSession = (session: ChatSession) => {
if (session.username === currentSessionId) return
setCurrentSession(session.username)
setCurrentOffset(0)
loadMessages(session.username, 0)
setJumpStartTime(0)
setJumpEndTime(0)
loadMessages(session.username, 0, 0, 0)
// 重置详情面板
setSessionDetail(null)
if (showDetailPanel) {
@@ -678,16 +732,21 @@ function ChatPage(_props: ChatPageProps) {
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
const threshold = clientHeight * 0.3
if (scrollTop < threshold) {
loadMessages(currentSessionId, currentOffset)
loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
}
}
// 预加载更晚的消息
if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) {
const threshold = clientHeight * 0.3
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
if (distanceFromBottom < threshold) {
loadLaterMessages()
}
}
})
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages])
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages])
const getMessageKey = useCallback((msg: Message): string => {
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
}, [])
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
return (
@@ -1102,6 +1161,25 @@ function ChatPage(_props: ChatPageProps) {
)}
</div>
<div className="header-actions">
<button
className="icon-btn jump-to-time-btn"
onClick={() => setShowJumpDialog(true)}
title="跳转到指定时间"
>
<Calendar size={18} />
</button>
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => setShowJumpDialog(false)}
onSelect={(date) => {
if (!currentSessionId) return
const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
setCurrentOffset(0)
setJumpStartTime(0)
setJumpEndTime(end)
loadMessages(currentSessionId, 0, 0, end)
}}
/>
<button
className="icon-btn refresh-messages-btn"
onClick={handleRefreshMessages}
@@ -1177,6 +1255,19 @@ function ChatPage(_props: ChatPageProps) {
)
})}
{hasMoreLater && (
<div className={`load-more-trigger later ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>...</span>
</>
) : (
<span></span>
)}
</div>
)}
{/* 回到底部按钮 */}
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
<ChevronDown size={16} />

View File

@@ -231,12 +231,12 @@ function ExportPage() {
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns,
sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
// ?????????????????????????????????23:59:59,??????????????????????????????
// 将结束日期设置为当天的 23:59:59确保包含当天的所有记录
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null
}
@@ -249,10 +249,10 @@ function ExportPage() {
)
setExportResult(result)
} else {
setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` })
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
}
} catch (e) {
console.error('????????????:', e)
console.error('导出过程中发生异常:', e)
setExportResult({ success: false, error: String(e) })
} finally {
setIsExporting(false)

579
src/pages/SnsPage.scss Normal file
View File

@@ -0,0 +1,579 @@
.sns-page {
height: 100%;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
.sns-container {
display: flex;
height: 100%;
}
.sns-sidebar {
width: 280px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
&.closed {
width: 0;
opacity: 0;
pointer-events: none;
}
.sidebar-header {
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.toggle-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
}
.filter-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 10px;
}
}
.filter-group {
padding-bottom: 4px;
border-bottom: 1px solid var(--border-color);
}
.filter-section {
padding: 10px 20px;
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
font-weight: 500;
}
input[type="text"] {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
outline: none;
&:focus {
border-color: var(--accent-color);
}
}
.date-inputs {
display: flex;
flex-direction: column;
gap: 6px;
input[type="date"] {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 10px;
color: var(--text-primary);
font-size: 12px;
outline: none;
width: 100%;
&:focus {
border-color: var(--accent-color);
}
}
span {
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
}
}
.contact-filter-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 200px;
padding: 12px 0 0 0;
.section-header {
padding: 0 20px 8px 20px;
display: flex;
justify-content: space-between;
align-items: center;
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.selected-count {
font-size: 11px;
background: var(--accent-color);
color: white;
padding: 1px 6px;
border-radius: 10px;
}
}
.contact-search {
margin: 0 20px 10px 20px;
position: relative;
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
}
input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 10px 6px 28px;
font-size: 12px;
color: var(--text-primary);
outline: none;
&:focus {
border-color: var(--accent-color);
}
}
}
.contact-list {
flex: 1;
overflow-y: auto;
padding: 0 10px;
.contact-item {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
gap: 10px;
margin-bottom: 2px;
position: relative;
&:hover {
background: var(--hover-bg);
}
&.active {
background: rgba(var(--accent-color-rgb), 0.1);
.contact-name {
color: var(--accent-color);
font-weight: 600;
}
}
.contact-name {
flex: 1;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-secondary);
}
.check-mark {
color: var(--accent-color);
font-size: 12px;
font-weight: bold;
}
}
.empty-contacts {
text-align: center;
padding: 20px;
font-size: 12px;
color: var(--text-tertiary);
}
}
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
.clear-btn {
width: 100%;
padding: 8px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
&:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
}
}
.sns-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
.sns-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.header-left {
display: flex;
align-items: center;
gap: 12px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.icon-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
.spinning {
animation: spin 1s linear infinite;
}
}
.sns-content {
flex: 1;
overflow-y: auto;
padding: 24px;
scroll-behavior: smooth;
.active-filters {
max-width: 680px;
margin: 0 auto 16px auto;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(var(--accent-color-rgb), 0.05);
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
color: var(--accent-color);
.clear-chip-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 12px;
text-decoration: underline;
&:hover {
color: var(--text-secondary);
}
}
}
.sns-post {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
max-width: 680px;
margin-left: auto;
margin-right: auto;
border: 1px solid var(--border-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
.post-header {
display: flex;
align-items: center;
margin-bottom: 14px;
.post-info {
margin-left: 12px;
.nickname {
font-weight: 600;
margin-bottom: 2px;
color: var(--accent-color);
}
.time {
font-size: 12px;
color: var(--text-tertiary);
}
}
}
.post-body {
margin-bottom: 16px;
.post-text {
margin-bottom: 12px;
white-space: pre-wrap;
line-height: 1.6;
font-size: 15px;
word-break: break-word;
}
.post-media-grid {
display: grid;
gap: 4px;
width: fit-content;
max-width: 100%;
&.media-count-1 {
grid-template-columns: 1fr;
.media-item {
max-width: 400px;
aspect-ratio: unset;
}
img {
height: auto;
max-height: 500px;
}
}
&.media-count-2,
&.media-count-4 {
grid-template-columns: repeat(2, 1fr);
}
&.media-count-3,
&.media-count-5,
&.media-count-6,
&.media-count-7,
&.media-count-8,
&.media-count-9 {
grid-template-columns: repeat(3, 1fr);
}
.media-item {
aspect-ratio: 1;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
&.error {
background: transparent;
border: 1px dashed var(--border-color);
color: var(--text-tertiary);
font-size: 12px;
min-width: 100px;
min-height: 100px;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.85;
}
}
}
}
.post-video-placeholder {
display: inline-flex;
align-items: center;
background: rgba(var(--accent-color-rgb), 0.1);
color: var(--accent-color);
padding: 6px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
}
.post-footer {
background: var(--bg-tertiary);
border-radius: 6px;
padding: 10px 12px;
font-size: 13.5px;
.likes-section {
display: flex;
align-items: flex-start;
color: var(--accent-color);
padding-bottom: 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 8px;
&:last-child {
padding-bottom: 0;
border-bottom: none;
margin-bottom: 0;
}
.icon {
margin-top: 3.5px;
margin-right: 8px;
flex-shrink: 0;
}
.likes-list {
line-height: 1.5;
}
}
.comments-section {
.comment-item {
margin-bottom: 6px;
line-height: 1.5;
&:last-child {
margin-bottom: 0;
}
.comment-user {
color: var(--accent-color);
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.reply-text {
color: var(--text-tertiary);
margin: 0 4px;
font-size: 13px;
}
.comment-separator {
color: var(--text-secondary);
margin-left: -2px;
margin-right: 4px;
}
.comment-content {
color: var(--text-secondary);
}
}
}
}
}
.loading-more,
.no-more,
.no-results {
text-align: center;
padding: 40px 20px;
color: var(--text-tertiary);
font-size: 14px;
.reset-inline {
margin-top: 12px;
background: var(--accent-color);
color: white;
border: none;
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
box-shadow: 0 2px 6px rgba(var(--accent-color-rgb), 0.3);
}
}
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

421
src/pages/SnsPage.tsx Normal file
View File

@@ -0,0 +1,421 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import { ImagePreview } from '../components/ImagePreview'
import './SnsPage.scss'
interface SnsPost {
id: string
username: string
nickname: string
avatarUrl?: string
createTime: number
contentDesc: string
type?: number
media: { url: string; thumb: string }[]
likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
}
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
const [error, setError] = useState(false);
if (error) {
return (
<div className="media-item error">
<span></span>
</div>
);
}
return (
<div className="media-item">
<img
src={thumb || url}
alt=""
loading="lazy"
onClick={onPreview}
onError={() => setError(true)}
/>
</div>
);
};
interface Contact {
username: string
displayName: string
avatarUrl?: string
}
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const loadingRef = useRef(false)
// 筛选与搜索状态
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
// 联系人列表状态
const [contacts, setContacts] = useState<Contact[]>([])
const [contactSearch, setContactSearch] = useState('')
const [contactsLoading, setContactsLoading] = useState(false)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const loadPosts = useCallback(async (reset = false) => {
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const currentOffset = reset ? 0 : offset
const limit = 20
// 转换日期为秒级时间戳
const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined
const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天
const result = await window.electronAPI.sns.getTimeline(
limit,
currentOffset,
selectedUsernames,
searchKeyword,
startTs,
endTs
)
if (result.success && result.timeline) {
if (reset) {
setPosts(result.timeline)
setOffset(limit)
setHasMore(result.timeline.length >= limit)
} else {
setPosts(prev => [...prev, ...result.timeline!])
setOffset(prev => prev + limit)
if (result.timeline.length < limit) {
setHasMore(false)
}
}
}
} catch (error) {
console.error('Failed to load SNS timeline:', error)
} finally {
setLoading(false)
loadingRef.current = false
}
}, [offset, selectedUsernames, searchKeyword, startDate, endDate])
// 获取联系人列表
const loadContacts = async () => {
setContactsLoading(true)
try {
const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions) {
// 系统账号和特殊前缀
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
// 初步提取并过滤联系人
const initialContacts = result.sessions
.filter((s: any) => {
if (!s.username) return false;
const u = s.username.toLowerCase();
// 1. 排除群聊 (WeChat 群组以 @chatroom 结尾)
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) {
return false;
}
// 2. 排除公众号 (通常以 gh_ 开头)
if (u.startsWith('gh_')) {
return false;
}
// 3. 排除系统账号
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) {
return false;
}
return true;
})
.map((s: any) => ({
username: s.username,
displayName: s.displayName || s.username,
avatarUrl: s.avatarUrl
}))
setContacts(initialContacts)
// 异步进一步富化(获取更多准确的昵称和头像)
const usernames = initialContacts.map(c => c.username)
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (enriched.success && enriched.contacts) {
setContacts(prev => prev.map(c => {
const extra = enriched.contacts![c.username]
if (extra) {
return {
...c,
displayName: extra.displayName || c.displayName,
avatarUrl: extra.avatarUrl || c.avatarUrl
}
}
return c
}))
}
}
} catch (error) {
console.error('Failed to load contacts:', error)
} finally {
setContactsLoading(false)
}
}
useEffect(() => {
loadContacts()
}, [])
useEffect(() => {
loadPosts(true)
}, [selectedUsernames, searchKeyword, startDate, endDate])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !loading) {
loadPosts()
}
}
const formatTime = (ts: number) => {
const date = new Date(ts * 1000)
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
return date.toLocaleString('zh-CN', {
year: isCurrentYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const toggleUserSelection = (username: string) => {
setSelectedUsernames(prev => {
if (prev.includes(username)) {
return prev.filter(u => u !== username)
} else {
return [...prev, username]
}
})
}
const clearFilters = () => {
setSearchKeyword('')
setSelectedUsernames([])
setStartDate('')
setEndDate('')
}
const filteredContacts = contacts.filter(c =>
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
)
return (
<div className="sns-page">
<div className="sns-container">
{/* 侧边栏:过滤与搜索 */}
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
<div className="sidebar-header">
<h3></h3>
<button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}>
<X size={18} />
</button>
</div>
<div className="filter-content">
{/* 关键词与时间 */}
<div className="filter-group">
<div className="filter-section">
<label><Search size={14} /> </label>
<input
type="text"
placeholder="搜索正文..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
</div>
<div className="filter-section">
<label><Calendar size={14} /> </label>
<div className="date-inputs">
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
/>
<span></span>
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
/>
</div>
</div>
</div>
{/* 联系人列表 */}
<div className="contact-filter-section">
<div className="section-header">
<label><User size={14} /> </label>
{selectedUsernames.length > 0 && (
<span className="selected-count"> {selectedUsernames.length}</span>
)}
</div>
<div className="contact-search">
<Search size={12} className="search-icon" />
<input
type="text"
placeholder="搜索好友..."
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
/>
</div>
<div className="contact-list custom-scrollbar">
{filteredContacts.map(contact => (
<div
key={contact.username}
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
onClick={() => toggleUserSelection(contact.username)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={28} shape="rounded" />
<span className="contact-name">{contact.displayName}</span>
{selectedUsernames.includes(contact.username) && (
<div className="check-mark"></div>
)}
</div>
))}
{contacts.length === 0 && !contactsLoading && (
<div className="empty-contacts"></div>
)}
</div>
</div>
</div>
<div className="sidebar-footer">
<button className="clear-btn" onClick={clearFilters}></button>
</div>
</aside>
<main className="sns-main">
<div className="sns-header">
<div className="header-left">
{!isSidebarOpen && (
<button className="icon-btn" onClick={() => setIsSidebarOpen(true)}>
<Filter size={20} />
</button>
)}
<h2></h2>
</div>
<div className="header-right">
<button onClick={() => loadPosts(true)} disabled={loading} className="icon-btn refresh-btn">
<RefreshCw size={18} className={loading ? 'spinning' : ''} />
</button>
</div>
</div>
<div className="sns-content" onScroll={handleScroll}>
{selectedUsernames.length > 0 && (
<div className="active-filters">
<span>: {selectedUsernames.length} </span>
<button onClick={() => setSelectedUsernames([])} className="clear-chip-btn"></button>
</div>
)}
{posts.map(post => (
<div key={post.id} className="sns-post">
<div className="post-header">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={44}
shape="rounded"
/>
<div className="post-info">
<div className="nickname">{post.nickname}</div>
<div className="time">{formatTime(post.createTime)}</div>
</div>
</div>
<div className="post-body">
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
{post.type === 15 ? (
<div className="post-video-placeholder">
[]
</div>
) : post.media.length > 0 && (
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
{post.media.map((m, idx) => (
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
))}
</div>
)}
</div>
{(post.likes.length > 0 || post.comments.length > 0) && (
<div className="post-footer">
{post.likes.length > 0 && (
<div className="likes-section">
<Heart size={14} className="icon" />
<span className="likes-list">
{post.likes.join('、')}
</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-section">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-item">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text"></span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-separator">: </span>
<span className="comment-content">{c.content}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
{loading && <div className="loading-more">...</div>}
{!hasMore && posts.length > 0 && <div className="no-more"></div>}
{!loading && posts.length === 0 && (
<div className="no-results">
<p></p>
{selectedUsernames.length > 0 && (
<button onClick={() => setSelectedUsernames([])} className="reset-inline">
</button>
)}
</div>
)}
</div>
</main>
</div>
{previewImage && (
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
)}
</div>
)
}