From 6d74eb65ae0cbdb5cef7d43f047be8cb5c4b4084 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:28:25 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=9C=8B=E5=8F=8B?= =?UTF-8?q?=E5=9C=88=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sns/SnsFilterPanel.tsx | 185 +++ src/components/Sns/SnsMediaGrid.tsx | 327 ++++ src/components/Sns/SnsPostItem.tsx | 263 ++++ src/pages/SnsPage.scss | 1987 ++++++++++--------------- src/pages/SnsPage.tsx | 986 ++---------- src/types/sns.ts | 47 + 6 files changed, 1676 insertions(+), 2119 deletions(-) create mode 100644 src/components/Sns/SnsFilterPanel.tsx create mode 100644 src/components/Sns/SnsMediaGrid.tsx create mode 100644 src/components/Sns/SnsPostItem.tsx create mode 100644 src/types/sns.ts diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx new file mode 100644 index 0000000..6182d88 --- /dev/null +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -0,0 +1,185 @@ +import React, { useState } from 'react' +import { Search, Calendar, User, X, Filter, Check } from 'lucide-react' +import { Avatar } from '../Avatar' +// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved + +interface Contact { + username: string + displayName: string + avatarUrl?: string +} + +interface SnsFilterPanelProps { + searchKeyword: string + setSearchKeyword: (val: string) => void + jumpTargetDate?: Date + setJumpTargetDate: (date?: Date) => void + onOpenJumpDialog: () => void + selectedUsernames: string[] + setSelectedUsernames: (val: string[]) => void + contacts: Contact[] + contactSearch: string + setContactSearch: (val: string) => void + loading?: boolean +} + +export const SnsFilterPanel: React.FC = ({ + searchKeyword, + setSearchKeyword, + jumpTargetDate, + setJumpTargetDate, + onOpenJumpDialog, + selectedUsernames, + setSelectedUsernames, + contacts, + contactSearch, + setContactSearch, + loading +}) => { + + const filteredContacts = contacts.filter(c => + c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || + c.username.toLowerCase().includes(contactSearch.toLowerCase()) + ) + + const toggleUserSelection = (username: string) => { + if (selectedUsernames.includes(username)) { + setSelectedUsernames(selectedUsernames.filter(u => u !== username)) + } else { + setJumpTargetDate(undefined) // Reset date jump when selecting user + setSelectedUsernames([...selectedUsernames, username]) + } + } + + const clearFilters = () => { + setSearchKeyword('') + setSelectedUsernames([]) + setJumpTargetDate(undefined) + } + + return ( + + ) +} + +function RefreshCw({ size, className }: { size?: number, className?: string }) { + return ( + + + + + + ) +} diff --git a/src/components/Sns/SnsMediaGrid.tsx b/src/components/Sns/SnsMediaGrid.tsx new file mode 100644 index 0000000..a9ce6a9 --- /dev/null +++ b/src/components/Sns/SnsMediaGrid.tsx @@ -0,0 +1,327 @@ +import React, { useState } from 'react' +import { Play, Lock, Download } from 'lucide-react' +import { LivePhotoIcon } from '../../components/LivePhotoIcon' +import { RefreshCw } from 'lucide-react' + +interface SnsMedia { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: { + url: string + thumb: string + token?: string + key?: string + encIdx?: string + } +} + +interface SnsMediaGridProps { + mediaList: SnsMedia[] + onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void +} + +const isSnsVideoUrl = (url?: string): boolean => { + if (!url) return false + const lower = url.toLowerCase() + return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') +} + +const extractVideoFrame = async (videoPath: string): Promise => { + return new Promise((resolve, reject) => { + const video = document.createElement('video') + video.preload = 'auto' + video.src = videoPath + video.muted = true + video.currentTime = 0 // Initial reset + // video.crossOrigin = 'anonymous' // Not needed for file:// usually + + const onSeeked = () => { + try { + const canvas = document.createElement('canvas') + canvas.width = video.videoWidth + canvas.height = video.videoHeight + const ctx = canvas.getContext('2d') + if (ctx) { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height) + const dataUrl = canvas.toDataURL('image/jpeg', 0.8) + resolve(dataUrl) + } else { + reject(new Error('Canvas context failed')) + } + } catch (e) { + reject(e) + } finally { + // Cleanup + video.removeEventListener('seeked', onSeeked) + video.src = '' + video.load() + } + } + + video.onloadedmetadata = () => { + if (video.duration === Infinity || isNaN(video.duration)) { + // Determine duration failed, try a fixed small offset + video.currentTime = 1 + } else { + video.currentTime = Math.max(0.1, video.duration / 2) + } + } + + video.onseeked = onSeeked + + video.onerror = (e) => { + reject(new Error('Video load failed')) + } + }) +} + +const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => { + const [error, setError] = useState(false) + const [loading, setLoading] = useState(true) + const [thumbSrc, setThumbSrc] = useState('') + const [videoPath, setVideoPath] = useState('') + const [liveVideoPath, setLiveVideoPath] = useState('') + const [isDecrypting, setIsDecrypting] = useState(false) + const [isGeneratingCover, setIsGeneratingCover] = useState(false) + + const isVideo = isSnsVideoUrl(media.url) + const isLive = !!media.livePhoto + const targetUrl = media.thumb || media.url + + // Simple effect to load image/decrypt + // Simple effect to load image/decrypt + React.useEffect(() => { + let cancelled = false + setLoading(true) + + const load = async () => { + try { + if (!isVideo) { + // For images, we proxy to get the local path/base64 + const result = await window.electronAPI.sns.proxyImage({ + url: targetUrl, + key: media.key + }) + if (cancelled) return + + if (result.success) { + if (result.dataUrl) setThumbSrc(result.dataUrl) + else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`) + } else { + setThumbSrc(targetUrl) + } + + // Pre-load live photo video if needed + if (isLive && media.livePhoto?.url) { + window.electronAPI.sns.proxyImage({ + url: media.livePhoto.url, + key: media.livePhoto.key || media.key + }).then((res: any) => { + if (!cancelled && res.success && res.videoPath) { + setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`) + } + }).catch(() => { }) + } + setLoading(false) + } else { + // Video logic: Decrypt -> Extract Frame + setIsGeneratingCover(true) + + // First check if we already have it decryptable? + // Usually we need to call proxyImage with the video URL to decrypt it to cache + const result = await window.electronAPI.sns.proxyImage({ + url: media.url, + key: media.key + }) + + if (cancelled) return + + if (result.success && result.videoPath) { + const localPath = `file://${result.videoPath.replace(/\\/g, '/')}` + setVideoPath(localPath) + + try { + const coverDataUrl = await extractVideoFrame(localPath) + if (!cancelled) setThumbSrc(coverDataUrl) + } catch (err) { + console.error('Frame extraction failed', err) + // Fallback to video path if extraction fails, though it might be black + // Only set thumbSrc if extraction fails, so we don't override the generated one + } + } else { + console.error('Video decryption for cover failed') + } + + setIsGeneratingCover(false) + setLoading(false) + } + } catch (e) { + console.error(e) + if (!cancelled) { + setThumbSrc(targetUrl) + setLoading(false) + setIsGeneratingCover(false) + } + } + } + + load() + return () => { cancelled = true } + }, [media, isVideo, isLive, targetUrl]) + + const handlePreview = async (e: React.MouseEvent) => { + e.stopPropagation() + if (isVideo) { + // Decrypt video on demand if not already + if (!videoPath) { + setIsDecrypting(true) + try { + const res = await window.electronAPI.sns.proxyImage({ + url: media.url, + key: media.key + }) + if (res.success && res.videoPath) { + const local = `file://${res.videoPath.replace(/\\/g, '/')}` + setVideoPath(local) + onPreview(local, true, undefined) + } else { + alert('视频解密失败') + } + } catch (e) { + console.error(e) + } finally { + setIsDecrypting(false) + } + } else { + onPreview(videoPath, true, undefined) + } + } else { + onPreview(thumbSrc || targetUrl, false, liveVideoPath) + } + } + + const handleDownload = async (e: React.MouseEvent) => { + e.stopPropagation() + setLoading(true) + try { + const result = await window.electronAPI.sns.proxyImage({ + url: media.url, + key: media.key + }) + + if (result.success) { + const link = document.createElement('a') + link.download = `sns_media_${Date.now()}.${isVideo ? 'mp4' : 'jpg'}` + + if (result.dataUrl) { + link.href = result.dataUrl + } else if (result.videoPath) { + // For local video files, we need to fetch as blob to force download behavior + // or just use the file protocol url if the browser supports it + try { + const response = await fetch(`file://${result.videoPath}`) + const blob = await response.blob() + const url = URL.createObjectURL(blob) + link.href = url + setTimeout(() => URL.revokeObjectURL(url), 60000) + } catch (err) { + console.error('Video fetch failed, falling back to direct link', err) + link.href = `file://${result.videoPath}` + } + } + + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } else { + alert('下载失败: 无法获取资源') + } + } catch (e) { + console.error('Download error:', e) + alert('下载出错') + } finally { + setLoading(false) + } + } + + return ( +
+ {(thumbSrc && !thumbSrc.startsWith('data:') && (thumbSrc.toLowerCase().endsWith('.mp4') || thumbSrc.includes('video'))) ? ( +
+ ) +} + +export const SnsMediaGrid: React.FC = ({ mediaList, onPreview }) => { + if (!mediaList || mediaList.length === 0) return null + + const count = mediaList.length + let gridClass = '' + + if (count === 1) gridClass = 'grid-1' + else if (count === 2) gridClass = 'grid-2' + else if (count === 3) gridClass = 'grid-3' + else if (count === 4) gridClass = 'grid-4' // 2x2 + else if (count <= 6) gridClass = 'grid-6' // 3 cols + else gridClass = 'grid-9' // 3x3 + + return ( +
+ {mediaList.map((media, idx) => ( + + ))} +
+ ) +} diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx new file mode 100644 index 0000000..50ac5cd --- /dev/null +++ b/src/components/Sns/SnsPostItem.tsx @@ -0,0 +1,263 @@ +import React, { useState, useMemo } from 'react' +import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal } from 'lucide-react' +import { SnsPost, SnsLinkCardData } from '../../types/sns' +import { Avatar } from '../Avatar' +import { SnsMediaGrid } from './SnsMediaGrid' + +// Helper functions (extracted from SnsPage.tsx but simplified/reused) +const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl'] +const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle'] +const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload'] + +const isSnsVideoUrl = (url?: string): boolean => { + if (!url) return false + const lower = url.toLowerCase() + return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') +} + +const decodeHtmlEntities = (text: string): string => { + if (!text) return '' + return text + .replace(//g, '$1') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .trim() +} + +const normalizeUrlCandidate = (raw: string): string | null => { + const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim() + if (!value) return null + if (!/^https?:\/\//i.test(value)) return null + return value +} + +const simplifyUrlForCompare = (value: string): string => { + const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '') + const [withoutQuery] = normalized.split('?') + return withoutQuery.replace(/\/+$/, '') +} + +const getXmlTagValues = (xml: string, tags: string[]): string[] => { + if (!xml) return [] + const results: string[] = [] + for (const tag of tags) { + const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig') + let match: RegExpExecArray | null + while ((match = reg.exec(xml)) !== null) { + if (match[1]) results.push(match[1]) + } + } + return results +} + +const getUrlLikeStrings = (text: string): string[] => { + if (!text) return [] + return text.match(/https?:\/\/[^\s<>"']+/gi) || [] +} + +const isLikelyMediaAssetUrl = (url: string): boolean => { + const lower = url.toLowerCase() + return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint)) +} + +const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => { + const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) + if (hasVideoMedia) return null + + const mediaValues = post.media + .flatMap((item) => [item.url, item.thumb]) + .filter((value): value is string => Boolean(value)) + const mediaSet = new Set(mediaValues.map((value) => simplifyUrlForCompare(value))) + + const urlCandidates: string[] = [ + post.linkUrl || '', + ...getXmlTagValues(post.rawXml || '', LINK_XML_URL_TAGS), + ...getUrlLikeStrings(post.rawXml || ''), + ...getUrlLikeStrings(post.contentDesc || '') + ] + + const normalizedCandidates = urlCandidates + .map(normalizeUrlCandidate) + .filter((value): value is string => Boolean(value)) + + const dedupedCandidates: string[] = [] + const seen = new Set() + for (const candidate of normalizedCandidates) { + if (seen.has(candidate)) continue + seen.add(candidate) + dedupedCandidates.push(candidate) + } + + const linkUrl = dedupedCandidates.find((candidate) => { + const simplified = simplifyUrlForCompare(candidate) + if (mediaSet.has(simplified)) return false + if (isLikelyMediaAssetUrl(candidate)) return false + return true + }) + + if (!linkUrl) return null + + const titleCandidates = [ + post.linkTitle || '', + ...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS), + post.contentDesc || '' + ] + + const title = titleCandidates + .map((value) => decodeHtmlEntities(value)) + .find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) + + return { + url: linkUrl, + title: title || '网页链接', + thumb: post.media[0]?.thumb || post.media[0]?.url + } +} + +const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { + const [thumbFailed, setThumbFailed] = useState(false) + const hostname = useMemo(() => { + try { + return new URL(card.url).hostname.replace(/^www\./i, '') + } catch { + return card.url + } + }, [card.url]) + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation() + try { + await window.electronAPI.shell.openExternal(card.url) + } catch (error) { + console.error('[SnsLinkCard] openExternal failed:', error) + } + } + + return ( + + ) +} + +interface SnsPostItemProps { + post: SnsPost + onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void + onDebug: (post: SnsPost) => void +} + +export const SnsPostItem: React.FC = ({ post, onPreview, onDebug }) => { + const linkCard = buildLinkCardData(post) + const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) + const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia + const showMediaGrid = post.media.length > 0 && !showLinkCard + + 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' + }) + } + + // Add extra class for media-only posts (no text) to adjust spacing? + // Not strictly needed but good to know + + return ( +
+
+ +
+ +
+
+
+ {decodeHtmlEntities(post.nickname)} + {formatTime(post.createTime)} +
+ +
+ + {post.contentDesc && ( +
{decodeHtmlEntities(post.contentDesc)}
+ )} + + {showLinkCard && linkCard && ( + + )} + + {showMediaGrid && ( +
+ +
+ )} + + {(post.likes.length > 0 || post.comments.length > 0) && ( +
+ {post.likes.length > 0 && ( +
+ + {post.likes.join('、')} +
+ )} + + {post.comments.length > 0 && ( +
+ {post.comments.map((c, idx) => ( +
+ {c.nickname} + {c.refNickname && ( + <> + 回复 + {c.refNickname} + + )} + + {c.content} +
+ ))} +
+ )} +
+ )} +
+
+ ) +} diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 31cd990..a18fba0 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1,1202 +1,820 @@ -.sns-page { - height: 100%; - background: var(--bg-primary); - color: var(--text-primary); - overflow: hidden; +/* Global Variables */ +:root { + --sns-max-width: 800px; + --sns-panel-width: 320px; + --sns-bg-color: var(--bg-primary); + --sns-card-bg: var(--bg-secondary); + --sns-border-radius-lg: 16px; + --sns-border-radius-md: 12px; + --sns-border-radius-sm: 8px; +} - .sns-container { - display: flex; - height: 100%; +.sns-page-layout { + display: flex; + height: 100%; + overflow: hidden; + background: var(--sns-bg-color); + position: relative; + color: var(--text-primary); +} + +/* ========================================= + Main Viewport & Feed + ========================================= */ +.sns-main-viewport { + flex: 1; + overflow-y: scroll; + /* Always show scrollbar track for stability */ + scroll-behavior: smooth; + position: relative; + display: flex; + justify-content: center; +} + +.sns-feed-container { + width: 100%; + max-width: var(--sns-max-width); + padding: 20px 24px 60px 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.feed-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + padding: 0 4px; + + h2 { + font-size: 20px; + font-weight: 700; + margin: 0; + color: var(--text-primary); } - .sns-sidebar { - width: 320px; - background: var(--bg-secondary); - border-left: 1px solid var(--border-color); + .icon-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--sns-border-radius-sm); + padding: 8px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--hover-bg); + color: var(--primary); + transform: scale(1.05); + } + + &.spinning { + animation: spin 1s linear infinite; + } + } +} + +.posts-list { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* ========================================= + Post Item Component + ========================================= */ +.sns-post-item { + background: var(--sns-card-bg); + border-radius: var(--sns-border-radius-lg); + border: 1px solid var(--border-color); + padding: 20px; + display: flex; + gap: 16px; + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06); + } +} + +.post-avatar-col { + flex-shrink: 0; +} + +.post-content-col { + flex: 1; + min-width: 0; + /* Enable ellipsis */ +} + +.post-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; + + .post-author-info { display: flex; flex-direction: column; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - flex-shrink: 0; - z-index: 10; - box-shadow: -4px 0 16px rgba(0, 0, 0, 0.05); - &.closed { - width: 0; - opacity: 0; - transform: translateX(100%); - pointer-events: none; - border-left: none; + .author-name { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + /* Changed to primary from accent for cleaner look, or keep accent */ + color: var(--primary); + margin-bottom: 2px; } - .sidebar-header { - padding: 0 24px; - height: 64px; - box-sizing: border-box; + .post-time { + font-size: 12px; + color: var(--text-tertiary); + } + } + + .debug-btn { + opacity: 0; + transition: opacity 0.2s; + background: none; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px; + color: var(--text-tertiary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + border-color: var(--text-secondary); + } + } +} + +.sns-post-item:hover .debug-btn { + opacity: 1; +} + +.post-text { + font-size: 15px; + line-height: 1.6; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + margin-bottom: 12px; +} + +.post-media-container { + margin-bottom: 12px; +} + +.post-link-card { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--sns-border-radius-md); + padding: 10px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + text-align: left; + transition: all 0.2s; + margin-bottom: 12px; + + &:hover { + background: rgba(var(--primary-rgb), 0.08); + border-color: rgba(var(--primary-rgb), 0.3); + } + + .link-thumb { + width: 60px; + height: 60px; + border-radius: var(--sns-border-radius-sm); + overflow: hidden; + flex-shrink: 0; + background: var(--bg-secondary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .link-thumb-fallback { display: flex; align-items: center; - /* justify-content: space-between; -- No longer needed as it's just h3 */ - border-bottom: 1px solid var(--border-color); - background: var(--bg-secondary); + justify-content: center; + height: 100%; + color: var(--text-tertiary); + } + } - h3 { - margin: 0; - font-size: 18px; - font-weight: 700; - color: var(--text-primary); - letter-spacing: 0; - } + .link-meta { + flex: 1; + min-width: 0; + + .link-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } - .filter-content { - flex: 1; - overflow-y: hidden; - /* Changed from auto to hidden to allow inner scrolling of contact list */ - padding: 16px; + .link-url { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 4px; + } + } + + .link-arrow { + color: var(--text-tertiary); + } +} + +.post-interactions { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed var(--border-color); + font-size: 13px; + + .likes-block { + display: flex; + gap: 8px; + margin-bottom: 8px; + color: var(--primary); + font-weight: 500; + + .like-icon { + margin-top: 2px; + } + } + + .comments-block { + background: var(--bg-tertiary); + border-radius: var(--sns-border-radius-sm); + padding: 8px 12px; + + .comment-row { + margin-bottom: 4px; + line-height: 1.4; + color: var(--text-secondary); + + &:last-child { + margin-bottom: 0; + } + + .comment-user { + color: var(--primary); + font-weight: 500; + } + + .reply-text { + margin: 0 4px; + color: var(--text-tertiary); + } + + .comment-colon { + margin-right: 4px; + } + } + } +} + + +/* ========================================= + Media Grid Component + ========================================= */ +.sns-media-grid { + display: grid; + gap: 6px; + + &.grid-1 .sns-media-item { + width: fit-content; + height: auto; + max-width: 300px; + /* Max width constraint */ + max-height: 480px; + /* Increased max height a bit */ + aspect-ratio: auto; + border-radius: var(--sns-border-radius-md); + + img, + video { + max-width: 100%; + max-height: 480px; + width: auto; + height: auto; + object-fit: contain; + display: block; + /* Remove baseline space */ + background: rgba(0, 0, 0, 0.05); + } + } + + &.grid-2, + &.grid-4 { + grid-template-columns: repeat(2, 1fr); + max-width: 320px; + } + + &.grid-3, + &.grid-6, + &.grid-9 { + grid-template-columns: repeat(3, 1fr); + max-width: 320px; + } + + .sns-media-item { + position: relative; + aspect-ratio: 1; + border-radius: var(--sns-border-radius-md); + /* Consistent radius for grid items */ + overflow: hidden; + cursor: zoom-in; + background: var(--bg-tertiary); + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + + img, + video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + animation: fade-in 0.3s ease; + } + + .media-badge { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 32px; + height: 32px; + background: rgba(0, 0, 0, 0.4); + border-radius: 50%; + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + color: white; + pointer-events: none; + /* Let clicks pass through badge to item */ + + &.live { + top: 8px; + left: 8px; + transform: none; + width: 24px; + height: 24px; + } + + } + + .media-decrypting-mask { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); display: flex; flex-direction: column; - gap: 16px; + align-items: center; + justify-content: center; + color: white; + gap: 8px; + z-index: 10; + font-size: 12px; + backdrop-filter: blur(2px); + } - .filter-card { - background: var(--bg-primary); - border-radius: 12px; - border: 1px solid var(--border-color); - padding: 14px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); - transition: transform 0.2s, box-shadow 0.2s; - flex-shrink: 0; + .media-download-btn { + position: absolute; + bottom: 6px; + right: 6px; + width: 28px; + height: 28px; + background: rgba(0, 0, 0, 0.5); + border-radius: 50%; + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + color: white; + cursor: pointer; + opacity: 0; + transition: all 0.2s; + z-index: 5; - &:hover { - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04); - } - - &.jump-date-card { - .jump-date-btn { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 10px 14px; - color: var(--text-secondary); - font-size: 13px; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - overflow: hidden; - - &.active { - border-color: var(--accent-color); - color: var(--text-primary); - font-weight: 500; - background: rgba(var(--accent-color-rgb), 0.05); - - .icon { - color: var(--accent-color); - opacity: 1; - } - } - - &:hover { - border-color: var(--accent-color); - background: var(--bg-primary); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.08); - } - - &:active { - transform: translateY(0); - } - - .text { - flex: 1; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .icon { - opacity: 0.5; - transition: all 0.2s; - margin-left: 8px; - } - } - - .clear-jump-date-inline { - width: 100%; - margin-top: 10px; - background: rgba(var(--accent-color-rgb), 0.06); - border: 1px dashed rgba(var(--accent-color-rgb), 0.3); - color: var(--accent-color); - font-size: 12px; - cursor: pointer; - text-align: center; - padding: 6px; - border-radius: 8px; - transition: all 0.2s; - font-weight: 500; - - &:hover { - background: var(--accent-color); - color: white; - border-style: solid; - } - } - } - - &.contact-card { - flex: 1; - display: flex; - flex-direction: column; - min-height: 200px; - padding: 0; - overflow: hidden; - } + &:hover { + background: var(--primary); + transform: scale(1.1); } - - - .filter-section { - margin-bottom: 0px; - - label { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - color: var(--text-secondary); - margin-bottom: 10px; - font-weight: 600; - - svg { - color: var(--accent-color); - opacity: 0.8; - } - } - - .search-input-wrapper { - position: relative; - display: flex; - align-items: center; - - .input-icon { - position: absolute; - left: 12px; - color: var(--text-tertiary); - pointer-events: none; - } - - input { - width: 100%; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 10px 10px 10px 36px; - color: var(--text-primary); - font-size: 13px; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - &::placeholder { - color: var(--text-tertiary); - opacity: 0.6; - } - - &:focus { - outline: none; - border-color: var(--accent-color); - background: var(--bg-primary); - box-shadow: 0 0 0 4px rgba(var(--accent-color-rgb), 0.1); - } - } - - .clear-input { - position: absolute; - right: 8px; - background: none; - border: none; - color: var(--text-tertiary); - cursor: pointer; - padding: 4px; - display: flex; - border-radius: 50%; - transition: all 0.2s; - - &:hover { - color: var(--text-secondary); - background: var(--hover-bg); - transform: rotate(90deg); - } - } - } - } - - .contact-filter-section { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - - .section-header { - padding: 16px 16px 1px 16px; - margin-bottom: 12px; - /* Increased spacing */ - display: flex; - justify-content: space-between; - align-items: center; - flex-shrink: 0; - - .header-actions { - display: flex; - align-items: center; - gap: 8px; - - .clear-selection-btn { - background: none; - border: none; - color: var(--text-tertiary); - font-size: 11px; - cursor: pointer; - padding: 2px 6px; - border-radius: 4px; - transition: all 0.2s; - - &:hover { - color: var(--accent-color); - background: rgba(var(--accent-color-rgb), 0.1); - } - } - - .selected-count { - font-size: 10px; - background: var(--accent-color); - color: white; - min-width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - font-weight: bold; - } - } - } - - .contact-search { - padding: 0 16px 12px 16px; - position: relative; - display: flex; - align-items: center; - flex-shrink: 0; - - .search-icon { - position: absolute; - left: 26px; - color: var(--text-tertiary); - pointer-events: none; - z-index: 1; - opacity: 0.6; - } - - input { - width: 100%; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 8px 30px 8px 30px; - font-size: 12px; - color: var(--text-primary); - transition: all 0.2s; - - &:focus { - outline: none; - border-color: var(--accent-color); - background: var(--bg-primary); - } - } - - .clear-search-icon { - position: absolute; - right: 24px; - color: var(--text-tertiary); - cursor: pointer; - padding: 4px; - border-radius: 50%; - transition: all 0.2s; - - &:hover { - color: var(--text-secondary); - background: var(--hover-bg); - } - } - } - - .contact-list { - flex: 1; - overflow-y: auto; - padding: 4px 8px; - margin: 0 4px 8px 4px; - min-height: 0; - - .contact-item { - display: flex; - align-items: center; - padding: 8px 12px; - border-radius: 10px; - cursor: pointer; - gap: 12px; - margin-bottom: 2px; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - - &:hover { - background: var(--hover-bg); - transform: translateX(2px); - } - - &.active { - background: rgba(var(--accent-color-rgb), 0.08); - - .contact-name { - color: var(--accent-color); - font-weight: 600; - } - - .check-box { - border-color: var(--accent-color); - background: var(--accent-color); - - .inner-check { - transform: scale(1); - } - } - } - - .avatar-wrapper { - position: relative; - display: flex; - - .active-badge { - position: absolute; - bottom: -1px; - right: -1px; - width: 10px; - height: 10px; - background: var(--accent-color); - border: 2px solid var(--bg-secondary); - border-radius: 50%; - animation: badge-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); - } - } - - .contact-name { - flex: 1; - font-size: 13px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--text-secondary); - transition: color 0.2s; - } - - .check-box { - width: 16px; - height: 16px; - border: 2px solid var(--border-color); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - - .inner-check { - width: 8px; - height: 8px; - border-radius: 1px; - background: white; - transform: scale(0); - transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); - } - } - } - - .empty-contacts { - padding: 32px 16px; - text-align: center; - font-size: 13px; - color: var(--text-tertiary); - font-style: italic; - } - } + /* Increase click area */ + &::after { + content: ''; + position: absolute; + inset: -4px; } } - .sidebar-footer { - padding: 16px; - border-top: 1px solid var(--border-color); + &:hover .media-download-btn { + opacity: 1; + } + } +} - .clear-btn { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 10px; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - color: var(--text-secondary); - border-radius: 8px; - cursor: pointer; - font-size: 13px; - font-weight: 500; - transition: all 0.2s; +@keyframes fade-in { + from { + opacity: 0; + } - &:hover { - background: var(--accent-color); - color: white; - border-color: var(--accent-color); - box-shadow: 0 4px 10px rgba(var(--accent-color-rgb), 0.2); - } + to { + opacity: 1; + } +} - &:active { - transform: scale(0.98); - } + +/* ========================================= + Filter Panel Component (Right Side) + ========================================= */ +.sns-filter-panel { + width: var(--sns-panel-width); + flex-shrink: 0; + border-left: 1px solid var(--border-color); + background: var(--bg-secondary); + display: flex; + flex-direction: column; + padding: 24px; + gap: 24px; + z-index: 10; + + .filter-header { + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + font-size: 16px; + font-weight: 700; + margin: 0; + } + + .reset-all-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary); + + &:hover { + color: var(--primary); + animation: spin 0.5s ease; } } } - .sns-main { - flex: 1; + .filter-widgets { display: flex; flex-direction: column; - min-width: 0; - background: var(--bg-primary); + gap: 20px; + flex: 1; + min-height: 0; + } - .sns-header { + .filter-widget { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--sns-border-radius-md); + padding: 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); + + .widget-header { display: flex; align-items: center; - justify-content: space-between; - padding: 0 24px; - height: 64px; + gap: 8px; + margin-bottom: 12px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + + svg { + color: var(--primary); + opacity: 0.8; + } + + .badge { + margin-left: auto; + background: var(--primary); + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + } + } + } + + /* Search Widget */ + .input-group { + position: relative; + display: flex; + align-items: center; + + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid transparent; + border-radius: var(--sns-border-radius-sm); + padding: 10px 30px 10px 12px; + font-size: 13px; + color: var(--text-primary); + transition: all 0.2s; + + &:focus { + background: var(--bg-primary); + border-color: transparent; + /* Explicitly transparent */ + outline: none; + /* Ensure no outline */ + box-shadow: none; + } + } + + .clear-input-btn { + position: absolute; + right: 8px; + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--text-tertiary); + display: flex; + } + } + + /* Date Widget */ + .date-picker-trigger { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-tertiary); + border: 1px solid transparent; + border-radius: var(--sns-border-radius-sm); + padding: 12px; + cursor: pointer; + transition: all 0.2s; + font-size: 13px; + color: var(--text-secondary); + + &:hover { + background: var(--bg-primary); + border-color: var(--primary); + } + + &.active { + background: rgba(var(--primary-rgb), 0.08); + border-color: var(--primary); + color: var(--primary); + font-weight: 500; + } + + .clear-date-btn { + padding: 4px; + display: flex; + color: var(--primary); + + &:hover { + transform: scale(1.1); + } + } + } + + /* Contact Widget - Refactored */ + .contact-widget { + display: flex; + flex-direction: column; + flex: 1; + min-height: 300px; + overflow: hidden; + padding: 0; + + .widget-header { + padding: 16px 16px 12px 16px; + margin-bottom: 0; + } + + .contact-search-bar { + padding: 0 16px 12px 16px; + position: relative; border-bottom: 1px solid var(--border-color); - background: var(--bg-secondary); - backdrop-filter: blur(10px); - z-index: 5; - .header-left { - display: flex; - align-items: center; - gap: 16px; + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid transparent; + border-radius: 8px; + padding: 8px 32px 8px 12px; + font-size: 12px; + color: var(--text-primary); - h2 { - margin: 0; - font-size: 18px; - font-weight: 700; - color: var(--text-primary); - } - - .sidebar-trigger { - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 8px; - - &:hover { - background: var(--hover-bg); - color: var(--accent-color); - } + &:focus { + outline: none; + background: var(--bg-primary); + border-color: var(--primary); } } - .header-right { + .search-icon { + position: absolute; + right: 28px; + top: 8px; + color: var(--text-tertiary); + pointer-events: none; + } + } + + .contact-list-scroll { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 0; + /* Remove gap to allow borders to merge */ + + .contact-row { display: flex; align-items: center; gap: 12px; - } - - .icon-btn { - background: none; - border: none; - color: var(--text-secondary); + padding: 10px; + border-radius: var(--sns-border-radius-md); cursor: pointer; - padding: 8px; - border-radius: 8px; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; + transition: background 0.2s ease, transform 0.2s ease; + border: 2px solid transparent; + margin-bottom: 4px; + /* Separation for unselected items */ &:hover { - color: var(--text-primary); background: var(--hover-bg); + transform: translateX(2px); + z-index: 10; } - &.refresh-btn { - &:hover { - color: var(--accent-color); - } - } - } + &.selected { + background: rgba(var(--primary-rgb), 0.1); + border-color: var(--primary); + box-shadow: none; + z-index: 5; + margin-bottom: 0; + /* Remove margin to merge */ - .spinning { - animation: spin 1s linear infinite; - } - } - - - .sns-content-wrapper { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - position: relative; - } - - .sns-notice-banner { - margin: 16px 24px 0 24px; - padding: 10px 16px; - background: rgba(var(--accent-color-rgb), 0.08); - border-radius: 10px; - border: 1px solid rgba(var(--accent-color-rgb), 0.2); - display: flex; - align-items: center; - gap: 10px; - color: var(--accent-color); - font-size: 13px; - font-weight: 500; - animation: banner-slide-down 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); - - svg { - flex-shrink: 0; - } - } - - @keyframes banner-slide-down { - from { - opacity: 0; - transform: translateY(-10px); - } - - to { - opacity: 1; - transform: translateY(0); - } - } - - .sns-content { - flex: 1; - overflow-y: auto; - padding: 24px 0; - scroll-behavior: smooth; - - .active-filters-bar { - max-width: 680px; - margin: 0 auto 24px auto; - display: flex; - align-items: center; - justify-content: space-between; - background: rgba(var(--accent-color-rgb), 0.08); - border: 1px solid rgba(var(--accent-color-rgb), 0.2); - padding: 10px 16px; - border-radius: 10px; - font-size: 13px; - color: var(--accent-color); - - .filter-info { - display: flex; - align-items: center; - gap: 8px; - font-weight: 500; - } - - .clear-chip-btn { - background: var(--accent-color); - border: none; - color: white; - cursor: pointer; - font-size: 11px; - padding: 4px 10px; - border-radius: 4px; - font-weight: 600; - - &:hover { - background: var(--accent-color-hover); - } - } - } - - .posts-list { - display: flex; - flex-direction: column; - gap: 32px; - } - - .sns-post-row { - display: flex; - width: 100%; - max-width: 800px; - position: relative; - } - - } - - .sns-post-wrapper { - width: 100%; - padding: 0 20px; - } - - .sns-post { - background: var(--bg-secondary); - border-radius: 16px; - padding: 24px; - border: 1px solid var(--border-color); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03); - transition: transform 0.2s; - - &:hover { - transform: translateY(-2px); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06); - } - - .post-header { - display: flex; - align-items: center; - margin-bottom: 18px; - - .post-info { - margin-left: 14px; - - .nickname { - font-size: 15px; - font-weight: 700; - margin-bottom: 4px; - color: var(--accent-color); + .contact-name { + color: var(--primary); + font-weight: 600; } - .time { - font-size: 12px; - color: var(--text-tertiary); - display: flex; - align-items: center; - gap: 4px; - - &::before { - content: ''; - width: 4px; - height: 4px; - border-radius: 50%; - background: currentColor; - opacity: 0.5; - } - } - } - } - - .post-body { - margin-bottom: 20px; - - .post-text { - margin-bottom: 14px; - white-space: pre-wrap; - line-height: 1.7; - font-size: 15px; - color: var(--text-primary); - word-break: break-word; - } - - .post-link-card { - width: min(460px, 100%); - display: flex; - align-items: center; - gap: 12px; - padding: 10px; - margin-bottom: 14px; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 12px; - cursor: pointer; - text-align: left; - transition: all 0.2s ease; - - &:hover { - border-color: rgba(var(--accent-color-rgb), 0.35); - background: rgba(var(--accent-color-rgb), 0.08); - transform: translateY(-1px); - } - - .link-thumb { - width: 88px; - min-width: 88px; - height: 66px; - border-radius: 8px; - overflow: hidden; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - - img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } - - .link-thumb-fallback { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-tertiary); - } - } - - .link-meta { - flex: 1; - min-width: 0; - - .link-title { - font-size: 14px; - line-height: 1.4; - font-weight: 600; - color: var(--text-primary); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; - } - - .link-url { - margin-top: 6px; - font-size: 12px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .link-arrow { - color: var(--text-tertiary); - flex-shrink: 0; - } - } - - .post-media-grid { - display: grid; - gap: 6px; - width: fit-content; - max-width: 100%; - - &.media-count-1 { - grid-template-columns: 1fr; - - .media-item { - width: 320px; - height: 240px; - max-width: 100%; - border-radius: 12px; - aspect-ratio: auto; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - - &.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 { - width: 160px; // 多图模式下项固定大小(或由 grid 控制,但确保有高度) - height: 160px; - aspect-ratio: 1; - background: var(--bg-tertiary); - border-radius: 6px; - overflow: hidden; - border: 1px solid var(--border-color); - position: relative; - - img { - width: 100%; - height: 100%; - object-fit: cover; - cursor: zoom-in; - } - - .live-badge { - position: absolute; - top: 8px; - left: 8px; - right: 8px; - left: auto; - background: rgba(255, 255, 255, 0.9); - background: rgba(0, 0, 0, 0.3); - backdrop-filter: blur(4px); - color: white; - padding: 4px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; - z-index: 2; - transition: opacity 0.2s; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - } - - .download-btn-overlay { - position: absolute; - bottom: 6px; - right: 6px; - width: 28px; - height: 28px; - border-radius: 50%; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.3); - color: white; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - opacity: 0; - transform: translateY(10px); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 2; - - &:hover { - background: rgba(0, 0, 0, 0.7); - transform: scale(1.1); - border-color: rgba(255, 255, 255, 0.8); - } - } - - - - .video-badge-container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 2; - pointer-events: none; - display: flex; - align-items: center; - justify-content: center; - - .video-badge { - width: 44px; - height: 44px; - background: rgba(0, 0, 0, 0.3); - backdrop-filter: blur(4px); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: white; - border: 1px solid rgba(255, 255, 255, 0.3); - transition: all 0.2s; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); - - svg { - fill: white; - opacity: 0.9; - } - } - - .decrypting-badge { - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(8px); - padding: 8px 16px; - border-radius: 20px; - display: flex; - align-items: center; - gap: 8px; - color: white; - font-size: 13px; - font-weight: 500; - border: 1px solid rgba(255, 255, 255, 0.2); - white-space: nowrap; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); - - .spin-icon { - animation: spin 1s linear infinite; - } - } - } - - &:hover { - .download-btn-overlay { - opacity: 1; - transform: translateY(0); - } - } - - .media-error-placeholder { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-deep); - color: var(--text-tertiary); - cursor: default; - } - } - } - - .post-video-placeholder { - display: inline-flex; - align-items: center; - gap: 10px; - background: rgba(var(--accent-color-rgb), 0.08); - color: var(--accent-color); - padding: 10px 18px; - border-radius: 12px; - font-size: 14px; - font-weight: 600; - border: 1px solid rgba(var(--accent-color-rgb), 0.1); - cursor: pointer; - - &:hover { - background: rgba(var(--accent-color-rgb), 0.12); - } - } - } - - .post-footer { - background: var(--bg-tertiary); - border-radius: 10px; - padding: 14px; - position: relative; - - &::after { - content: ''; - position: absolute; - top: -8px; - left: 20px; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid var(--bg-tertiary); - } - - .likes-section { - display: flex; - align-items: flex-start; - color: var(--accent-color); - padding-bottom: 10px; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - margin-bottom: 10px; - font-size: 13px; - - &:last-child { - padding-bottom: 0; + /* If the NEXT item is also selected */ + &:has(+ .contact-row.selected) { border-bottom: none; - margin-bottom: 0; - } - - .icon { - margin-top: 3px; - margin-right: 10px; - flex-shrink: 0; - opacity: 0.8; - } - - .likes-list { - line-height: 1.6; - font-weight: 500; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + padding-bottom: 12px; + /* Compensate for missing border (+2px) */ } } - .comments-section { - .comment-item { - margin-bottom: 8px; - line-height: 1.6; - font-size: 13px; + /* If the PREVIOUS item is selected */ + &.selected+.contact-row.selected { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + margin-top: 0; + padding-top: 12px; + /* Compensate for missing border */ + } - &:last-child { - margin-bottom: 0; - } - - .comment-user { - color: var(--accent-color); - font-weight: 700; - cursor: pointer; - - &:hover { - text-decoration: underline; - } - } - - .reply-text { - color: var(--text-tertiary); - margin: 0 6px; - font-size: 12px; - } - - .comment-content { - color: var(--text-secondary); - } - } + .contact-name { + flex: 1; + font-size: 14px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } } - .status-indicator { + .empty-state { text-align: center; - padding: 40px; color: var(--text-tertiary); - font-size: 14px; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - - &.loading-more, - &.loading-newer { - color: var(--accent-color); - } - - &.newer-hint { - background: rgba(var(--accent-color-rgb), 0.08); - padding: 12px; - border-radius: 12px; - cursor: pointer; - border: 1px dashed rgba(var(--accent-color-rgb), 0.2); - transition: all 0.2s; - margin-bottom: 16px; - - &:hover { - background: rgba(var(--accent-color-rgb), 0.15); - border-style: solid; - transform: translateY(-2px); - } - } + padding: 20px; + font-size: 12px; } + } +} - .no-results { - text-align: center; - padding: 80px 20px; - color: var(--text-tertiary); +/* ========================================= + Status Indicators, etc. + ========================================= */ +.status-indicator { + text-align: center; + padding: 16px; + color: var(--text-tertiary); + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; - .no-results-icon { - margin-bottom: 20px; - opacity: 0.2; - } + &.newer-hint { + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + cursor: pointer; + border-radius: var(--sns-border-radius-sm); + margin: 0 24px; + } +} - p { - font-size: 16px; - margin-bottom: 24px; - } +.no-results { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 0; + color: var(--text-tertiary); - .reset-inline { - background: var(--accent-color); - color: white; - border: none; - padding: 10px 24px; - border-radius: 10px; - cursor: pointer; - font-size: 14px; - font-weight: 600; - box-shadow: 0 4px 15px rgba(var(--accent-color-rgb), 0.3); - transition: all 0.2s; + .no-results-icon { + margin-bottom: 16px; + opacity: 0.5; + } - &:hover { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(var(--accent-color-rgb), 0.4); - } - } + .reset-inline { + margin-top: 16px; + background: none; + border: 1px solid var(--border-color); + padding: 6px 16px; + border-radius: 20px; + cursor: pointer; + color: var(--text-secondary); + + &:hover { + border-color: var(--primary); + color: var(--primary); } } } @keyframes spin { - from { - transform: rotate(0deg); - } - - to { + 100% { transform: rotate(360deg); } } -@keyframes badge-pop { - from { - transform: scale(0); - opacity: 0; - } - - to { - transform: scale(1); - opacity: 1; - } -} - -// Debug Dialog Styles -.debug-btn { - margin-left: auto; - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-secondary); - padding: 6px; - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - - &:hover { - background: var(--hover-bg); - color: var(--accent-color); - border-color: var(--accent-color); - } -} - +/* ========================================= + Modal & Debug Dialog + ========================================= */ .modal-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; - z-index: 10000; - backdrop-filter: blur(4px); + animation: fade-in 0.2s ease; } .debug-dialog { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 12px; - width: 90%; - max-width: 800px; - max-height: 85vh; + background: var(--sns-card-bg); + /* Use card bg */ + border-radius: var(--sns-border-radius-lg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); + width: 600px; + max-width: 90vw; + height: 80vh; display: flex; flex-direction: column; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-color); + overflow: hidden; + /* Ensure header doesn't overflow corners */ + animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); .debug-dialog-header { padding: 16px 20px; border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + /* Distinct header bg */ display: flex; align-items: center; justify-content: space-between; @@ -1204,24 +822,22 @@ h3 { margin: 0; font-size: 16px; - font-weight: 600; + font-weight: 700; color: var(--text-primary); } .close-btn { - background: transparent; + background: none; border: none; - color: var(--text-secondary); + color: var(--text-tertiary); cursor: pointer; - padding: 4px; + padding: 6px; + border-radius: 6px; display: flex; - align-items: center; - border-radius: 4px; - transition: all 0.2s; &:hover { - background: var(--hover-bg); - color: var(--accent-color); + background: rgba(0, 0, 0, 0.05); + color: var(--text-primary); } } } @@ -1230,123 +846,30 @@ flex: 1; overflow-y: auto; padding: 20px; + background: var(--bg-primary); - .debug-section { - margin-bottom: 24px; - padding-bottom: 20px; - border-bottom: 1px solid var(--border-color); - - &:last-child { - border-bottom: none; - } - - h4 { - margin: 0 0 12px 0; - font-size: 14px; - font-weight: 600; - color: var(--accent-color); - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .debug-item { - display: flex; - gap: 12px; - padding: 8px 0; - align-items: flex-start; - - .debug-key { - font-weight: 500; - color: var(--text-secondary); - min-width: 140px; - font-size: 13px; - font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace; - } - - .debug-value { - flex: 1; - color: var(--text-primary); - font-size: 13px; - word-break: break-all; - font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace; - user-select: text; - cursor: text; - padding: 2px 0; - } - } - - .media-debug-item { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 12px; - margin-bottom: 12px; - - .media-debug-header { - font-weight: 600; - color: var(--text-primary); - margin-bottom: 8px; - padding-bottom: 8px; - border-bottom: 1px solid var(--border-color); - } - - .live-photo-debug { - margin-top: 12px; - padding-top: 12px; - border-top: 1px dashed var(--border-color); - - .live-photo-label { - font-weight: 500; - color: var(--accent-color); - margin-bottom: 8px; - font-size: 13px; - } - } - } - - .json-code { - background: var(--bg-tertiary); - color: var(--text-primary); - padding: 16px; - border-radius: 8px; - border: 1px solid var(--border-color); - overflow-x: auto; - font-family: 'Consolas', 'Monaco', monospace; - font-size: 12px; - line-height: 1.5; - user-select: all; - max-height: 400px; - overflow-y: auto; - } - - .copy-json-btn { - margin-top: 12px; - padding: 8px 16px; - background: var(--accent-color); - color: white; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 13px; - font-weight: 500; - transition: all 0.2s; - - &:hover { - background: var(--accent-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.3); - } - } - } - } - - @keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); + .json-code { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 12px; + line-height: 1.5; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + user-select: text; + /* Allow selection */ } } } + +@keyframes slide-up-fade { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index d10031a..9853ede 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,426 +1,11 @@ -import { useEffect, useState, useRef, useCallback, useMemo } from 'react' -import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight, AlertTriangle } from 'lucide-react' -import { Avatar } from '../components/Avatar' +import { useEffect, useState, useRef, useCallback } from 'react' +import { RefreshCw, Search, X, Download, FolderOpen } from 'lucide-react' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' -import { LivePhotoIcon } from '../components/LivePhotoIcon' import './SnsPage.scss' - -interface SnsPost { - id: string - username: string - nickname: string - avatarUrl?: string - createTime: number - contentDesc: string - type?: number - media: { - url: string - thumb: string - md5?: string - token?: string - key?: string - encIdx?: string - livePhoto?: { - url: string - thumb: string - token?: string - key?: string - encIdx?: string - } - }[] - likes: string[] - comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] - rawXml?: string // 原始 XML 数据 - linkTitle?: string - linkUrl?: string -} - -interface SnsLinkCardData { - title: string - url: string - thumb?: string -} - -const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl'] -const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle'] -const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload'] - -const isSnsVideoUrl = (url?: string): boolean => { - if (!url) return false - const lower = url.toLowerCase() - return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') -} - -const decodeHtmlEntities = (text: string): string => { - if (!text) return '' - return text - .replace(//g, '$1') - .replace(/&/gi, '&') - .replace(/</gi, '<') - .replace(/>/gi, '>') - .replace(/"/gi, '"') - .replace(/'/gi, "'") - .trim() -} - -const normalizeUrlCandidate = (raw: string): string | null => { - const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim() - if (!value) return null - if (!/^https?:\/\//i.test(value)) return null - return value -} - -const simplifyUrlForCompare = (value: string): string => { - const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '') - const [withoutQuery] = normalized.split('?') - return withoutQuery.replace(/\/+$/, '') -} - -const getXmlTagValues = (xml: string, tags: string[]): string[] => { - if (!xml) return [] - const results: string[] = [] - for (const tag of tags) { - const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig') - let match: RegExpExecArray | null - while ((match = reg.exec(xml)) !== null) { - if (match[1]) results.push(match[1]) - } - } - return results -} - -const getUrlLikeStrings = (text: string): string[] => { - if (!text) return [] - return text.match(/https?:\/\/[^\s<>"']+/gi) || [] -} - -const isLikelyMediaAssetUrl = (url: string): boolean => { - const lower = url.toLowerCase() - return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint)) -} - -const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => { - const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) - if (hasVideoMedia) return null - - const mediaValues = post.media - .flatMap((item) => [item.url, item.thumb]) - .filter((value): value is string => Boolean(value)) - const mediaSet = new Set(mediaValues.map((value) => simplifyUrlForCompare(value))) - - const urlCandidates: string[] = [ - post.linkUrl || '', - ...getXmlTagValues(post.rawXml || '', LINK_XML_URL_TAGS), - ...getUrlLikeStrings(post.rawXml || ''), - ...getUrlLikeStrings(post.contentDesc || '') - ] - - const normalizedCandidates = urlCandidates - .map(normalizeUrlCandidate) - .filter((value): value is string => Boolean(value)) - - const dedupedCandidates: string[] = [] - const seen = new Set() - for (const candidate of normalizedCandidates) { - if (seen.has(candidate)) continue - seen.add(candidate) - dedupedCandidates.push(candidate) - } - - const linkUrl = dedupedCandidates.find((candidate) => { - const simplified = simplifyUrlForCompare(candidate) - if (mediaSet.has(simplified)) return false - if (isLikelyMediaAssetUrl(candidate)) return false - return true - }) - - if (!linkUrl) return null - - const titleCandidates = [ - post.linkTitle || '', - ...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS), - post.contentDesc || '' - ] - - const title = titleCandidates - .map((value) => decodeHtmlEntities(value)) - .find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) - - return { - url: linkUrl, - title: title || '网页链接', - thumb: post.media[0]?.thumb || post.media[0]?.url - } -} - -const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { - const [thumbFailed, setThumbFailed] = useState(false) - const hostname = useMemo(() => { - try { - return new URL(card.url).hostname.replace(/^www\./i, '') - } catch { - return card.url - } - }, [card.url]) - - const handleClick = async (e: React.MouseEvent) => { - e.stopPropagation() - try { - await window.electronAPI.shell.openExternal(card.url) - } catch (error) { - console.error('[SnsPage] openExternal failed:', error) - } - } - - return ( - - ) -} - -const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => { - const [error, setError] = useState(false) - const [thumbSrc, setThumbSrc] = useState('') // 缩略图 - const [videoPath, setVideoPath] = useState('') // 视频本地路径 - const [liveVideoPath, setLiveVideoPath] = useState('') // Live Photo 视频路径 - const [isDecrypting, setIsDecrypting] = useState(false) // 解密状态 - const { url, thumb, livePhoto } = media - const isLive = !!livePhoto - const targetUrl = thumb || url // 默认显示缩略图 - - // 判断是否为视频 - const isVideo = isSnsVideoUrl(url) - - useEffect(() => { - let cancelled = false - setError(false) - setThumbSrc('') - setVideoPath('') - setLiveVideoPath('') - setIsDecrypting(false) - - const extractFirstFrame = (videoUrl: string) => { - const video = document.createElement('video') - video.crossOrigin = 'anonymous' - video.style.display = 'none' - video.muted = true - video.src = videoUrl - video.currentTime = 0.1 - - const onLoadedData = () => { - if (cancelled) return cleanup() - try { - const canvas = document.createElement('canvas') - canvas.width = video.videoWidth - canvas.height = video.videoHeight - const ctx = canvas.getContext('2d') - if (ctx) { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height) - const dataUrl = canvas.toDataURL('image/jpeg', 0.8) - if (!cancelled) { - setThumbSrc(dataUrl) - setIsDecrypting(false) - } - } else { - if (!cancelled) setIsDecrypting(false) - } - } catch (e) { - console.warn('Frame extraction error', e) - if (!cancelled) setIsDecrypting(false) - } finally { - cleanup() - } - } - - const onError = () => { - if (!cancelled) { - setIsDecrypting(false) - setThumbSrc(targetUrl) // Fallback - } - cleanup() - } - - const cleanup = () => { - video.removeEventListener('seeked', onLoadedData) - video.removeEventListener('error', onError) - video.remove() - } - - video.addEventListener('seeked', onLoadedData) - video.addEventListener('error', onError) - video.load() - } - - const run = async () => { - try { - if (isVideo) { - setIsDecrypting(true) - - const videoResult = await window.electronAPI.sns.proxyImage({ - url: url, - key: media.key - }) - - if (cancelled) return - - if (videoResult.success && videoResult.videoPath) { - const localUrl = videoResult.videoPath.startsWith('file:') - ? videoResult.videoPath - : `file://${videoResult.videoPath.replace(/\\/g, '/')}` - setVideoPath(localUrl) - extractFirstFrame(localUrl) - } else { - console.warn('[MediaItem] Video decryption failed:', url, videoResult.error) - setIsDecrypting(false) - setError(true) - } - } else { - const result = await window.electronAPI.sns.proxyImage({ - url: targetUrl, - key: media.key - }) - - if (cancelled) return - if (result.success) { - if (result.dataUrl) { - setThumbSrc(result.dataUrl) - } else if (result.videoPath) { - const localUrl = result.videoPath.startsWith('file:') - ? result.videoPath - : `file://${result.videoPath.replace(/\\/g, '/')}` - setThumbSrc(localUrl) - } - } else { - console.warn('[MediaItem] Image proxy failed:', targetUrl, result.error) - setThumbSrc(targetUrl) - } - - if (isLive && livePhoto && livePhoto.url) { - window.electronAPI.sns.proxyImage({ - url: livePhoto.url, - key: livePhoto.key || media.key - }).then((res: any) => { - if (cancelled) return - if (res.success && res.videoPath) { - const localUrl = res.videoPath.startsWith('file:') - ? res.videoPath - : `file://${res.videoPath.replace(/\\/g, '/')}` - setLiveVideoPath(localUrl) - console.log('[MediaItem] Live video ready:', localUrl) - } else { - console.warn('[MediaItem] Live video failed:', res.error) - } - }).catch((e: any) => console.error('[MediaItem] Live video err:', e)) - } - } - } catch (err) { - if (!cancelled) { - console.error('[MediaItem] run error:', err) - setError(true) - setIsDecrypting(false) - } - } - } - - run() - return () => { cancelled = true } - }, [targetUrl, url, media.key, isVideo, isLive, livePhoto]) - - const handleDownload = async (e: React.MouseEvent) => { - e.stopPropagation() - try { - const result = await window.electronAPI.sns.downloadImage({ - url: url || targetUrl, // Use original url if available - key: media.key - }) - if (!result.success && result.error !== '用户已取消') { - alert(`下载失败: ${result.error}`) - } - } catch (error) { - console.error('Download failed:', error) - alert('下载过程中发生错误') - } - } - - // 点击时:如果是视频,应该传视频地址给 Preview? - // ImagePreview 目前可能只支持图片。需要检查 ImagePreview 是否支持视频。 - // 假设 ImagePreview 暂不支持视频播放,我们可以在这里直接点开播放? - // 或者,传视频 URL 给 onPreview,让父组件决定/ImagePreview 决定。 - // 通常做法:传给 ImagePreview,ImagePreview 识别 mp4 后播放。 - - // 显示用的图片:始终显示缩略图 - const displaySrc = thumbSrc || targetUrl - - // 预览用的地址:如果是视频,优先使用本地路径 - const previewSrc = isVideo ? (videoPath || url) : (thumbSrc || url || targetUrl) - - // 点击处理:解密中禁止点击 - const handleClick = () => { - if (isVideo && isDecrypting) return - onPreview(previewSrc, isVideo, liveVideoPath) - } - - return ( -
- {isVideo && isDecrypting ? ( -
- - 解密中... -
- ) : ( - setError(true)} - /> - )} - - {isVideo && !isDecrypting && ( -
-
- -
-
- )} - - {isLive && !isVideo && ( -
- -
- )} - -
- ) -} +import { SnsPost } from '../types/sns' +import { SnsPostItem } from '../components/Sns/SnsPostItem' +import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' interface Contact { username: string @@ -431,37 +16,36 @@ interface Contact { export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) - const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(true) const loadingRef = useRef(false) - // 筛选与搜索状态 + // Filter states const [searchKeyword, setSearchKeyword] = useState('') const [selectedUsernames, setSelectedUsernames] = useState([]) - const [isSidebarOpen, setIsSidebarOpen] = useState(true) + const [jumpTargetDate, setJumpTargetDate] = useState(undefined) - // 联系人列表状态 + // Contacts state const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) + + // UI states const [showJumpDialog, setShowJumpDialog] = useState(false) - const [jumpTargetDate, setJumpTargetDate] = useState(undefined) const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null) const [debugPost, setDebugPost] = useState(null) const postsContainerRef = useRef(null) - const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) const postsRef = useRef([]) const scrollAdjustmentRef = useRef(0) - // 同步 posts 到 ref 供 loadPosts 使用 + // Sync posts ref useEffect(() => { postsRef.current = posts }, [posts]) - // 处理向上加载动态时的滚动位置保持 + // Maintain scroll position when loading newer posts useEffect(() => { if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) { const container = postsContainerRef.current; @@ -488,6 +72,7 @@ export default function SnsPage() { let endTs: number | undefined = undefined if (reset) { + // If jumping to date, set endTs to end of that day if (jumpTargetDate) { endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399 } @@ -496,7 +81,6 @@ export default function SnsPage() { if (currentPosts.length > 0) { const topTs = currentPosts[0].createTime - const result = await window.electronAPI.sns.getTimeline( limit, 0, @@ -526,6 +110,7 @@ export default function SnsPage() { loadingRef.current = false; return; } else { + // Loading older const currentPosts = postsRef.current if (currentPosts.length > 0) { endTs = currentPosts[currentPosts.length - 1].createTime - 1 @@ -537,7 +122,7 @@ export default function SnsPage() { 0, selectedUsernames, searchKeyword, - startTs, + startTs, // default undefined endTs ) @@ -546,7 +131,7 @@ export default function SnsPage() { setPosts(result.timeline) setHasMore(result.timeline.length >= limit) - // 探测上方是否还有新动态(利用 DLL 过滤,而非底层 SQL) + // Check for newer items above topTs const topTs = result.timeline[0]?.createTime || 0; if (topTs > 0) { const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined); @@ -576,7 +161,7 @@ export default function SnsPage() { } }, [selectedUsernames, searchKeyword, jumpTargetDate]) - // 获取联系人列表 + // Load Contacts const loadContacts = useCallback(async () => { setContactsLoading(true) try { @@ -622,372 +207,130 @@ export default function SnsPage() { } }, []) - // 初始加载 + // Initial Load & Listeners useEffect(() => { - const checkSchema = async () => { - try { - const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)"); - - if (schema.success && schema.rows) { - const columns = schema.rows.map((r: any) => r.name); - - } - } catch (e) { - console.error('[SnsPage] Failed to check schema:', e); - } - }; - checkSchema(); loadContacts() }, [loadContacts]) useEffect(() => { const handleChange = () => { - setPosts([]) - setHasMore(true) - setHasNewer(false) - setSelectedUsernames([]) - setSearchKeyword('') - setJumpTargetDate(undefined) - loadContacts() - loadPosts({ reset: true }) + // wxid changed, reset everything + setPosts([]); setHasMore(true); setHasNewer(false); + setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined); + loadContacts(); + loadPosts({ reset: true }); } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, [loadContacts, loadPosts]) useEffect(() => { - loadPosts({ reset: true }) - }, [selectedUsernames, searchKeyword, jumpTargetDate]) + const timer = setTimeout(() => { + loadPosts({ reset: true }) + }, 500) + return () => clearTimeout(timer) + }, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts]) const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget - - // 加载更旧的动态(触底) if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { loadPosts({ direction: 'older' }) } - - // 加载更新的动态(触顶触发) - // 这里的阈值可以保留,但主要依赖下面的 handleWheel 捕获到顶后的上划 if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) { loadPosts({ direction: 'newer' }) } } - // 处理到顶后的手动上滚意图 const handleWheel = (e: React.WheelEvent) => { const container = postsContainerRef.current if (!container) return - - // deltaY < 0 表示向上滚,scrollTop === 0 表示已经在最顶端 if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) { - loadPosts({ direction: 'newer' }) } } - 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) => { - // 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑 - // 或者保持原样。根据用户反馈“乱跳”,我们在这里选择: - // 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。 - setJumpTargetDate(undefined); - - setSelectedUsernames(prev => { - if (prev.includes(username)) { - return prev.filter(u => u !== username) - } else { - return [...prev, username] - } - }) - } - - const clearFilters = () => { - setSearchKeyword('') - setSelectedUsernames([]) - setJumpTargetDate(undefined) - } - - const filteredContacts = contacts.filter(c => - c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || - c.username.toLowerCase().includes(contactSearch.toLowerCase()) - ) - - - return ( -
-
-
-
-
-

社交动态

-
-
+
+
+
+
+

朋友圈

+
-
-
-
-
- {loadingNewer && ( -
- - 正在检查更新的动态... -
- )} - {!loadingNewer && hasNewer && ( -
loadPosts({ direction: 'newer' })}> - 查看更新的动态 -
- )} - {posts.map((post) => { - const linkCard = buildLinkCardData(post) - const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) - const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia - const showMediaGrid = post.media.length > 0 && !showLinkCard - return ( -
-
-
-
- -
-
{post.nickname}
-
{formatTime(post.createTime)}
-
- -
+ {loadingNewer && ( +
+ + 正在检查更新的动态... +
+ )} -
- {post.contentDesc &&
{post.contentDesc}
} + {!loadingNewer && hasNewer && ( +
loadPosts({ direction: 'newer' })}> + 有新动态,点击查看 +
+ )} - {showLinkCard && linkCard && ( - - )} +
+ {posts.map(post => ( + setPreviewImage({ src, isVideo, liveVideoPath })} + onDebug={(p) => setDebugPost(p)} + /> + ))} +
- {showMediaGrid && ( -
- {post.media.map((m, idx) => ( - setPreviewImage({ src, isVideo, liveVideoPath })} /> - ))} -
- )} -
+ {loading &&
+ + 正在加载更多... +
} - {(post.likes.length > 0 || post.comments.length > 0) && ( -
- {post.likes.length > 0 && ( -
- - - {post.likes.join('、')} - -
- )} + {!hasMore && posts.length > 0 && ( +
已经到底啦
+ )} - {post.comments.length > 0 && ( -
- {post.comments.map((c, idx) => ( -
- {c.nickname} - {c.refNickname && ( - <> - 回复 - {c.refNickname} - - )} - : - {c.content} -
- ))} -
- )} -
- )} -
-
-
- ) - })} -
- - {loading &&
- - 正在加载更多... -
} - {!hasMore && posts.length > 0 &&
已经到底啦
} - {!loading && posts.length === 0 && ( -
-
-

未找到相关动态

- {(selectedUsernames.length > 0 || searchKeyword) && ( - - )} -
+ {!loading && posts.length === 0 && ( +
+
+

未找到相关动态

+ {(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && ( + )}
-
-
- - {/* 侧边栏:过滤与搜索 (moved to right) */} - + )} +
+ + setShowJumpDialog(true)} + selectedUsernames={selectedUsernames} + setSelectedUsernames={setSelectedUsernames} + contacts={contacts} + contactSearch={contactSearch} + setContactSearch={setContactSearch} + loading={contactsLoading} + /> + + {/* Dialogs and Overlays */} {previewImage && ( setPreviewImage(null)} /> )} + { - setShowJumpDialog(false) - }} + onClose={() => setShowJumpDialog(false)} onSelect={(date) => { setJumpTargetDate(date) setShowJumpDialog(false) @@ -1008,149 +350,19 @@ export default function SnsPage() { currentDate={jumpTargetDate || new Date()} /> - {/* Debug Info Dialog */} {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}>
-

原始数据 - {debugPost.nickname}

+

原始数据

- -
-

ℹ 基本信息

-
- ID: - {debugPost.id} -
-
- 用户名: - {debugPost.username} -
-
- 昵称: - {debugPost.nickname} -
-
- 时间: - {new Date(debugPost.createTime * 1000).toLocaleString()} -
-
- 类型: - {debugPost.type} -
-
- -
-

媒体信息 ({debugPost.media.length} 项)

- {debugPost.media.map((media, idx) => ( -
-
媒体 {idx + 1}
-
- URL: - {media.url} -
-
- 缩略图: - {media.thumb} -
- {media.md5 && ( -
- MD5: - {media.md5} -
- )} - {media.token && ( -
- Token: - {media.token} -
- )} - {media.key && ( -
- Key (解密密钥): - {media.key} -
- )} - {media.encIdx && ( -
- Enc Index: - {media.encIdx} -
- )} - {media.livePhoto && ( -
-
Live Photo 视频部分:
-
- 视频 URL: - {media.livePhoto.url} -
-
- 视频缩略图: - {media.livePhoto.thumb} -
- {media.livePhoto.token && ( -
- 视频 Token: - {media.livePhoto.token} -
- )} - {media.livePhoto.key && ( -
- 视频 Key: - {media.livePhoto.key} -
- )} -
- )} -
- ))} -
- - {/* 原始 XML */} - {debugPost.rawXml && ( -
-

原始 XML 数据

-
{(() => {
-                                        // XML 缩进格式化
-                                        let formatted = '';
-                                        let indent = 0;
-                                        const tab = '  ';
-                                        const parts = debugPost.rawXml.split(/(<[^>]+>)/g).filter(p => p.trim());
-
-                                        for (const part of parts) {
-                                            if (!part.startsWith('<')) {
-                                                if (part.trim()) formatted += part;
-                                                continue;
-                                            }
-
-                                            if (part.startsWith('')) {
-                                                formatted += '\n' + tab.repeat(indent) + part;
-                                            } else {
-                                                formatted += '\n' + tab.repeat(indent) + part;
-                                                indent++;
-                                            }
-                                        }
-
-                                        return formatted.trim();
-                                    })()}
- -
- )} +
+                                {JSON.stringify(debugPost, null, 2)}
+                            
diff --git a/src/types/sns.ts b/src/types/sns.ts new file mode 100644 index 0000000..b909433 --- /dev/null +++ b/src/types/sns.ts @@ -0,0 +1,47 @@ +export interface SnsLivePhoto { + url: string + thumb: string + token?: string + key?: string + encIdx?: string +} + +export interface SnsMedia { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: SnsLivePhoto +} + +export interface SnsComment { + id: string + nickname: string + content: string + refCommentId: string + refNickname?: string +} + +export interface SnsPost { + id: string + username: string + nickname: string + avatarUrl?: string + createTime: number + contentDesc: string + type?: number + media: SnsMedia[] + likes: string[] + comments: SnsComment[] + rawXml?: string + linkTitle?: string + linkUrl?: string +} + +export interface SnsLinkCardData { + title: string + url: string + thumb?: string +} From 9aee5787075437d0376230d7d702527236cae792 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:50:02 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9C=8B=E5=8F=8B?= =?UTF-8?q?=E5=9C=88=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- electron/main.ts | 20 + electron/preload.ts | 8 +- electron/services/snsService.ts | 374 ++++++++++++++- src/pages/SnsPage.scss | 807 +++++++++++++++++++++++++++++++- src/pages/SnsPage.tsx | 385 ++++++++++++++- src/types/electron.d.ts | 12 + 7 files changed, 1597 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index c424a77..d1425df 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,5 @@ wcdb/ 概述.md chatlab-format.md *.bak -AGENTS.md \ No newline at end of file +AGENTS.md +.claude/ \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index 09fd39a..81186a2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -983,6 +983,26 @@ function registerIpcHandlers() { } }) + ipcMain.handle('sns:exportTimeline', async (event, options: any) => { + return snsService.exportTimeline(options, (progress) => { + if (!event.sender.isDestroyed()) { + event.sender.send('sns:exportProgress', progress) + } + }) + }) + + ipcMain.handle('sns:selectExportDir', async () => { + const { dialog } = await import('electron') + const result = await dialog.showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], + title: '选择导出目录' + }) + if (result.canceled || !result.filePaths?.[0]) { + return { canceled: true } + } + return { canceled: false, filePath: result.filePaths[0] } + }) + // 私聊克隆 diff --git a/electron/preload.ts b/electron/preload.ts index d25b65f..3adee1c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -278,7 +278,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), - downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload) + downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), + exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options), + onExportProgress: (callback: (payload: any) => void) => { + ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('sns:exportProgress') + }, + selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir') }, // Llama AI diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index f4b7fff..0b468ca 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -38,6 +38,8 @@ export interface SnsPost { likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] rawXml?: string + linkTitle?: string + linkUrl?: string } @@ -266,6 +268,367 @@ class SnsService { return this.fetchAndDecryptImage(url, key) } + /** + * 导出朋友圈动态 + * 支持筛选条件(用户名、关键词)和媒体文件导出 + */ + async exportTimeline(options: { + outputDir: string + format: 'json' | 'html' + usernames?: string[] + keyword?: string + exportMedia?: boolean + startTime?: number + endTime?: number + }, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> { + const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options + + try { + // 确保输出目录存在 + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }) + } + + // 1. 分页加载全部帖子 + const allPosts: SnsPost[] = [] + const pageSize = 50 + let endTs: number | undefined = endTime // 使用 endTime 作为分页起始上界 + let hasMore = true + + progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' }) + + while (hasMore) { + const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs) + if (result.success && result.timeline && result.timeline.length > 0) { + allPosts.push(...result.timeline) + // 下一页的 endTs 为当前最后一条帖子的时间 - 1 + const lastTs = result.timeline[result.timeline.length - 1].createTime - 1 + endTs = lastTs + hasMore = result.timeline.length >= pageSize + // 如果已经低于 startTime,提前终止 + if (startTime && lastTs < startTime) { + hasMore = false + } + progressCallback?.({ current: allPosts.length, total: 0, status: `已加载 ${allPosts.length} 条动态...` }) + } else { + hasMore = false + } + } + + if (allPosts.length === 0) { + return { success: true, filePath: '', postCount: 0, mediaCount: 0 } + } + + progressCallback?.({ current: 0, total: allPosts.length, status: `共 ${allPosts.length} 条动态,准备导出...` }) + + // 2. 如果需要导出媒体,创建 media 子目录并下载 + let mediaCount = 0 + const mediaDir = join(outputDir, 'media') + + if (exportMedia) { + if (!existsSync(mediaDir)) { + mkdirSync(mediaDir, { recursive: true }) + } + + // 收集所有媒体下载任务 + const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = [] + for (const post of allPosts) { + post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi })) + } + + // 并发下载(5路) + let done = 0 + const concurrency = 5 + const runTask = async (task: typeof mediaTasks[0]) => { + const { media, postId, mi } = task + try { + const isVideo = isVideoUrl(media.url) + const ext = isVideo ? 'mp4' : 'jpg' + const fileName = `${postId}_${mi}.${ext}` + const filePath = join(mediaDir, fileName) + + if (existsSync(filePath)) { + ;(media as any).localPath = `media/${fileName}` + mediaCount++ + } else { + const result = await this.fetchAndDecryptImage(media.url, media.key) + if (result.success && result.data) { + await writeFile(filePath, result.data) + ;(media as any).localPath = `media/${fileName}` + mediaCount++ + } else if (result.success && result.cachePath) { + const cachedData = await readFile(result.cachePath) + await writeFile(filePath, cachedData) + ;(media as any).localPath = `media/${fileName}` + mediaCount++ + } + } + } catch (e) { + console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e) + } + done++ + progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` }) + } + + // 控制并发的执行器 + const queue = [...mediaTasks] + const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { + while (queue.length > 0) { + const task = queue.shift()! + await runTask(task) + } + }) + await Promise.all(workers) + } + + // 2.5 下载头像 + const avatarMap = new Map() + if (format === 'html') { + if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true }) + const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()] + let avatarDone = 0 + const avatarQueue = [...uniqueUsers] + const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => { + while (avatarQueue.length > 0) { + const post = avatarQueue.shift()! + try { + const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg` + const filePath = join(mediaDir, fileName) + if (existsSync(filePath)) { + avatarMap.set(post.username, `media/${fileName}`) + } else { + const result = await this.fetchAndDecryptImage(post.avatarUrl!) + if (result.success && result.data) { + await writeFile(filePath, result.data) + avatarMap.set(post.username, `media/${fileName}`) + } + } + } catch (e) { /* 头像下载失败不影响导出 */ } + avatarDone++ + progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` }) + } + }) + await Promise.all(avatarWorkers) + } + + // 3. 生成输出文件 + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + let outputFilePath: string + + if (format === 'json') { + outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`) + const exportData = { + exportTime: new Date().toISOString(), + totalPosts: allPosts.length, + filters: { + usernames: usernames || [], + keyword: keyword || '' + }, + posts: allPosts.map(p => ({ + id: p.id, + username: p.username, + nickname: p.nickname, + createTime: p.createTime, + createTimeStr: new Date(p.createTime * 1000).toLocaleString('zh-CN'), + contentDesc: p.contentDesc, + type: p.type, + media: p.media.map(m => ({ + url: m.url, + thumb: m.thumb, + localPath: (m as any).localPath || undefined + })), + likes: p.likes, + comments: p.comments, + linkTitle: (p as any).linkTitle, + linkUrl: (p as any).linkUrl + })) + } + await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') + } else { + // HTML 格式 + outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`) + const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap) + await writeFile(outputFilePath, html, 'utf-8') + } + + progressCallback?.({ current: allPosts.length, total: allPosts.length, status: '导出完成!' }) + + return { success: true, filePath: outputFilePath, postCount: allPosts.length, mediaCount } + } catch (e: any) { + console.error('[SnsExport] 导出失败:', e) + return { success: false, error: e.message || String(e) } + } + } + + /** + * 生成朋友圈 HTML 导出文件 + */ + private generateHtml(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }, avatarMap?: Map): string { + const escapeHtml = (str: string) => str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/\n/g, '
') + + const formatTime = (ts: number) => { + const d = new Date(ts * 1000) + const now = new Date() + const isCurrentYear = d.getFullYear() === now.getFullYear() + const pad = (n: number) => String(n).padStart(2, '0') + const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}` + const m = d.getMonth() + 1, day = d.getDate() + return isCurrentYear ? `${m}月${day}日 ${timeStr}` : `${d.getFullYear()}年${m}月${day}日 ${timeStr}` + } + + // 生成头像首字母 + const avatarLetter = (name: string) => { + const ch = name.charAt(0) + return escapeHtml(ch || '?') + } + + let filterInfo = '' + if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" ` + if (filters.usernames && filters.usernames.length > 0) filterInfo += `筛选用户: ${filters.usernames.length} 人` + + const postsHtml = posts.map(post => { + const mediaCount = post.media.length + const gridClass = mediaCount === 1 ? 'grid-1' : mediaCount === 2 || mediaCount === 4 ? 'grid-2' : 'grid-3' + + const mediaHtml = post.media.map((m, mi) => { + const localPath = (m as any).localPath + if (localPath) { + if (isVideoUrl(m.url)) { + return `
` + } + return `
` + } + return `` + }).join('') + + const linkHtml = post.linkTitle && post.linkUrl + ? `${escapeHtml(post.linkTitle)}` + : '' + + const likesHtml = post.likes.length > 0 + ? `
` + : '' + + const commentsHtml = post.comments.length > 0 + ? `
${post.comments.map(c => { + const ref = c.refNickname ? `回复${escapeHtml(c.refNickname)}` : '' + return `
${escapeHtml(c.nickname)}${ref}:${escapeHtml(c.content)}
` + }).join('')}
` + : '' + + const avatarSrc = avatarMap?.get(post.username) + const avatarHtml = avatarSrc + ? `
` + : `
${avatarLetter(post.nickname)}
` + + return `
+${avatarHtml} +
+
${escapeHtml(post.nickname)}${formatTime(post.createTime)}
+${post.contentDesc ? `
${escapeHtml(post.contentDesc)}
` : ''} +${mediaHtml ? `
${mediaHtml}
` : ''} +${linkHtml} +${likesHtml} +${commentsHtml} +
` + }).join('\n') + + return ` + + + + +朋友圈导出 + + + +
+

朋友圈

共 ${posts.length} 条${filterInfo ? ` · ${filterInfo}` : ''}
+ ${postsHtml} +
由 WeFlow 导出 · ${new Date().toLocaleString('zh-CN')}
+
+
+ + + +` + } + private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { if (!url) return { success: false, error: 'url 不能为空' } @@ -321,7 +684,6 @@ class SnsService { } res.pipe(fileStream) - fileStream.on('finish', async () => { fileStream.close() @@ -381,6 +743,12 @@ class SnsService { resolve({ success: false, error: e.message }) }) + req.setTimeout(15000, () => { + req.destroy() + fs.unlink(tmpPath, () => { }) + resolve({ success: false, error: '请求超时' }) + }) + req.end() } catch (e: any) { @@ -467,6 +835,10 @@ class SnsService { }) req.on('error', (e: any) => resolve({ success: false, error: e.message })) + req.setTimeout(15000, () => { + req.destroy() + resolve({ success: false, error: '请求超时' }) + }) req.end() } catch (e: any) { resolve({ success: false, error: e.message }) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index a18fba0..a364791 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -54,6 +54,12 @@ color: var(--text-primary); } + .header-actions { + display: flex; + align-items: center; + gap: 10px; + } + .icon-btn { background: var(--bg-tertiary); border: 1px solid var(--border-color); @@ -69,8 +75,14 @@ transform: scale(1.05); } - &.spinning { - animation: spin 1s linear infinite; + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + .spinning { + animation: spin 0.8s linear infinite; } } } @@ -746,6 +758,53 @@ } } +/* Initial Loading Animation */ +.initial-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 120px 0; + + .loading-pulse { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + + .pulse-circle { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--primary, #576b95); + opacity: 0.25; + animation: pulse-ring 1.4s ease-in-out infinite; + } + + span { + font-size: 14px; + color: var(--text-tertiary); + letter-spacing: 0.5px; + } + } +} + +@keyframes pulse-ring { + 0% { + transform: scale(0.6); + opacity: 0.15; + } + + 50% { + transform: scale(1.0); + opacity: 0.35; + } + + 100% { + transform: scale(0.6); + opacity: 0.15; + } +} + .no-results { display: flex; flex-direction: column; @@ -872,4 +931,748 @@ opacity: 1; transform: translateY(0); } +} + +/* ========================================= + Export Dialog + ========================================= */ +.export-dialog { + background: rgba(255, 255, 255, 0.88); + border-radius: var(--sns-border-radius-lg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); + width: 480px; + max-width: 92vw; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + overflow: hidden; + animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + .export-dialog-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: space-between; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + + &:hover { + background: rgba(0, 0, 0, 0.05); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + } + + .export-dialog-body { + padding: 20px; + display: flex; + flex-direction: column; + gap: 18px; + } +} + +.export-filter-info { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 14px; + background: var(--bg-tertiary); + border-radius: 8px; + border: 1px solid var(--border-color); + + .filter-badge { + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--primary, #576b95); + padding: 2px 8px; + border-radius: 10px; + } + + .filter-tag { + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-primary); + padding: 2px 10px; + border-radius: 6px; + border: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 4px; + + svg { + flex-shrink: 0; + } + + .sync-hint { + font-size: 11px; + color: var(--text-tertiary); + font-style: italic; + } + } +} + +.export-section { + display: flex; + flex-direction: column; + gap: 8px; +} + + +.export-format-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + + .format-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 14px 10px; + border-radius: 10px; + border: 2px solid var(--border-color); + background: var(--bg-primary); + cursor: pointer; + transition: all 0.2s; + + span { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + small { + font-size: 11px; + color: var(--text-tertiary); + } + + svg { + color: var(--text-tertiary); + } + + &:hover:not(:disabled) { + border-color: var(--primary, #576b95); + background: var(--bg-tertiary); + } + + &.active { + border-color: var(--primary, #576b95); + background: rgba(87, 107, 149, 0.08); + + svg { + color: var(--primary, #576b95); + } + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.export-path-row { + display: flex; + gap: 8px; + + .export-path-input { + flex: 1; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + outline: none; + cursor: default; + + &::placeholder { + color: var(--text-tertiary); + } + } + + .export-browse-btn { + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: var(--primary, #576b95); + color: #fff; + border-color: var(--primary, #576b95); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.export-date-row { + display: flex; + align-items: center; + gap: 8px; + + .date-picker-trigger { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + min-height: 36px; + + &:hover { + border-color: var(--primary, #576b95); + } + + &>svg:first-child { + color: var(--text-tertiary); + flex-shrink: 0; + } + + .placeholder { + color: var(--text-tertiary); + } + + .clear-date { + margin-left: auto; + color: var(--text-tertiary); + cursor: pointer; + border-radius: 50%; + padding: 1px; + + &:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + } + } + } + + .date-separator { + font-size: 13px; + color: var(--text-tertiary); + flex-shrink: 0; + } +} + +.calendar-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2100; + animation: fadeIn 0.2s ease-out; +} + +.calendar-modal { + background: var(--card-bg); + width: 340px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + .calendar-header { + padding: 18px 20px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-color); + + .title-area { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-primary); + + svg { + color: var(--primary); + } + + h3 { + font-size: 16px; + font-weight: 600; + margin: 0; + } + } + + .close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + .calendar-view { + padding: 20px; + + .calendar-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + .current-month { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .nav-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + color: var(--primary); + } + } + } + + .calendar-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 8px; + + .weekday { + text-align: center; + font-size: 12px; + font-weight: 500; + color: var(--text-tertiary); + padding: 4px 0; + } + } + + .calendar-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: repeat(6, 36px); + gap: 4px; + + .day-cell { + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--text-primary); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + position: relative; + + &.empty { + cursor: default; + } + + &:not(.empty):hover { + background: var(--bg-hover); + } + + &.selected { + background: var(--primary); + color: #fff; + font-weight: 600; + } + + &.today:not(.selected) { + color: var(--primary); + font-weight: 600; + background: var(--primary-light); + } + } + } + } + + .quick-options { + display: flex; + gap: 8px; + padding: 0 20px 16px; + + button { + flex: 1; + padding: 8px; + font-size: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--primary); + border-color: var(--primary); + } + } + } + + .dialog-footer { + padding: 16px 20px; + display: flex; + gap: 12px; + background: var(--bg-secondary); + + button { + flex: 1; + padding: 10px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .cancel-btn { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes modalSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.export-label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 4px; + + svg { + flex-shrink: 0; + } +} + +.export-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + + .toggle-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--text-primary); + + svg { + color: var(--text-tertiary); + } + } + + .toggle-switch { + width: 44px; + height: 24px; + border-radius: 12px; + border: none; + background: var(--bg-tertiary, #555); + cursor: pointer; + position: relative; + transition: background 0.25s; + padding: 0; + flex-shrink: 0; + + &.active { + background: var(--primary, #576b95); + } + + .toggle-knob { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + transition: transform 0.25s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + + &.active .toggle-knob { + transform: translateX(20px); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.export-media-hint { + font-size: 12px; + color: var(--text-tertiary); + margin: 0; + padding-left: 24px; + line-height: 1.4; +} + +.export-progress { + display: flex; + flex-direction: column; + gap: 6px; + + .export-progress-bar { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; + + .export-progress-fill { + height: 100%; + background: var(--primary, #576b95); + border-radius: 3px; + transition: width 0.3s ease; + } + } + + .export-progress-text { + font-size: 12px; + color: var(--text-tertiary); + text-align: center; + } +} + +.export-result { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-align: center; + padding: 10px 0; + + .export-result-icon { + &.success svg { + color: #52c41a; + } + + &.error svg { + color: #ff4d4f; + } + } + + h4 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + } + + p { + margin: 0; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; + + &.error-text { + color: #ff4d4f; + word-break: break-all; + } + } + + .export-result-actions { + display: flex; + gap: 10px; + margin-top: 8px; + } + + .export-open-btn, + .export-done-btn { + padding: 8px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.15s; + } + + .export-open-btn { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + + &:hover { + background: var(--bg-primary); + } + } + + .export-done-btn { + background: var(--primary, #576b95); + color: #fff; + + &:hover { + filter: brightness(1.1); + } + } +} + +.export-sync-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 8px; + margin: 8px 0; + color: var(--text-tertiary); + font-size: 12px; + border: 1px dashed var(--border-color); + + svg { + color: var(--primary); + flex-shrink: 0; + } +} + +.export-actions { + display: flex; + gap: 12px; + margin-top: 24px; + + button { + flex: 1; + height: 40px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + + .export-cancel-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + + &:hover { + background: var(--hover-bg); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .export-start-btn { + background: var(--primary, #576b95); + border: none; + color: #fff; + box-shadow: 0 4px 12px rgba(87, 107, 149, 0.2); + + &:hover:not(:disabled) { + filter: brightness(1.1); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(87, 107, 149, 0.3); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + background: var(--bg-tertiary); + color: var(--text-tertiary); + box-shadow: none; + cursor: not-allowed; + } + } } \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 9853ede..ae30e01 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef, useCallback } from 'react' -import { RefreshCw, Search, X, Download, FolderOpen } from 'lucide-react' +import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' import './SnsPage.scss' @@ -34,6 +34,18 @@ export default function SnsPage() { const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null) const [debugPost, setDebugPost] = useState(null) + // 导出相关状态 + const [showExportDialog, setShowExportDialog] = useState(false) + const [exportFormat, setExportFormat] = useState<'json' | 'html'>('html') + const [exportFolder, setExportFolder] = useState('') + const [exportMedia, setExportMedia] = useState(false) + const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' }) + const [isExporting, setIsExporting] = useState(false) + const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null) + const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null) + const [refreshSpin, setRefreshSpin] = useState(false) + const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null) + const postsContainerRef = useRef(null) const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) @@ -257,12 +269,28 @@ export default function SnsPage() {

朋友圈

+
@@ -291,10 +319,21 @@ export default function SnsPage() { ))} - {loading &&
- - 正在加载更多... -
} + {loading && posts.length === 0 && ( +
+
+
+ 正在加载朋友圈... +
+
+ )} + + {loading && posts.length > 0 && ( +
+ + 正在加载更多... +
+ )} {!hasMore && posts.length > 0 && (
已经到底啦
@@ -367,6 +406,338 @@ export default function SnsPage() { )} + + {/* 导出对话框 */} + {showExportDialog && ( +
!isExporting && setShowExportDialog(false)}> +
e.stopPropagation()}> +
+

导出朋友圈

+ +
+ +
+ {/* 筛选条件提示 */} + {(selectedUsernames.length > 0 || searchKeyword) && ( +
+ 筛选导出 + {searchKeyword && 关键词: "{searchKeyword}"} + {selectedUsernames.length > 0 && ( + + + {selectedUsernames.length} 个联系人 + (同步自侧栏筛选) + + )} +
+ )} + + {!exportResult ? ( + <> + {/* 格式选择 */} +
+ +
+ + +
+
+ + {/* 输出路径 */} +
+ +
+ + +
+
+ + {/* 时间范围 */} +
+ +
+
{ + if (!isExporting) setCalendarPicker(prev => prev?.field === 'start' ? null : { field: 'start', month: exportDateRange.start ? new Date(exportDateRange.start) : new Date() }) + }}> + + + {exportDateRange.start || '开始日期'} + + {exportDateRange.start && ( + { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, start: '' })) }} /> + )} +
+ +
{ + if (!isExporting) setCalendarPicker(prev => prev?.field === 'end' ? null : { field: 'end', month: exportDateRange.end ? new Date(exportDateRange.end) : new Date() }) + }}> + + + {exportDateRange.end || '结束日期'} + + {exportDateRange.end && ( + { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, end: '' })) }} /> + )} +
+
+
+ + {/* 媒体导出 */} +
+
+
+ + 导出媒体文件(图片/视频) +
+ +
+ {exportMedia && ( +

媒体文件将保存到输出目录的 media 子目录中,可能需要较长时间

+ )} +
+ + {/* 同步提示 */} +
+ + 将同步主页面的联系人范围筛选及关键词搜索 +
+ + {/* 进度条 */} + {isExporting && exportProgress && ( +
+
+
0 ? `${Math.round((exportProgress.current / exportProgress.total) * 100)}%` : '100%' }} + /> +
+ {exportProgress.status} +
+ )} + + {/* 操作按钮 */} +
+ + +
+ + ) : ( + /* 导出结果 */ +
+ {exportResult.success ? ( + <> +
+ +
+

导出成功

+

共导出 {exportResult.postCount} 条动态{exportResult.mediaCount ? `,${exportResult.mediaCount} 个媒体文件` : ''}

+
+ + +
+ + ) : ( + <> +
+ +
+

导出失败

+

{exportResult.error}

+ + + )} +
+ )} +
+
+
+ )} + + {/* 日期选择弹窗 */} + {calendarPicker && ( +
setCalendarPicker(null)}> +
e.stopPropagation()}> +
+
+ +

选择{calendarPicker.field === 'start' ? '开始' : '结束'}日期

+
+ +
+
+
+ + + {calendarPicker.month.getFullYear()}年{calendarPicker.month.getMonth() + 1}月 + + +
+
+ {['日', '一', '二', '三', '四', '五', '六'].map(d =>
{d}
)} +
+
+ {(() => { + const y = calendarPicker.month.getFullYear() + const m = calendarPicker.month.getMonth() + const firstDay = new Date(y, m, 1).getDay() + const daysInMonth = new Date(y, m + 1, 0).getDate() + const cells: (number | null)[] = [] + for (let i = 0; i < firstDay; i++) cells.push(null) + for (let i = 1; i <= daysInMonth; i++) cells.push(i) + const today = new Date() + return cells.map((day, i) => { + if (day === null) return
+ const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` + const isToday = day === today.getDate() && m === today.getMonth() && y === today.getFullYear() + const currentVal = calendarPicker.field === 'start' ? exportDateRange.start : exportDateRange.end + const isSelected = dateStr === currentVal + return ( +
{ + setExportDateRange(prev => ({ ...prev, [calendarPicker.field]: dateStr })) + setCalendarPicker(null) + }} + >{day}
+ ) + }) + })()} +
+
+
+ + +
+
+ +
+
+
+ )}
) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 6c4136c..2863202 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -491,6 +491,18 @@ export interface ElectronAPI { }> debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }> proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }> + downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }> + exportTimeline: (options: { + outputDir: string + format: 'json' | 'html' + usernames?: string[] + keyword?: string + exportMedia?: boolean + startTime?: number + endTime?: number + }) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> + onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void + selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> } llama: { loadModel: (modelPath: string) => Promise From db4fab9130c72b5cb3b218ce013bdcd58e40ab92 Mon Sep 17 00:00:00 2001 From: The Shit Code Here Date: Fri, 20 Feb 2026 20:55:31 +0700 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8DHTML=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=96=87=E4=BB=B6=E5=90=8D=E5=86=B2=E7=AA=81?= =?UTF-8?q?=20(#282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 0xshitcode <0xshitcode@users.noreply.github.com> --- electron/services/exportService.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index be1a653..8d4fbf5 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1479,13 +1479,17 @@ class ExportService { result.localPath = thumbResult.localPath } + // 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖 + const messageId = String(msg.localId || Date.now()) + const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '') + // 从 data URL 或 file URL 获取实际路径 let sourcePath = result.localPath if (sourcePath.startsWith('data:')) { // 是 data URL,需要保存为文件 const base64Data = sourcePath.split(',')[1] const ext = this.getExtFromDataUrl(sourcePath) - const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` + const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) @@ -1501,7 +1505,7 @@ class ExportService { // 复制文件 if (!fs.existsSync(sourcePath)) return null const ext = path.extname(sourcePath) || '.jpg' - const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` + const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) if (!fs.existsSync(destPath)) { @@ -4769,4 +4773,3 @@ class ExportService { } export const exportService = new ExportService() - From 4577b4e955670bd9874437de0d1bca21c3435a80 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:55:44 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E9=97=AE=E9=A2=98=EF=BC=8C=E5=B9=B6=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E4=BA=86=E6=96=B0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 19 +- src/components/NotificationToast.scss | 9 +- src/pages/ChatPage.scss | 15 ++ src/pages/ChatPage.tsx | 25 ++- src/pages/ContactsPage.scss | 102 ++++++++- src/pages/ContactsPage.tsx | 311 +++++++++++++++----------- src/pages/NotificationWindow.tsx | 8 + 7 files changed, 350 insertions(+), 139 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e55dae7..81ea83a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -103,7 +103,7 @@ export interface ContactInfo { remark?: string nickname?: string avatarUrl?: string - type: 'friend' | 'group' | 'official' | 'other' + type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other' } // 表情包缓存 @@ -603,7 +603,7 @@ class ChatService { // 使用execQuery直接查询加密的contact.db // kind='contact', path=null表示使用已打开的contact.db const contactQuery = ` - SELECT username, remark, nick_name, alias, local_type + SELECT username, remark, nick_name, alias, local_type, flag FROM contact ` @@ -663,28 +663,31 @@ class ChatService { } // 判断类型 - 正确规则:wxid开头且有alias的是好友 - let type: 'friend' | 'group' | 'official' | 'other' = 'other' + let type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other' = 'other' const localType = row.local_type || 0 + const flag = Number(row.flag ?? 0) + if (username.includes('@chatroom')) { type = 'group' } else if (username.startsWith('gh_')) { + if (flag === 0) continue type = 'official' } else if (localType === 3 || localType === 4) { + if (flag === 0) continue + if (flag === 4) continue type = 'official' } else if (username.startsWith('wxid_') && row.alias) { - // wxid开头且有alias的是好友 - type = 'friend' + type = flag === 0 ? 'deleted_friend' : 'friend' } else if (localType === 1) { - // local_type=1 也是好友 - type = 'friend' + type = flag === 0 ? 'deleted_friend' : 'friend' } else if (localType === 2) { // local_type=2 是群成员但非好友,跳过 continue } else if (localType === 0) { // local_type=0 可能是好友或其他,检查是否有备注或昵称 if (row.remark || row.nick_name) { - type = 'friend' + type = flag === 0 ? 'deleted_friend' : 'friend' } else { continue } diff --git a/src/components/NotificationToast.scss b/src/components/NotificationToast.scss index cb405a3..b48013c 100644 --- a/src/components/NotificationToast.scss +++ b/src/components/NotificationToast.scss @@ -6,6 +6,13 @@ backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--border-light); + + // 浅色模式下使用不透明背景,避免透明窗口中通知过于透明 + [data-mode="light"] &, + :not([data-mode]) & { + background: rgba(255, 255, 255, 1); + } + border-radius: 12px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); padding: 12px; @@ -39,7 +46,7 @@ backdrop-filter: none !important; -webkit-backdrop-filter: none !important; - // Ensure background is solid + // 确保背景不透明 background: var(--bg-secondary, #2c2c2c); color: var(--text-primary, #ffffff); diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 05fb05a..40ad9ca 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1288,6 +1288,21 @@ z-index: 2; } +.empty-chat-inline { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 60px 0; + color: var(--text-tertiary); + font-size: 14px; + + svg { + opacity: 0.4; + } +} + .message-list * { -webkit-app-region: no-drag !important; } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index f0f5641..26b019a 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1261,6 +1261,7 @@ function ChatPage(_props: ChatPageProps) { useEffect(() => { if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore) { + setHasInitialMessages(false) loadMessages(currentSessionId, 0) } }, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore]) @@ -1327,8 +1328,21 @@ function ChatPage(_props: ChatPageProps) { return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` }, []) - // 获取当前会话信息 - const currentSession = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined + // 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback) + const currentSession = (() => { + const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined + if (found || !currentSessionId) return found + return { + username: currentSessionId, + type: 0, + unreadCount: 0, + summary: '', + sortTimestamp: 0, + lastTimestamp: 0, + lastMsgType: 0, + displayName: currentSessionId, + } as ChatSession + })() // 判断是否为群聊 const isGroupChat = (username: string) => username.includes('@chatroom') @@ -2048,6 +2062,13 @@ function ChatPage(_props: ChatPageProps) {
)} + {!isLoadingMessages && messages.length === 0 && !hasMoreMessages && ( +
+ + 该联系人没有聊天记录 +
+ )} + {messages.map((msg, index) => { const prevMsg = index > 0 ? messages[index - 1] : undefined const showDateDivider = shouldShowDateDivider(msg, prevMsg) diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index 2609639..7f5a1b3 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -7,8 +7,8 @@ // 左侧联系人面板 .contacts-panel { - width: 380px; - min-width: 380px; + width: 400px; + min-width: 400px; display: flex; flex-direction: column; border-right: 1px solid var(--border-color); @@ -55,6 +55,11 @@ .spin { animation: contactsSpin 1s linear infinite; } + + &.export-mode-btn.active { + background: var(--primary); + color: #fff; + } } } @@ -231,8 +236,8 @@ padding: 12px; border-radius: 10px; transition: all 0.2s; - margin-bottom: 4px; cursor: pointer; + margin-bottom: 4px; &:hover { background: var(--bg-hover); @@ -242,6 +247,10 @@ background: color-mix(in srgb, var(--primary) 12%, transparent); } + &.active { + background: var(--bg-tertiary); + } + .contact-select { display: flex; align-items: center; @@ -334,6 +343,93 @@ } } + // 右侧详情面板内的联系人资料 + .detail-profile { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 20px; + + .detail-avatar { + width: 80px; + height: 80px; + border-radius: 16px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + img { width: 100%; height: 100%; object-fit: cover; } + span { color: #fff; font-size: 28px; font-weight: 600; } + } + + .detail-name { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + } + + .detail-info-list { + margin-bottom: 24px; + + .detail-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + font-size: 13px; + border-bottom: 1px solid var(--border-color); + + &:last-child { border-bottom: none; } + } + + .detail-label { + color: var(--text-tertiary); + min-width: 48px; + flex-shrink: 0; + } + + .detail-value { + color: var(--text-primary); + word-break: break-all; + } + } + + .goto-chat-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + background: var(--primary); + color: #fff; + border: none; + border-radius: 10px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + &:hover { background: var(--primary-hover); } + } + + .empty-detail { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-tertiary); + font-size: 14px; + } + // 右侧设置面板 .settings-panel { flex: 1; diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 43968ae..198c809 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' +import { useChatStore } from '../stores/chatStore' import './ContactsPage.scss' interface ContactInfo { @@ -8,7 +10,7 @@ interface ContactInfo { remark?: string nickname?: string avatarUrl?: string - type: 'friend' | 'group' | 'official' | 'other' + type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other' } function ContactsPage() { @@ -20,9 +22,16 @@ function ContactsPage() { const [contactTypes, setContactTypes] = useState({ friends: true, groups: true, - officials: true + officials: true, + deletedFriends: false }) + // 导出模式与查看详情 + const [exportMode, setExportMode] = useState(false) + const [selectedContact, setSelectedContact] = useState(null) + const navigate = useNavigate() + const { setCurrentSession } = useChatStore() + // 导出相关状态 const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json') const [exportAvatars, setExportAvatars] = useState(true) @@ -85,6 +94,7 @@ function ContactsPage() { if (c.type === 'friend' && !contactTypes.friends) return false if (c.type === 'group' && !contactTypes.groups) return false if (c.type === 'official' && !contactTypes.officials) return false + if (c.type === 'deleted_friend' && !contactTypes.deletedFriends) return false return true }) @@ -154,6 +164,7 @@ function ContactsPage() { case 'friend': return case 'group': return case 'official': return + case 'deleted_friend': return default: return } } @@ -163,6 +174,7 @@ function ContactsPage() { case 'friend': return '好友' case 'group': return '群聊' case 'official': return '公众号' + case 'deleted_friend': return '已删除' default: return '其他' } } @@ -236,9 +248,18 @@ function ContactsPage() {

通讯录

- +
+ + +
@@ -258,49 +279,41 @@ function ContactsPage() {
+
共 {filteredContacts.length} 个联系人
-
- - 已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length}) -
+ + {exportMode && ( +
+ + 已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length}) +
+ )} {isLoading ? (
@@ -314,20 +327,29 @@ function ContactsPage() { ) : (
{filteredContacts.map(contact => { - const isSelected = selectedUsernames.has(contact.username) + const isChecked = selectedUsernames.has(contact.username) + const isActive = !exportMode && selectedContact?.username === contact.username return (
toggleContactSelected(contact.username, !isSelected)} + className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`} + onClick={() => { + if (exportMode) { + toggleContactSelected(contact.username, !isChecked) + } else { + setSelectedContact(isActive ? null : contact) + } + }} > - + {exportMode && ( + + )}
{contact.avatarUrl ? ( @@ -352,90 +374,129 @@ function ContactsPage() { )}
- {/* 右侧:导出设置 */} -
-
-

导出设置

-
+ {/* 右侧面板 */} + {exportMode ? ( +
+
+

导出设置

+
-
-
-

导出格式

-
- + {showFormatSelect && ( +
+ {exportFormatOptions.map(option => ( + + ))} +
+ )} +
+
+ +
+

导出选项

+ +
+ +
+

导出位置

+
+ + {exportFolder || '未设置'} +
+ - {showFormatSelect && ( -
- {exportFormatOptions.map(option => ( - - ))} -
+
+
+ +
+
-
- -
-

导出选项

- -
- -
-

导出位置

-
- - {exportFolder || '未设置'} -
-
+ ) : selectedContact ? ( +
+
+

联系人详情

+
+
+
+
+ {selectedContact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(selectedContact.displayName)} + )} +
+
{selectedContact.displayName}
+
+ {getContactTypeIcon(selectedContact.type)} + {getContactTypeName(selectedContact.type)} +
+
-
- +
+
用户名{selectedContact.username}
+
昵称{selectedContact.nickname || selectedContact.displayName}
+ {selectedContact.remark &&
备注{selectedContact.remark}
} +
类型{getContactTypeName(selectedContact.type)}
+
+ + +
-
+ ) : ( +
+
+ + 点击左侧联系人查看详情 +
+
+ )}
) } diff --git a/src/pages/NotificationWindow.tsx b/src/pages/NotificationWindow.tsx index deb6616..2e9acd0 100644 --- a/src/pages/NotificationWindow.tsx +++ b/src/pages/NotificationWindow.tsx @@ -1,9 +1,11 @@ import { useEffect, useState, useRef } from 'react' import { NotificationToast, type NotificationData } from '../components/NotificationToast' +import { useThemeStore } from '../stores/themeStore' import '../components/NotificationToast.scss' import './NotificationWindow.scss' export default function NotificationWindow() { + const { currentTheme, themeMode } = useThemeStore() const [notification, setNotification] = useState(null) const [prevNotification, setPrevNotification] = useState(null) @@ -17,6 +19,12 @@ export default function NotificationWindow() { const notificationRef = useRef(null) + // 应用主题到通知窗口 + useEffect(() => { + document.documentElement.setAttribute('data-theme', currentTheme) + document.documentElement.setAttribute('data-mode', themeMode) + }, [currentTheme, themeMode]) + useEffect(() => { notificationRef.current = notification }, [notification])