feat: 增强评论功能,添加表情和图片支持

This commit is contained in:
ILoveBingLu
2026-03-02 02:52:19 +08:00
parent 5e4937ae98
commit 6a3e4dc0e1
5 changed files with 333 additions and 26 deletions

View File

@@ -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: 查找 <CommentUserList> 标签
@@ -525,7 +572,7 @@ class SnsService {
const refUsernameMatch = commentUserXml.match(/<ref_username>([^<]*)<\/ref_username>/i)
// 提取表情包信息
const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = []
const emojis: SnsCommentEmoji[] = []
const emojiRegex = /<emojiinfo>([\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 = /<imageinfo>([\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(/&amp;/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,

View File

@@ -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');

View File

@@ -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;
}
}
}

View File

@@ -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<string, string>()
const commentImageCache = new Map<string, string>()
// 评论表情包组件(先查内存缓存,再查本地文件,最后才下载)
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<string>(() => 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 (
<img
src={localSrc}
alt="comment-image"
className="comment-image-thumb"
draggable={false}
style={{
pointerEvents: 'auto',
position: 'relative',
zIndex: 5
}}
onClick={(e) => {
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 ? `<span class="re">回复</span><b>${escHtml(c.refNickname)}</b>` : ''
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 `<img src="${escHtml(fileUrl)}" style="width: ${Math.min(emoji.width || 36, 48)}px; height: ${Math.min(emoji.height || 36, 48)}px; vertical-align: middle; margin-left: 2px; border-radius: 6px; cursor: pointer; pointer-events: auto;" onclick="openLightbox('${escHtml(fileUrl)}')" />`
}).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 `<img src="${escHtml(fileUrl)}" style="width: 48px; height: 48px; object-fit: cover; vertical-align: middle; margin-left: 4px; border-radius: 6px; cursor: pointer; pointer-events: auto;" onclick="openLightbox('${escHtml(fileUrl)}')" />`
}).join('')
}
const cContentHtml = await parseWechatEmojiHtml(c.content)
return `<div class="cmt"><b>${escHtml(c.nickname)}</b>${reply}${cContentHtml}${emojiHtml}</div>`
return `<div class="cmt"><b>${escHtml(c.nickname)}</b>${reply}${cContentHtml}${emojiHtml}${imageHtml}</div>`
}))
commentsHtml = `<div class="interactions${p.likes.length > 0 ? ' cmt-border' : ''}"><div class="cmts">${items.join('')}</div></div>`
}
@@ -1644,7 +1777,7 @@ document.querySelectorAll('.vi video').forEach(function(v) {
)}
{post.comments.length > 0 && (
<div className="post-comments">
{post.comments.map((comment: any, idx: number) => (
{post.comments.map((comment: SnsComment, idx: number) => (
<div key={comment.id || idx} className="comment-item">
<span className="comment-nickname">{comment.nickname}</span>
{comment.refNickname && (
@@ -1656,9 +1789,12 @@ document.querySelectorAll('.vi video').forEach(function(v) {
<span className="comment-separator">: </span>
<span className="comment-content">
{comment.content && parseWechatEmoji(comment.content)}
{comment.emojis && comment.emojis.map((emoji: any, eidx: number) => (
{comment.emojis && comment.emojis.map((emoji: SnsCommentEmoji, eidx: number) => (
<CommentEmoji key={eidx} emoji={emoji} onPreview={(src) => setPreviewImage({ src })} />
))}
{comment.images && comment.images.map((image: SnsCommentImage, iidx: number) => (
<CommentImage key={`${image.mediaId || image.md5 || image.url || iidx}_${iidx}`} image={image} onPreview={(src) => setPreviewImage({ src })} />
))}
</span>
</div>
))}

View File

@@ -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