mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
更新朋友圈样式
This commit is contained in:
185
src/components/Sns/SnsFilterPanel.tsx
Normal file
185
src/components/Sns/SnsFilterPanel.tsx
Normal file
@@ -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<SnsFilterPanelProps> = ({
|
||||||
|
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 (
|
||||||
|
<aside className="sns-filter-panel">
|
||||||
|
<div className="filter-header">
|
||||||
|
<h3>筛选条件</h3>
|
||||||
|
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
|
||||||
|
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-widgets">
|
||||||
|
{/* Search Widget */}
|
||||||
|
<div className="filter-widget search-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<Search size={14} />
|
||||||
|
<span>关键词搜索</span>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索动态内容..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={e => setSearchKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchKeyword && (
|
||||||
|
<button className="clear-input-btn" onClick={() => setSearchKeyword('')}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Widget */}
|
||||||
|
<div className="filter-widget date-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span>时间跳转</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
|
||||||
|
onClick={onOpenJumpDialog}
|
||||||
|
>
|
||||||
|
<span className="date-text">
|
||||||
|
{jumpTargetDate
|
||||||
|
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
|
: '选择日期...'}
|
||||||
|
</span>
|
||||||
|
{jumpTargetDate && (
|
||||||
|
<div
|
||||||
|
className="clear-date-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setJumpTargetDate(undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Widget */}
|
||||||
|
<div className="filter-widget contact-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<User size={14} />
|
||||||
|
<span>联系人</span>
|
||||||
|
{selectedUsernames.length > 0 && (
|
||||||
|
<span className="badge">{selectedUsernames.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="查找好友..."
|
||||||
|
value={contactSearch}
|
||||||
|
onChange={e => setContactSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Search size={14} className="search-icon" />
|
||||||
|
{contactSearch && (
|
||||||
|
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} style={{ right: 8, top: 8, position: 'absolute', cursor: 'pointer', color: 'var(--text-tertiary)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-list-scroll">
|
||||||
|
{filteredContacts.map(contact => (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggleUserSelection(contact.username)}
|
||||||
|
>
|
||||||
|
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||||
|
<span className="contact-name">{contact.displayName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredContacts.length === 0 && (
|
||||||
|
<div className="empty-state">没有找到联系人</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefreshCw({ size, className }: { size?: number, className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || 24}
|
||||||
|
height={size || 24}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M23 4v6h-6"></path>
|
||||||
|
<path d="M1 20v-6h6"></path>
|
||||||
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
327
src/components/Sns/SnsMediaGrid.tsx
Normal file
327
src/components/Sns/SnsMediaGrid.tsx
Normal file
@@ -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<string> => {
|
||||||
|
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<string>('')
|
||||||
|
const [videoPath, setVideoPath] = useState<string>('')
|
||||||
|
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
|
||||||
|
onClick={handlePreview}
|
||||||
|
>
|
||||||
|
{(thumbSrc && !thumbSrc.startsWith('data:') && (thumbSrc.toLowerCase().endsWith('.mp4') || thumbSrc.includes('video'))) ? (
|
||||||
|
<video
|
||||||
|
key={thumbSrc}
|
||||||
|
src={`${thumbSrc}#t=0.1`}
|
||||||
|
className="media-image"
|
||||||
|
preload="auto"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
disablePictureInPicture
|
||||||
|
disableRemotePlayback
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
e.currentTarget.currentTime = 0.1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={thumbSrc || targetUrl}
|
||||||
|
className="media-image"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setError(true)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGeneratingCover && (
|
||||||
|
<div className="media-decrypting-mask">
|
||||||
|
<RefreshCw className="spin" size={24} />
|
||||||
|
<span>解密中...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isVideo && (
|
||||||
|
<div className="media-badge video">
|
||||||
|
{/* If we have a cover, show Play. If decrypting for preview, show spin. Generating cover has its own mask. */}
|
||||||
|
{isDecrypting ? <RefreshCw className="spin" size={16} /> : <Play size={16} fill="currentColor" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLive && !isVideo && (
|
||||||
|
<div className="media-badge live">
|
||||||
|
<LivePhotoIcon size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="media-download-btn" onClick={handleDownload} title="下载">
|
||||||
|
<Download size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ 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 (
|
||||||
|
<div className={`sns-media-grid ${gridClass}`}>
|
||||||
|
{mediaList.map((media, idx) => (
|
||||||
|
<MediaItem key={idx} media={media} onPreview={onPreview} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
263
src/components/Sns/SnsPostItem.tsx
Normal file
263
src/components/Sns/SnsPostItem.tsx
Normal file
@@ -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(/<!\[CDATA\[([\s\S]*?)\]\]>/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<string>()
|
||||||
|
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<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await window.electronAPI.shell.openExternal(card.url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SnsLinkCard] openExternal failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="post-link-card" onClick={handleClick}>
|
||||||
|
<div className="link-thumb">
|
||||||
|
{card.thumb && !thumbFailed ? (
|
||||||
|
<img
|
||||||
|
src={card.thumb}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setThumbFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="link-thumb-fallback">
|
||||||
|
<ImageIcon size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="link-meta">
|
||||||
|
<div className="link-title">{card.title}</div>
|
||||||
|
<div className="link-url">{hostname}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="link-arrow" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsPostItemProps {
|
||||||
|
post: SnsPost
|
||||||
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
|
onDebug: (post: SnsPost) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ 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 (
|
||||||
|
<div className="sns-post-item">
|
||||||
|
<div className="post-avatar-col">
|
||||||
|
<Avatar
|
||||||
|
src={post.avatarUrl}
|
||||||
|
name={post.nickname}
|
||||||
|
size={48}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="post-content-col">
|
||||||
|
<div className="post-header-row">
|
||||||
|
<div className="post-author-info">
|
||||||
|
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||||
|
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDebug(post);
|
||||||
|
}} title="查看原始数据">
|
||||||
|
<Code size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.contentDesc && (
|
||||||
|
<div className="post-text">{decodeHtmlEntities(post.contentDesc)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLinkCard && linkCard && (
|
||||||
|
<SnsLinkCard card={linkCard} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMediaGrid && (
|
||||||
|
<div className="post-media-container">
|
||||||
|
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(post.likes.length > 0 || post.comments.length > 0) && (
|
||||||
|
<div className="post-interactions">
|
||||||
|
{post.likes.length > 0 && (
|
||||||
|
<div className="likes-block">
|
||||||
|
<Heart size={14} className="like-icon" />
|
||||||
|
<span className="likes-text">{post.likes.join('、')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.comments.length > 0 && (
|
||||||
|
<div className="comments-block">
|
||||||
|
{post.comments.map((c, idx) => (
|
||||||
|
<div key={idx} className="comment-row">
|
||||||
|
<span className="comment-user">{c.nickname}</span>
|
||||||
|
{c.refNickname && (
|
||||||
|
<>
|
||||||
|
<span className="reply-text">回复</span>
|
||||||
|
<span className="comment-user">{c.refNickname}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="comment-colon">:</span>
|
||||||
|
<span className="comment-content">{c.content}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
47
src/types/sns.ts
Normal file
47
src/types/sns.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user