From 6a3e4dc0e1c850e3bbf983c4e27fe4d7bb66e013 Mon Sep 17 00:00:00 2001 From: ILoveBingLu Date: Mon, 2 Mar 2026 02:52:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0=E8=A1=A8=E6=83=85?= =?UTF-8?q?=E5=92=8C=E5=9B=BE=E7=89=87=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/snsService.ts | 118 ++++++++++++++++++++++-- electron/services/wasmService.ts | 33 +++++-- src/pages/MomentsWindow.scss | 27 +++++- src/pages/MomentsWindow.tsx | 148 +++++++++++++++++++++++++++++-- src/types/electron.d.ts | 33 ++++++- 5 files changed, 333 insertions(+), 26 deletions(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index a0015d9..120263e 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -44,6 +44,43 @@ export interface SnsShareInfo { type?: number } +export interface SnsCommentEmoji { + url: string + md5: string + width: number + height: number + encryptUrl?: string + aesKey?: string +} + +export interface SnsCommentImage { + url: string + token?: string + key?: string + encIdx?: string + thumbUrl?: string + thumbUrlToken?: string + thumbKey?: string + thumbEncIdx?: string + width?: number + height?: number + heightPercentage?: number + fileSize?: number + minArea?: number + mediaId?: string + md5?: string +} + +export interface SnsComment { + id: string + nickname: string + content: string + refCommentId: string + refNickname?: string + emojis?: SnsCommentEmoji[] + images?: SnsCommentImage[] +} + export interface SnsPost { id: string username: string @@ -55,7 +92,7 @@ export interface SnsPost { media: SnsMedia[] shareInfo?: SnsShareInfo likes: string[] - comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] + comments: SnsComment[] rawXml?: string } @@ -465,10 +502,20 @@ class SnsService { /** * 从 XML 中解析评论信息 */ - private parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number }[] }[] { + private parseCommentsFromXml(xml: string): SnsComment[] { if (!xml) return [] - type CommentItem = { id: string; nickname: string; username?: string; content: string; refCommentId: string; refUsername?: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number }[] } + type CommentItem = { + id: string + nickname: string + username?: string + content: string + refCommentId: string + refUsername?: string + refNickname?: string + emojis?: SnsCommentEmoji[] + images?: SnsCommentImage[] + } const comments: CommentItem[] = [] try { // 方式1: 查找 标签 @@ -525,7 +572,7 @@ class SnsService { const refUsernameMatch = commentUserXml.match(/([^<]*)<\/ref_username>/i) // 提取表情包信息 - const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = [] + const emojis: SnsCommentEmoji[] = [] const emojiRegex = /([\s\S]*?)<\/emojiinfo>/gi let emojiMatch while ((emojiMatch = emojiRegex.exec(commentUserXml)) !== null) { @@ -558,8 +605,46 @@ class SnsService { } } - // 昵称存在即可(content 可能为空但有表情包) - if (nicknameMatch && (contentMatch || emojis.length > 0)) { + // 提取评论图片信息(评论图片走 imagelist/imageinfo) + const images: SnsCommentImage[] = [] + const imageRegex = /([\s\S]*?)<\/imageinfo>/gi + let imageMatch + while ((imageMatch = imageRegex.exec(commentUserXml)) !== null) { + const imageXml = imageMatch[1] + const pick = (tag: string) => { + const m = imageXml.match(new RegExp(`<${tag}>([^<]*)<\\/${tag}>`, 'i')) + return m ? m[1].trim().replace(/&/g, '&') : '' + } + const parseNum = (value: string) => { + const n = parseInt(value, 10) + return Number.isFinite(n) ? n : undefined + } + + const imageInfo: SnsCommentImage = { + url: pick('url'), + token: pick('token') || undefined, + key: pick('key') || undefined, + encIdx: pick('enc_idx') || undefined, + thumbUrl: pick('thumb_url') || undefined, + thumbUrlToken: pick('thumb_url_token') || undefined, + thumbKey: pick('thumb_key') || undefined, + thumbEncIdx: pick('thumb_enc_idx') || undefined, + width: parseNum(pick('width')), + height: parseNum(pick('height')), + heightPercentage: parseNum(pick('height_percentage')), + fileSize: parseNum(pick('file_size')), + minArea: parseNum(pick('min_area')), + mediaId: pick('media_id') || undefined, + md5: pick('md5') || undefined + } + + if (imageInfo.url || imageInfo.thumbUrl) { + images.push(imageInfo) + } + } + + // 昵称存在即可(content 可能为空但有表情包/图片) + if (nicknameMatch && (contentMatch || emojis.length > 0 || images.length > 0)) { const refCommentId = refCommentIdMatch ? refCommentIdMatch[1].trim() : '' comments.push({ id: idMatch ? idMatch[1].trim() : `comment_${Date.now()}_${Math.random()}`, @@ -569,7 +654,8 @@ class SnsService { refCommentId: (refCommentId === '0') ? '' : refCommentId, refUsername: refUsernameMatch ? refUsernameMatch[1].trim() : undefined, refNickname: refNicknameMatch ? refNicknameMatch[1].trim() : undefined, - emojis: emojis.length > 0 ? emojis : undefined + emojis: emojis.length > 0 ? emojis : undefined, + images: images.length > 0 ? images : undefined }) } } @@ -593,6 +679,20 @@ class SnsService { return comments } + private normalizeComments(comments: SnsComment[]): SnsComment[] { + return comments.map((c) => { + const fixedImages = c.images?.map((img) => ({ + ...img, + url: fixSnsUrl(img.url || '', img.token, false), + thumbUrl: fixSnsUrl(img.thumbUrl || '', img.thumbUrlToken || img.token, false) + })) + return { + ...c, + images: fixedImages + } + }) + } + /** * 从 XML 中解析媒体信息 */ @@ -1287,7 +1387,7 @@ class SnsService { }) const likes = this.parseLikesFromXml(xmlContent) - const comments = this.parseCommentsFromXml(xmlContent) + const comments = this.normalizeComments(this.parseCommentsFromXml(xmlContent)) return { id: idMatch ? idMatch[1] : String(row.tid), @@ -1418,7 +1518,7 @@ class SnsService { // 提取点赞和评论 const likes = this.parseLikesFromXml(xmlContent) - const comments = this.parseCommentsFromXml(xmlContent) + const comments = this.normalizeComments(this.parseCommentsFromXml(xmlContent)) return { id: snsId, diff --git a/electron/services/wasmService.ts b/electron/services/wasmService.ts index 2a5a1ed..9b74183 100644 --- a/electron/services/wasmService.ts +++ b/electron/services/wasmService.ts @@ -6,9 +6,9 @@ import vm from 'vm'; let app: any; try { // eslint-disable-next-line @typescript-eslint/no-var-requires - app = require('electron').app; + app = require('electron')?.app; } catch (e) { - app = { isPackaged: false }; + app = null; } // This service handles the loading and execution of the WeChat WASM module @@ -35,13 +35,28 @@ export class WasmService { this.initPromise = new Promise((resolve, reject) => { try { - // For dev, files are in electron/assets/wasm - // __dirname in dev (from dist-electron) is .../dist-electron - // So we need to go up one level and then into electron/assets/wasm - const isDev = !app.isPackaged; - const basePath = isDev - ? path.join(__dirname, '../electron/assets/wasm') - : path.join(process.resourcesPath, 'assets/wasm'); // Adjust as needed for production build + const isPackaged = !!app && app.isPackaged === true; + const candidates = isPackaged + ? [path.join(process.resourcesPath, 'assets/wasm')] + : [ + path.join(__dirname, '../assets/wasm'), + path.join(__dirname, '../electron/assets/wasm'), + path.join(process.cwd(), 'electron/assets/wasm') + ]; + + let basePath = ''; + for (const p of candidates) { + const wasmCandidate = path.join(p, 'wasm_video_decode.wasm'); + const jsCandidate = path.join(p, 'wasm_video_decode.js'); + if (fs.existsSync(wasmCandidate) && fs.existsSync(jsCandidate)) { + basePath = p; + break; + } + } + + if (!basePath) { + throw new Error(`WASM files not found, checked: ${candidates.join(', ')}`); + } const wasmPath = path.join(basePath, 'wasm_video_decode.wasm'); const jsPath = path.join(basePath, 'wasm_video_decode.js'); diff --git a/src/pages/MomentsWindow.scss b/src/pages/MomentsWindow.scss index 21ee4a7..034495c 100644 --- a/src/pages/MomentsWindow.scss +++ b/src/pages/MomentsWindow.scss @@ -1114,6 +1114,11 @@ .comment-item { line-height: 1.5; word-break: break-word; + display: flex; + align-items: center; + flex-wrap: wrap; + column-gap: 2px; + row-gap: 4px; .comment-nickname, .comment-ref-nickname { @@ -1134,6 +1139,26 @@ .comment-content { color: var(--text-primary); + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + line-height: 1; + + .comment-image-thumb { + width: 42px; + height: 42px; + object-fit: cover; + display: block; + border-radius: 6px; + cursor: pointer; + border: 1px solid rgba(0, 0, 0, 0.08); + transition: transform 0.15s ease; + + &:hover { + transform: scale(1.05); + } + } } } } @@ -1667,4 +1692,4 @@ 100% { background-position: -200% 0; } -} \ No newline at end of file +} diff --git a/src/pages/MomentsWindow.tsx b/src/pages/MomentsWindow.tsx index 80269a4..187754a 100644 --- a/src/pages/MomentsWindow.tsx +++ b/src/pages/MomentsWindow.tsx @@ -44,10 +44,47 @@ interface SnsPost { }> shareInfo?: SnsShareInfo likes: string[] - comments: any[] + comments: SnsComment[] rawXml?: string } +interface SnsCommentEmoji { + url: string + md5: string + width: number + height: number + encryptUrl?: string + aesKey?: string +} + +interface SnsCommentImage { + url: string + token?: string + key?: string + encIdx?: string + thumbUrl?: string + thumbUrlToken?: string + thumbKey?: string + thumbEncIdx?: string + width?: number + height?: number + heightPercentage?: number + fileSize?: number + minArea?: number + mediaId?: string + md5?: string +} + +interface SnsComment { + id: string + nickname: string + content: string + refCommentId: string + refNickname?: string + emojis?: SnsCommentEmoji[] + images?: SnsCommentImage[] +} + const isVideoUrl = (url: string) => { if (!url) return false return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4') @@ -529,6 +566,7 @@ const ShareThumb = ({ shareInfo }: { shareInfo: SnsShareInfo }) => { // 表情包内存缓存:url/encryptUrl → file:// 本地路径 const emojiCache = new Map() +const commentImageCache = new Map() // 评论表情包组件(先查内存缓存,再查本地文件,最后才下载) const CommentEmoji = ({ emoji, onPreview }: { @@ -598,6 +636,69 @@ const CommentEmoji = ({ emoji, onPreview }: { ) } +const CommentImage = ({ image, onPreview }: { + image: SnsCommentImage + onPreview?: (src: string) => void +}) => { + const targetUrl = image.thumbUrl || image.url + const targetKey = image.thumbKey || image.key + const cacheKey = `${targetUrl}|${targetKey || ''}` + const [localSrc, setLocalSrc] = useState(() => commentImageCache.get(cacheKey) || '') + + useEffect(() => { + if (!targetUrl) return + if (commentImageCache.has(cacheKey)) { + setLocalSrc(commentImageCache.get(cacheKey)!) + return + } + let cancelled = false + + const load = async () => { + try { + const res = await window.electronAPI.sns.proxyImage({ + url: targetUrl, + key: targetKey + }) + if (cancelled || !res.success) return + + const src = res.localPath + ? (res.localPath.startsWith('file:') ? res.localPath : `file://${res.localPath.replace(/\\/g, '/')}`) + : (res.dataUrl || '') + if (!src) return + commentImageCache.set(cacheKey, src) + setLocalSrc(src) + } catch { + // 静默失败 + } + } + + load() + return () => { cancelled = true } + }, [cacheKey, targetUrl, targetKey]) + + if (!localSrc) return null + + return ( + comment-image { + e.preventDefault() + e.stopPropagation() + onPreview?.(localSrc) + }} + referrerPolicy="no-referrer" + /> + ) +} + // 朋友圈长文折叠展开组件 const ExpandableText = ({ content }: { content: string }) => { const [isExpanded, setIsExpanded] = useState(false) @@ -989,6 +1090,29 @@ function MomentsWindow() { } } } + if (c.images) { + for (const img of c.images) { + const thumbUrl = img.thumbUrl || img.url + if (thumbUrl && !mediaUrlSet.has(thumbUrl)) { + mediaUrlSet.add(thumbUrl) + allMediaUrls.push({ + type: 'media', + url: thumbUrl, + key: img.thumbKey || img.key, + md5: img.md5 + }) + } + if (img.url && img.url !== thumbUrl && !mediaUrlSet.has(img.url)) { + mediaUrlSet.add(img.url) + allMediaUrls.push({ + type: 'media', + url: img.url, + key: img.key, + md5: img.md5 + }) + } + } + } } } } @@ -1120,19 +1244,28 @@ function MomentsWindow() { let commentsHtml = '' if (p.comments && p.comments.length > 0) { - const items = await Promise.all(p.comments.map(async (c: any) => { + const items = await Promise.all(p.comments.map(async (c: SnsComment) => { const reply = c.refNickname ? `回复${escHtml(c.refNickname)}` : '' let emojiHtml = '' if (c.emojis && c.emojis.length > 0) { - emojiHtml = c.emojis.map((emoji: any) => { + emojiHtml = c.emojis.map((emoji: SnsCommentEmoji) => { const cacheKey = emoji.encryptUrl || emoji.url const fileUrl = imageCache.get(cacheKey) || emojiCache.get(cacheKey) || '' // 对于导出的表情包,我们可能需要使用对应的路径,由于表情包独立下载可能没存到导出的媒体库,先保留内存路径 if (!fileUrl) return '' return `` }).join('') } + let imageHtml = '' + if (c.images && c.images.length > 0) { + imageHtml = c.images.map((img: SnsCommentImage) => { + const thumbUrl = img.thumbUrl || img.url + const fileUrl = imageCache.get(thumbUrl || '') || '' + if (!fileUrl) return '' + return `` + }).join('') + } const cContentHtml = await parseWechatEmojiHtml(c.content) - return `
${escHtml(c.nickname)}${reply}:${cContentHtml}${emojiHtml}
` + return `
${escHtml(c.nickname)}${reply}:${cContentHtml}${emojiHtml}${imageHtml}
` })) commentsHtml = `
${items.join('')}
` } @@ -1644,7 +1777,7 @@ document.querySelectorAll('.vi video').forEach(function(v) { )} {post.comments.length > 0 && (
- {post.comments.map((comment: any, idx: number) => ( + {post.comments.map((comment: SnsComment, idx: number) => (
{comment.nickname} {comment.refNickname && ( @@ -1656,9 +1789,12 @@ document.querySelectorAll('.vi video').forEach(function(v) { : {comment.content && parseWechatEmoji(comment.content)} - {comment.emojis && comment.emojis.map((emoji: any, eidx: number) => ( + {comment.emojis && comment.emojis.map((emoji: SnsCommentEmoji, eidx: number) => ( setPreviewImage({ src })} /> ))} + {comment.images && comment.images.map((image: SnsCommentImage, iidx: number) => ( + setPreviewImage({ src })} /> + ))}
))} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 044860e..014e9cc 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -335,7 +335,38 @@ export interface ElectronAPI { } }> likes: string[] - comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }> + comments: Array<{ + id: string + nickname: string + content: string + refCommentId: string + refNickname?: string + emojis?: Array<{ + url: string + md5: string + width: number + height: number + encryptUrl?: string + aesKey?: string + }> + images?: Array<{ + url: string + token?: string + key?: string + encIdx?: string + thumbUrl?: string + thumbUrlToken?: string + thumbKey?: string + thumbEncIdx?: string + width?: number + height?: number + heightPercentage?: number + fileSize?: number + minArea?: number + mediaId?: string + md5?: string + }> + }> rawXml?: string }> error?: string