mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-27 08:05:51 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4fab9130 | ||
|
|
9aee578707 | ||
|
|
6d74eb65ae |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,3 +61,4 @@ wcdb/
|
|||||||
chatlab-format.md
|
chatlab-format.md
|
||||||
*.bak
|
*.bak
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
.claude/
|
||||||
@@ -983,6 +983,26 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
|
||||||
|
return snsService.exportTimeline(options, (progress) => {
|
||||||
|
if (!event.sender.isDestroyed()) {
|
||||||
|
event.sender.send('sns:exportProgress', progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:selectExportDir', async () => {
|
||||||
|
const { dialog } = await import('electron')
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
title: '选择导出目录'
|
||||||
|
})
|
||||||
|
if (result.canceled || !result.filePaths?.[0]) {
|
||||||
|
return { canceled: true }
|
||||||
|
}
|
||||||
|
return { canceled: false, filePath: result.filePaths[0] }
|
||||||
|
})
|
||||||
|
|
||||||
// 私聊克隆
|
// 私聊克隆
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -278,7 +278,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload)
|
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||||
|
exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options),
|
||||||
|
onExportProgress: (callback: (payload: any) => void) => {
|
||||||
|
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
||||||
|
},
|
||||||
|
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Llama AI
|
// Llama AI
|
||||||
|
|||||||
@@ -1479,13 +1479,17 @@ class ExportService {
|
|||||||
result.localPath = thumbResult.localPath
|
result.localPath = thumbResult.localPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖
|
||||||
|
const messageId = String(msg.localId || Date.now())
|
||||||
|
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
|
||||||
|
|
||||||
// 从 data URL 或 file URL 获取实际路径
|
// 从 data URL 或 file URL 获取实际路径
|
||||||
let sourcePath = result.localPath
|
let sourcePath = result.localPath
|
||||||
if (sourcePath.startsWith('data:')) {
|
if (sourcePath.startsWith('data:')) {
|
||||||
// 是 data URL,需要保存为文件
|
// 是 data URL,需要保存为文件
|
||||||
const base64Data = sourcePath.split(',')[1]
|
const base64Data = sourcePath.split(',')[1]
|
||||||
const ext = this.getExtFromDataUrl(sourcePath)
|
const ext = this.getExtFromDataUrl(sourcePath)
|
||||||
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
|
const fileName = `${messageId}_${imageKey}${ext}`
|
||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
|
|
||||||
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
||||||
@@ -1501,7 +1505,7 @@ class ExportService {
|
|||||||
// 复制文件
|
// 复制文件
|
||||||
if (!fs.existsSync(sourcePath)) return null
|
if (!fs.existsSync(sourcePath)) return null
|
||||||
const ext = path.extname(sourcePath) || '.jpg'
|
const ext = path.extname(sourcePath) || '.jpg'
|
||||||
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
|
const fileName = `${messageId}_${imageKey}${ext}`
|
||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
|
|
||||||
if (!fs.existsSync(destPath)) {
|
if (!fs.existsSync(destPath)) {
|
||||||
@@ -4769,4 +4773,3 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const exportService = new ExportService()
|
export const exportService = new ExportService()
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export 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
|
rawXml?: string
|
||||||
|
linkTitle?: string
|
||||||
|
linkUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -266,6 +268,367 @@ class SnsService {
|
|||||||
return this.fetchAndDecryptImage(url, key)
|
return this.fetchAndDecryptImage(url, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出朋友圈动态
|
||||||
|
* 支持筛选条件(用户名、关键词)和媒体文件导出
|
||||||
|
*/
|
||||||
|
async exportTimeline(options: {
|
||||||
|
outputDir: string
|
||||||
|
format: 'json' | 'html'
|
||||||
|
usernames?: string[]
|
||||||
|
keyword?: string
|
||||||
|
exportMedia?: boolean
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> {
|
||||||
|
const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 确保输出目录存在
|
||||||
|
if (!existsSync(outputDir)) {
|
||||||
|
mkdirSync(outputDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 分页加载全部帖子
|
||||||
|
const allPosts: SnsPost[] = []
|
||||||
|
const pageSize = 50
|
||||||
|
let endTs: number | undefined = endTime // 使用 endTime 作为分页起始上界
|
||||||
|
let hasMore = true
|
||||||
|
|
||||||
|
progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' })
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs)
|
||||||
|
if (result.success && result.timeline && result.timeline.length > 0) {
|
||||||
|
allPosts.push(...result.timeline)
|
||||||
|
// 下一页的 endTs 为当前最后一条帖子的时间 - 1
|
||||||
|
const lastTs = result.timeline[result.timeline.length - 1].createTime - 1
|
||||||
|
endTs = lastTs
|
||||||
|
hasMore = result.timeline.length >= pageSize
|
||||||
|
// 如果已经低于 startTime,提前终止
|
||||||
|
if (startTime && lastTs < startTime) {
|
||||||
|
hasMore = false
|
||||||
|
}
|
||||||
|
progressCallback?.({ current: allPosts.length, total: 0, status: `已加载 ${allPosts.length} 条动态...` })
|
||||||
|
} else {
|
||||||
|
hasMore = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPosts.length === 0) {
|
||||||
|
return { success: true, filePath: '', postCount: 0, mediaCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback?.({ current: 0, total: allPosts.length, status: `共 ${allPosts.length} 条动态,准备导出...` })
|
||||||
|
|
||||||
|
// 2. 如果需要导出媒体,创建 media 子目录并下载
|
||||||
|
let mediaCount = 0
|
||||||
|
const mediaDir = join(outputDir, 'media')
|
||||||
|
|
||||||
|
if (exportMedia) {
|
||||||
|
if (!existsSync(mediaDir)) {
|
||||||
|
mkdirSync(mediaDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有媒体下载任务
|
||||||
|
const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = []
|
||||||
|
for (const post of allPosts) {
|
||||||
|
post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发下载(5路)
|
||||||
|
let done = 0
|
||||||
|
const concurrency = 5
|
||||||
|
const runTask = async (task: typeof mediaTasks[0]) => {
|
||||||
|
const { media, postId, mi } = task
|
||||||
|
try {
|
||||||
|
const isVideo = isVideoUrl(media.url)
|
||||||
|
const ext = isVideo ? 'mp4' : 'jpg'
|
||||||
|
const fileName = `${postId}_${mi}.${ext}`
|
||||||
|
const filePath = join(mediaDir, fileName)
|
||||||
|
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
;(media as any).localPath = `media/${fileName}`
|
||||||
|
mediaCount++
|
||||||
|
} else {
|
||||||
|
const result = await this.fetchAndDecryptImage(media.url, media.key)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
await writeFile(filePath, result.data)
|
||||||
|
;(media as any).localPath = `media/${fileName}`
|
||||||
|
mediaCount++
|
||||||
|
} else if (result.success && result.cachePath) {
|
||||||
|
const cachedData = await readFile(result.cachePath)
|
||||||
|
await writeFile(filePath, cachedData)
|
||||||
|
;(media as any).localPath = `media/${fileName}`
|
||||||
|
mediaCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e)
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制并发的执行器
|
||||||
|
const queue = [...mediaTasks]
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const task = queue.shift()!
|
||||||
|
await runTask(task)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5 下载头像
|
||||||
|
const avatarMap = new Map<string, string>()
|
||||||
|
if (format === 'html') {
|
||||||
|
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
|
||||||
|
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
||||||
|
let avatarDone = 0
|
||||||
|
const avatarQueue = [...uniqueUsers]
|
||||||
|
const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => {
|
||||||
|
while (avatarQueue.length > 0) {
|
||||||
|
const post = avatarQueue.shift()!
|
||||||
|
try {
|
||||||
|
const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg`
|
||||||
|
const filePath = join(mediaDir, fileName)
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
avatarMap.set(post.username, `media/${fileName}`)
|
||||||
|
} else {
|
||||||
|
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
await writeFile(filePath, result.data)
|
||||||
|
avatarMap.set(post.username, `media/${fileName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* 头像下载失败不影响导出 */ }
|
||||||
|
avatarDone++
|
||||||
|
progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(avatarWorkers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 生成输出文件
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||||
|
let outputFilePath: string
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||||||
|
const exportData = {
|
||||||
|
exportTime: new Date().toISOString(),
|
||||||
|
totalPosts: allPosts.length,
|
||||||
|
filters: {
|
||||||
|
usernames: usernames || [],
|
||||||
|
keyword: keyword || ''
|
||||||
|
},
|
||||||
|
posts: allPosts.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
username: p.username,
|
||||||
|
nickname: p.nickname,
|
||||||
|
createTime: p.createTime,
|
||||||
|
createTimeStr: new Date(p.createTime * 1000).toLocaleString('zh-CN'),
|
||||||
|
contentDesc: p.contentDesc,
|
||||||
|
type: p.type,
|
||||||
|
media: p.media.map(m => ({
|
||||||
|
url: m.url,
|
||||||
|
thumb: m.thumb,
|
||||||
|
localPath: (m as any).localPath || undefined
|
||||||
|
})),
|
||||||
|
likes: p.likes,
|
||||||
|
comments: p.comments,
|
||||||
|
linkTitle: (p as any).linkTitle,
|
||||||
|
linkUrl: (p as any).linkUrl
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||||
|
} else {
|
||||||
|
// HTML 格式
|
||||||
|
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||||
|
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
||||||
|
await writeFile(outputFilePath, html, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback?.({ current: allPosts.length, total: allPosts.length, status: '导出完成!' })
|
||||||
|
|
||||||
|
return { success: true, filePath: outputFilePath, postCount: allPosts.length, mediaCount }
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[SnsExport] 导出失败:', e)
|
||||||
|
return { success: false, error: e.message || String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成朋友圈 HTML 导出文件
|
||||||
|
*/
|
||||||
|
private generateHtml(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }, avatarMap?: Map<string, string>): string {
|
||||||
|
const escapeHtml = (str: string) => str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
|
||||||
|
const formatTime = (ts: number) => {
|
||||||
|
const d = new Date(ts * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const isCurrentYear = d.getFullYear() === now.getFullYear()
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
const m = d.getMonth() + 1, day = d.getDate()
|
||||||
|
return isCurrentYear ? `${m}月${day}日 ${timeStr}` : `${d.getFullYear()}年${m}月${day}日 ${timeStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成头像首字母
|
||||||
|
const avatarLetter = (name: string) => {
|
||||||
|
const ch = name.charAt(0)
|
||||||
|
return escapeHtml(ch || '?')
|
||||||
|
}
|
||||||
|
|
||||||
|
let filterInfo = ''
|
||||||
|
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
|
||||||
|
if (filters.usernames && filters.usernames.length > 0) filterInfo += `筛选用户: ${filters.usernames.length} 人`
|
||||||
|
|
||||||
|
const postsHtml = posts.map(post => {
|
||||||
|
const mediaCount = post.media.length
|
||||||
|
const gridClass = mediaCount === 1 ? 'grid-1' : mediaCount === 2 || mediaCount === 4 ? 'grid-2' : 'grid-3'
|
||||||
|
|
||||||
|
const mediaHtml = post.media.map((m, mi) => {
|
||||||
|
const localPath = (m as any).localPath
|
||||||
|
if (localPath) {
|
||||||
|
if (isVideoUrl(m.url)) {
|
||||||
|
return `<div class="mi"><video src="${escapeHtml(localPath)}" controls preload="metadata"></video></div>`
|
||||||
|
}
|
||||||
|
return `<div class="mi"><img src="${escapeHtml(localPath)}" loading="lazy" onclick="openLb(this.src)" alt=""></div>`
|
||||||
|
}
|
||||||
|
return `<div class="mi ml"><a href="${escapeHtml(m.url)}" target="_blank">查看媒体</a></div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const linkHtml = post.linkTitle && post.linkUrl
|
||||||
|
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a">›</span></a>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const likesHtml = post.likes.length > 0
|
||||||
|
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const commentsHtml = post.comments.length > 0
|
||||||
|
? `<div class="interactions${post.likes.length > 0 ? ' cmt-border' : ''}"><div class="cmts">${post.comments.map(c => {
|
||||||
|
const ref = c.refNickname ? `<span class="re">回复</span><b>${escapeHtml(c.refNickname)}</b>` : ''
|
||||||
|
return `<div class="cmt"><b>${escapeHtml(c.nickname)}</b>${ref}:${escapeHtml(c.content)}</div>`
|
||||||
|
}).join('')}</div></div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const avatarSrc = avatarMap?.get(post.username)
|
||||||
|
const avatarHtml = avatarSrc
|
||||||
|
? `<div class="avatar"><img src="${escapeHtml(avatarSrc)}" alt=""></div>`
|
||||||
|
: `<div class="avatar">${avatarLetter(post.nickname)}</div>`
|
||||||
|
|
||||||
|
return `<div class="post">
|
||||||
|
${avatarHtml}
|
||||||
|
<div class="body">
|
||||||
|
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
|
||||||
|
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
|
||||||
|
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
|
||||||
|
${linkHtml}
|
||||||
|
${likesHtml}
|
||||||
|
${commentsHtml}
|
||||||
|
</div></div>`
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>朋友圈导出</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--t1);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||||
|
:root{--bg:#F0EEE9;--card:rgba(255,255,255,.92);--t1:#3d3d3d;--t2:#666;--t3:#999;--accent:#8B7355;--border:rgba(0,0,0,.08);--bg3:rgba(0,0,0,.03)}
|
||||||
|
@media(prefers-color-scheme:dark){:root{--bg:#1a1a1a;--card:rgba(40,40,40,.85);--t1:#e0e0e0;--t2:#aaa;--t3:#777;--accent:#c4a882;--border:rgba(255,255,255,.1);--bg3:rgba(255,255,255,.06)}}
|
||||||
|
.container{max-width:800px;margin:0 auto;padding:20px 24px 60px}
|
||||||
|
|
||||||
|
/* 页面标题 */
|
||||||
|
.feed-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 4px}
|
||||||
|
.feed-hd h2{font-size:20px;font-weight:700}
|
||||||
|
.feed-hd .info{font-size:12px;color:var(--t3)}
|
||||||
|
|
||||||
|
/* 帖子卡片 - 头像+内容双列 */
|
||||||
|
.post{background:var(--card);border-radius:16px;border:1px solid var(--border);padding:20px;margin-bottom:24px;display:flex;gap:16px;box-shadow:0 2px 8px rgba(0,0,0,.02);transition:transform .2s,box-shadow .2s}
|
||||||
|
.post:hover{transform:translateY(-2px);box-shadow:0 8px 16px rgba(0,0,0,.06)}
|
||||||
|
.avatar{width:48px;height:48px;border-radius:12px;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:600;flex-shrink:0;overflow:hidden}
|
||||||
|
.avatar img{width:100%;height:100%;object-fit:cover}
|
||||||
|
.body{flex:1;min-width:0}
|
||||||
|
.hd{display:flex;flex-direction:column;margin-bottom:8px}
|
||||||
|
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
|
||||||
|
.tm{font-size:12px;color:var(--t3)}
|
||||||
|
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
|
||||||
|
|
||||||
|
/* 媒体网格 */
|
||||||
|
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}
|
||||||
|
.grid-1{max-width:300px}
|
||||||
|
.grid-1 .mi{border-radius:12px}
|
||||||
|
.grid-1 .mi img{aspect-ratio:auto;max-height:480px;object-fit:contain;background:var(--bg3)}
|
||||||
|
.grid-2{grid-template-columns:1fr 1fr}
|
||||||
|
.grid-3{grid-template-columns:1fr 1fr 1fr}
|
||||||
|
.mi{overflow:hidden;border-radius:12px;background:var(--bg3);position:relative;aspect-ratio:1}
|
||||||
|
.mi img{width:100%;height:100%;object-fit:cover;display:block;cursor:zoom-in;transition:opacity .2s}
|
||||||
|
.mi img:hover{opacity:.9}
|
||||||
|
.mi video{width:100%;height:100%;object-fit:cover;display:block;background:#000}
|
||||||
|
.ml{display:flex;align-items:center;justify-content:center}
|
||||||
|
.ml a{color:var(--accent);text-decoration:none;font-size:13px}
|
||||||
|
|
||||||
|
/* 链接卡片 */
|
||||||
|
.lk{display:flex;align-items:center;gap:10px;padding:10px;background:var(--bg3);border:1px solid var(--border);border-radius:12px;text-decoration:none;color:var(--t1);font-size:14px;margin-bottom:12px;transition:background .15s}
|
||||||
|
.lk:hover{background:var(--border)}
|
||||||
|
.lk-t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
|
||||||
|
.lk-a{color:var(--t3);font-size:18px;flex-shrink:0}
|
||||||
|
|
||||||
|
/* 互动区域 */
|
||||||
|
.interactions{margin-top:12px;padding-top:12px;border-top:1px dashed var(--border);font-size:13px}
|
||||||
|
.interactions.cmt-border{border-top:none;padding-top:0;margin-top:8px}
|
||||||
|
.likes{color:var(--accent);font-weight:500;line-height:1.8}
|
||||||
|
.cmts{background:var(--bg3);border-radius:8px;padding:8px 12px;line-height:1.4}
|
||||||
|
.cmt{margin-bottom:4px;color:var(--t2)}
|
||||||
|
.cmt:last-child{margin-bottom:0}
|
||||||
|
.cmt b{color:var(--accent);font-weight:500}
|
||||||
|
.re{color:var(--t3);margin:0 4px;font-size:12px}
|
||||||
|
|
||||||
|
/* 灯箱 */
|
||||||
|
.lb{display:none;position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;align-items:center;justify-content:center;cursor:zoom-out}
|
||||||
|
.lb.on{display:flex}
|
||||||
|
.lb img{max-width:92vw;max-height:92vh;object-fit:contain;border-radius:4px}
|
||||||
|
|
||||||
|
/* 回到顶部 */
|
||||||
|
.btt{position:fixed;right:24px;bottom:32px;width:44px;height:44px;border-radius:50%;background:var(--card);box-shadow:0 2px 12px rgba(0,0,0,.12);border:1px solid var(--border);cursor:pointer;font-size:18px;display:none;align-items:center;justify-content:center;z-index:100;color:var(--t2)}
|
||||||
|
.btt:hover{transform:scale(1.1)}
|
||||||
|
.btt.show{display:flex}
|
||||||
|
|
||||||
|
/* 页脚 */
|
||||||
|
.ft{text-align:center;padding:32px 0 24px;font-size:12px;color:var(--t3)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="feed-hd"><h2>朋友圈</h2><span class="info">共 ${posts.length} 条${filterInfo ? ` · ${filterInfo}` : ''}</span></div>
|
||||||
|
${postsHtml}
|
||||||
|
<div class="ft">由 WeFlow 导出 · ${new Date().toLocaleString('zh-CN')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="lb" id="lb" onclick="closeLb()"><img id="lbi" src=""></div>
|
||||||
|
<button class="btt" id="btt" onclick="scrollTo({top:0,behavior:'smooth'})">↑</button>
|
||||||
|
<script>
|
||||||
|
function openLb(s){document.getElementById('lbi').src=s;document.getElementById('lb').classList.add('on');document.body.style.overflow='hidden'}
|
||||||
|
function closeLb(){document.getElementById('lb').classList.remove('on');document.body.style.overflow=''}
|
||||||
|
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeLb()})
|
||||||
|
window.addEventListener('scroll',function(){document.getElementById('btt').classList.toggle('show',window.scrollY>600)})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
||||||
if (!url) return { success: false, error: 'url 不能为空' }
|
if (!url) return { success: false, error: 'url 不能为空' }
|
||||||
|
|
||||||
@@ -321,7 +684,6 @@ class SnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.pipe(fileStream)
|
res.pipe(fileStream)
|
||||||
|
|
||||||
fileStream.on('finish', async () => {
|
fileStream.on('finish', async () => {
|
||||||
fileStream.close()
|
fileStream.close()
|
||||||
|
|
||||||
@@ -381,6 +743,12 @@ class SnsService {
|
|||||||
resolve({ success: false, error: e.message })
|
resolve({ success: false, error: e.message })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
req.setTimeout(15000, () => {
|
||||||
|
req.destroy()
|
||||||
|
fs.unlink(tmpPath, () => { })
|
||||||
|
resolve({ success: false, error: '请求超时' })
|
||||||
|
})
|
||||||
|
|
||||||
req.end()
|
req.end()
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -467,6 +835,10 @@ class SnsService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||||||
|
req.setTimeout(15000, () => {
|
||||||
|
req.destroy()
|
||||||
|
resolve({ success: false, error: '请求超时' })
|
||||||
|
})
|
||||||
req.end()
|
req.end()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
resolve({ success: false, error: e.message })
|
resolve({ success: false, error: e.message })
|
||||||
|
|||||||
185
src/components/Sns/SnsFilterPanel.tsx
Normal file
185
src/components/Sns/SnsFilterPanel.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
|
||||||
|
import { Avatar } from '../Avatar'
|
||||||
|
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsFilterPanelProps {
|
||||||
|
searchKeyword: string
|
||||||
|
setSearchKeyword: (val: string) => void
|
||||||
|
jumpTargetDate?: Date
|
||||||
|
setJumpTargetDate: (date?: Date) => void
|
||||||
|
onOpenJumpDialog: () => void
|
||||||
|
selectedUsernames: string[]
|
||||||
|
setSelectedUsernames: (val: string[]) => void
|
||||||
|
contacts: Contact[]
|
||||||
|
contactSearch: string
|
||||||
|
setContactSearch: (val: string) => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||||
|
searchKeyword,
|
||||||
|
setSearchKeyword,
|
||||||
|
jumpTargetDate,
|
||||||
|
setJumpTargetDate,
|
||||||
|
onOpenJumpDialog,
|
||||||
|
selectedUsernames,
|
||||||
|
setSelectedUsernames,
|
||||||
|
contacts,
|
||||||
|
contactSearch,
|
||||||
|
setContactSearch,
|
||||||
|
loading
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const filteredContacts = contacts.filter(c =>
|
||||||
|
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||||
|
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleUserSelection = (username: string) => {
|
||||||
|
if (selectedUsernames.includes(username)) {
|
||||||
|
setSelectedUsernames(selectedUsernames.filter(u => u !== username))
|
||||||
|
} else {
|
||||||
|
setJumpTargetDate(undefined) // Reset date jump when selecting user
|
||||||
|
setSelectedUsernames([...selectedUsernames, username])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchKeyword('')
|
||||||
|
setSelectedUsernames([])
|
||||||
|
setJumpTargetDate(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sns-filter-panel">
|
||||||
|
<div className="filter-header">
|
||||||
|
<h3>筛选条件</h3>
|
||||||
|
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
|
||||||
|
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-widgets">
|
||||||
|
{/* Search Widget */}
|
||||||
|
<div className="filter-widget search-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<Search size={14} />
|
||||||
|
<span>关键词搜索</span>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索动态内容..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={e => setSearchKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchKeyword && (
|
||||||
|
<button className="clear-input-btn" onClick={() => setSearchKeyword('')}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Widget */}
|
||||||
|
<div className="filter-widget date-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span>时间跳转</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
|
||||||
|
onClick={onOpenJumpDialog}
|
||||||
|
>
|
||||||
|
<span className="date-text">
|
||||||
|
{jumpTargetDate
|
||||||
|
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
|
: '选择日期...'}
|
||||||
|
</span>
|
||||||
|
{jumpTargetDate && (
|
||||||
|
<div
|
||||||
|
className="clear-date-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setJumpTargetDate(undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Widget */}
|
||||||
|
<div className="filter-widget contact-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<User size={14} />
|
||||||
|
<span>联系人</span>
|
||||||
|
{selectedUsernames.length > 0 && (
|
||||||
|
<span className="badge">{selectedUsernames.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="查找好友..."
|
||||||
|
value={contactSearch}
|
||||||
|
onChange={e => setContactSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Search size={14} className="search-icon" />
|
||||||
|
{contactSearch && (
|
||||||
|
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} style={{ right: 8, top: 8, position: 'absolute', cursor: 'pointer', color: 'var(--text-tertiary)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-list-scroll">
|
||||||
|
{filteredContacts.map(contact => (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggleUserSelection(contact.username)}
|
||||||
|
>
|
||||||
|
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||||
|
<span className="contact-name">{contact.displayName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredContacts.length === 0 && (
|
||||||
|
<div className="empty-state">没有找到联系人</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefreshCw({ size, className }: { size?: number, className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || 24}
|
||||||
|
height={size || 24}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M23 4v6h-6"></path>
|
||||||
|
<path d="M1 20v-6h6"></path>
|
||||||
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
327
src/components/Sns/SnsMediaGrid.tsx
Normal file
327
src/components/Sns/SnsMediaGrid.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Play, Lock, Download } from 'lucide-react'
|
||||||
|
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SnsMedia {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
md5?: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
livePhoto?: {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsMediaGridProps {
|
||||||
|
mediaList: SnsMedia[]
|
||||||
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSnsVideoUrl = (url?: string): boolean => {
|
||||||
|
if (!url) return false
|
||||||
|
const lower = url.toLowerCase()
|
||||||
|
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractVideoFrame = async (videoPath: string): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const video = document.createElement('video')
|
||||||
|
video.preload = 'auto'
|
||||||
|
video.src = videoPath
|
||||||
|
video.muted = true
|
||||||
|
video.currentTime = 0 // Initial reset
|
||||||
|
// video.crossOrigin = 'anonymous' // Not needed for file:// usually
|
||||||
|
|
||||||
|
const onSeeked = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
|
||||||
|
resolve(dataUrl)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Canvas context failed'))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
video.removeEventListener('seeked', onSeeked)
|
||||||
|
video.src = ''
|
||||||
|
video.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
if (video.duration === Infinity || isNaN(video.duration)) {
|
||||||
|
// Determine duration failed, try a fixed small offset
|
||||||
|
video.currentTime = 1
|
||||||
|
} else {
|
||||||
|
video.currentTime = Math.max(0.1, video.duration / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video.onseeked = onSeeked
|
||||||
|
|
||||||
|
video.onerror = (e) => {
|
||||||
|
reject(new Error('Video load failed'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [thumbSrc, setThumbSrc] = useState<string>('')
|
||||||
|
const [videoPath, setVideoPath] = useState<string>('')
|
||||||
|
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
|
||||||
|
const [isDecrypting, setIsDecrypting] = useState(false)
|
||||||
|
const [isGeneratingCover, setIsGeneratingCover] = useState(false)
|
||||||
|
|
||||||
|
const isVideo = isSnsVideoUrl(media.url)
|
||||||
|
const isLive = !!media.livePhoto
|
||||||
|
const targetUrl = media.thumb || media.url
|
||||||
|
|
||||||
|
// Simple effect to load image/decrypt
|
||||||
|
// Simple effect to load image/decrypt
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
if (!isVideo) {
|
||||||
|
// For images, we proxy to get the local path/base64
|
||||||
|
const result = await window.electronAPI.sns.proxyImage({
|
||||||
|
url: targetUrl,
|
||||||
|
key: media.key
|
||||||
|
})
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (result.dataUrl) setThumbSrc(result.dataUrl)
|
||||||
|
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
|
||||||
|
} else {
|
||||||
|
setThumbSrc(targetUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-load live photo video if needed
|
||||||
|
if (isLive && media.livePhoto?.url) {
|
||||||
|
window.electronAPI.sns.proxyImage({
|
||||||
|
url: media.livePhoto.url,
|
||||||
|
key: media.livePhoto.key || media.key
|
||||||
|
}).then((res: any) => {
|
||||||
|
if (!cancelled && res.success && res.videoPath) {
|
||||||
|
setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`)
|
||||||
|
}
|
||||||
|
}).catch(() => { })
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
} else {
|
||||||
|
// Video logic: Decrypt -> Extract Frame
|
||||||
|
setIsGeneratingCover(true)
|
||||||
|
|
||||||
|
// First check if we already have it decryptable?
|
||||||
|
// Usually we need to call proxyImage with the video URL to decrypt it to cache
|
||||||
|
const result = await window.electronAPI.sns.proxyImage({
|
||||||
|
url: media.url,
|
||||||
|
key: media.key
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (result.success && result.videoPath) {
|
||||||
|
const localPath = `file://${result.videoPath.replace(/\\/g, '/')}`
|
||||||
|
setVideoPath(localPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const coverDataUrl = await extractVideoFrame(localPath)
|
||||||
|
if (!cancelled) setThumbSrc(coverDataUrl)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Frame extraction failed', err)
|
||||||
|
// Fallback to video path if extraction fails, though it might be black
|
||||||
|
// Only set thumbSrc if extraction fails, so we don't override the generated one
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Video decryption for cover failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingCover(false)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
if (!cancelled) {
|
||||||
|
setThumbSrc(targetUrl)
|
||||||
|
setLoading(false)
|
||||||
|
setIsGeneratingCover(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [media, isVideo, isLive, targetUrl])
|
||||||
|
|
||||||
|
const handlePreview = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isVideo) {
|
||||||
|
// Decrypt video on demand if not already
|
||||||
|
if (!videoPath) {
|
||||||
|
setIsDecrypting(true)
|
||||||
|
try {
|
||||||
|
const res = await window.electronAPI.sns.proxyImage({
|
||||||
|
url: media.url,
|
||||||
|
key: media.key
|
||||||
|
})
|
||||||
|
if (res.success && res.videoPath) {
|
||||||
|
const local = `file://${res.videoPath.replace(/\\/g, '/')}`
|
||||||
|
setVideoPath(local)
|
||||||
|
onPreview(local, true, undefined)
|
||||||
|
} else {
|
||||||
|
alert('视频解密失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsDecrypting(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onPreview(videoPath, true, undefined)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onPreview(thumbSrc || targetUrl, false, liveVideoPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sns.proxyImage({
|
||||||
|
url: media.url,
|
||||||
|
key: media.key
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = `sns_media_${Date.now()}.${isVideo ? 'mp4' : 'jpg'}`
|
||||||
|
|
||||||
|
if (result.dataUrl) {
|
||||||
|
link.href = result.dataUrl
|
||||||
|
} else if (result.videoPath) {
|
||||||
|
// For local video files, we need to fetch as blob to force download behavior
|
||||||
|
// or just use the file protocol url if the browser supports it
|
||||||
|
try {
|
||||||
|
const response = await fetch(`file://${result.videoPath}`)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
link.href = url
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Video fetch failed, falling back to direct link', err)
|
||||||
|
link.href = `file://${result.videoPath}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
} else {
|
||||||
|
alert('下载失败: 无法获取资源')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Download error:', e)
|
||||||
|
alert('下载出错')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
|
||||||
|
onClick={handlePreview}
|
||||||
|
>
|
||||||
|
{(thumbSrc && !thumbSrc.startsWith('data:') && (thumbSrc.toLowerCase().endsWith('.mp4') || thumbSrc.includes('video'))) ? (
|
||||||
|
<video
|
||||||
|
key={thumbSrc}
|
||||||
|
src={`${thumbSrc}#t=0.1`}
|
||||||
|
className="media-image"
|
||||||
|
preload="auto"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
disablePictureInPicture
|
||||||
|
disableRemotePlayback
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
e.currentTarget.currentTime = 0.1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={thumbSrc || targetUrl}
|
||||||
|
className="media-image"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setError(true)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGeneratingCover && (
|
||||||
|
<div className="media-decrypting-mask">
|
||||||
|
<RefreshCw className="spin" size={24} />
|
||||||
|
<span>解密中...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isVideo && (
|
||||||
|
<div className="media-badge video">
|
||||||
|
{/* If we have a cover, show Play. If decrypting for preview, show spin. Generating cover has its own mask. */}
|
||||||
|
{isDecrypting ? <RefreshCw className="spin" size={16} /> : <Play size={16} fill="currentColor" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLive && !isVideo && (
|
||||||
|
<div className="media-badge live">
|
||||||
|
<LivePhotoIcon size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="media-download-btn" onClick={handleDownload} title="下载">
|
||||||
|
<Download size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview }) => {
|
||||||
|
if (!mediaList || mediaList.length === 0) return null
|
||||||
|
|
||||||
|
const count = mediaList.length
|
||||||
|
let gridClass = ''
|
||||||
|
|
||||||
|
if (count === 1) gridClass = 'grid-1'
|
||||||
|
else if (count === 2) gridClass = 'grid-2'
|
||||||
|
else if (count === 3) gridClass = 'grid-3'
|
||||||
|
else if (count === 4) gridClass = 'grid-4' // 2x2
|
||||||
|
else if (count <= 6) gridClass = 'grid-6' // 3 cols
|
||||||
|
else gridClass = 'grid-9' // 3x3
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`sns-media-grid ${gridClass}`}>
|
||||||
|
{mediaList.map((media, idx) => (
|
||||||
|
<MediaItem key={idx} media={media} onPreview={onPreview} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
263
src/components/Sns/SnsPostItem.tsx
Normal file
263
src/components/Sns/SnsPostItem.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal } from 'lucide-react'
|
||||||
|
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||||
|
import { Avatar } from '../Avatar'
|
||||||
|
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||||
|
|
||||||
|
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||||
|
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 isSnsVideoUrl = (url?: string): boolean => {
|
||||||
|
if (!url) return false
|
||||||
|
const lower = url.toLowerCase()
|
||||||
|
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
|
if (hasVideoMedia) return 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('[SnsLinkCard] 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsPostItemProps {
|
||||||
|
post: SnsPost
|
||||||
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
|
onDebug: (post: SnsPost) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
|
||||||
|
const linkCard = buildLinkCardData(post)
|
||||||
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
|
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||||
|
const showMediaGrid = post.media.length > 0 && !showLinkCard
|
||||||
|
|
||||||
|
const formatTime = (ts: number) => {
|
||||||
|
const date = new Date(ts * 1000)
|
||||||
|
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
|
||||||
|
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: isCurrentYear ? undefined : 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extra class for media-only posts (no text) to adjust spacing?
|
||||||
|
// Not strictly needed but good to know
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sns-post-item">
|
||||||
|
<div className="post-avatar-col">
|
||||||
|
<Avatar
|
||||||
|
src={post.avatarUrl}
|
||||||
|
name={post.nickname}
|
||||||
|
size={48}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="post-content-col">
|
||||||
|
<div className="post-header-row">
|
||||||
|
<div className="post-author-info">
|
||||||
|
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||||
|
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDebug(post);
|
||||||
|
}} title="查看原始数据">
|
||||||
|
<Code size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.contentDesc && (
|
||||||
|
<div className="post-text">{decodeHtmlEntities(post.contentDesc)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLinkCard && linkCard && (
|
||||||
|
<SnsLinkCard card={linkCard} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMediaGrid && (
|
||||||
|
<div className="post-media-container">
|
||||||
|
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(post.likes.length > 0 || post.comments.length > 0) && (
|
||||||
|
<div className="post-interactions">
|
||||||
|
{post.likes.length > 0 && (
|
||||||
|
<div className="likes-block">
|
||||||
|
<Heart size={14} className="like-icon" />
|
||||||
|
<span className="likes-text">{post.likes.join('、')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.comments.length > 0 && (
|
||||||
|
<div className="comments-block">
|
||||||
|
{post.comments.map((c, idx) => (
|
||||||
|
<div key={idx} className="comment-row">
|
||||||
|
<span className="comment-user">{c.nickname}</span>
|
||||||
|
{c.refNickname && (
|
||||||
|
<>
|
||||||
|
<span className="reply-text">回复</span>
|
||||||
|
<span className="comment-user">{c.refNickname}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="comment-colon">:</span>
|
||||||
|
<span className="comment-content">{c.content}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
12
src/types/electron.d.ts
vendored
12
src/types/electron.d.ts
vendored
@@ -491,6 +491,18 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
||||||
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
||||||
|
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
|
||||||
|
exportTimeline: (options: {
|
||||||
|
outputDir: string
|
||||||
|
format: 'json' | 'html'
|
||||||
|
usernames?: string[]
|
||||||
|
keyword?: string
|
||||||
|
exportMedia?: boolean
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
|
||||||
|
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||||
|
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||||
}
|
}
|
||||||
llama: {
|
llama: {
|
||||||
loadModel: (modelPath: string) => Promise<boolean>
|
loadModel: (modelPath: string) => Promise<boolean>
|
||||||
|
|||||||
47
src/types/sns.ts
Normal file
47
src/types/sns.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export interface SnsLivePhoto {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsMedia {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
md5?: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
livePhoto?: SnsLivePhoto
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsComment {
|
||||||
|
id: string
|
||||||
|
nickname: string
|
||||||
|
content: string
|
||||||
|
refCommentId: string
|
||||||
|
refNickname?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsPost {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatarUrl?: string
|
||||||
|
createTime: number
|
||||||
|
contentDesc: string
|
||||||
|
type?: number
|
||||||
|
media: SnsMedia[]
|
||||||
|
likes: string[]
|
||||||
|
comments: SnsComment[]
|
||||||
|
rawXml?: string
|
||||||
|
linkTitle?: string
|
||||||
|
linkUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsLinkCardData {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
thumb?: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user