diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index a4d9e27..31cd990 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -704,6 +704,84 @@ 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; @@ -1271,4 +1349,4 @@ transform: rotate(360deg); } } -} \ No newline at end of file +} diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index d0ab6ce..218d8d0 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -32,6 +32,162 @@ interface SnsPost { 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 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 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 }) => { @@ -606,7 +762,10 @@ export default function SnsPage() { 查看更新的动态 )} - {posts.map((post, index) => { + {posts.map((post) => { + const linkCard = buildLinkCardData(post) + const showLinkCard = Boolean(linkCard) && post.media.length <= 1 + const showMediaGrid = post.media.length > 0 && !showLinkCard return (
@@ -640,7 +799,11 @@ export default function SnsPage() {
{post.contentDesc &&
{post.contentDesc}
} - {post.media.length > 0 && ( + {showLinkCard && linkCard && ( + + )} + + {showMediaGrid && (
{post.media.map((m, idx) => ( setPreviewImage({ src, isVideo, liveVideoPath })} />