mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
朋友圈优化卡片消息类
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(/<!\[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 }) => {
|
||||
@@ -606,7 +762,10 @@ export default function SnsPage() {
|
||||
查看更新的动态
|
||||
</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 (
|
||||
<div key={post.id} className="sns-post-row">
|
||||
<div className="sns-post-wrapper">
|
||||
@@ -640,7 +799,11 @@ export default function SnsPage() {
|
||||
<div className="post-body">
|
||||
{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)}`}>
|
||||
{post.media.map((m, idx) => (
|
||||
<MediaItem key={idx} media={m} onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })} />
|
||||
|
||||
Reference in New Issue
Block a user