mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
朋友圈优化卡片消息类
This commit is contained in:
@@ -704,6 +704,84 @@
|
|||||||
word-break: break-word;
|
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 {
|
.post-media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -32,6 +32,162 @@ interface SnsPost {
|
|||||||
likes: string[]
|
likes: string[]
|
||||||
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
||||||
rawXml?: string // 原始 XML 数据
|
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(/<!\[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 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('[SnsPage] 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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
|
const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
|
||||||
@@ -606,7 +762,10 @@ export default function SnsPage() {
|
|||||||
查看更新的动态
|
查看更新的动态
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{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 (
|
return (
|
||||||
<div key={post.id} className="sns-post-row">
|
<div key={post.id} className="sns-post-row">
|
||||||
<div className="sns-post-wrapper">
|
<div className="sns-post-wrapper">
|
||||||
@@ -640,7 +799,11 @@ export default function SnsPage() {
|
|||||||
<div className="post-body">
|
<div className="post-body">
|
||||||
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
|
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
|
||||||
|
|
||||||
{post.media.length > 0 && (
|
{showLinkCard && linkCard && (
|
||||||
|
<SnsLinkCard card={linkCard} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMediaGrid && (
|
||||||
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
||||||
{post.media.map((m, idx) => (
|
{post.media.map((m, idx) => (
|
||||||
<MediaItem key={idx} media={m} onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })} />
|
<MediaItem key={idx} media={m} onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })} />
|
||||||
|
|||||||
Reference in New Issue
Block a user