mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-04-28 22:55:35 +08:00
feat: 增强评论功能,添加表情和图片支持
This commit is contained in:
@@ -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(/&/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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
33
src/types/electron.d.ts
vendored
33
src/types/electron.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user