mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
Merge branch 'dev' of https://github.com/xunchahaha/WeFlow into dev
This commit is contained in:
@@ -38,9 +38,10 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
|
<img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
|
||||||
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220">
|
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220" style="margin-right: 16px;">
|
||||||
|
<img src="4wm.jpg" alt="WeFlow 微信交流群二维码(三群)" width="220"
|
||||||
</p>
|
</p>
|
||||||
<p align="center">一群满了加二群</p>
|
<p align="center">扫到哪个算哪个</p>
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,28 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
win.loadFile(join(__dirname, '../dist/index.html'))
|
win.loadFile(join(__dirname, '../dist/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{
|
||||||
|
urls: [
|
||||||
|
'*://*.qpic.cn/*',
|
||||||
|
'*://*.qlogo.cn/*',
|
||||||
|
'*://*.wechat.com/*',
|
||||||
|
'*://*.weixin.qq.com/*'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
(details, callback) => {
|
||||||
|
details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351"
|
||||||
|
details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
||||||
|
details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br"
|
||||||
|
details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9"
|
||||||
|
details.requestHeaders['Referer'] = "https://servicewechat.com/"
|
||||||
|
details.requestHeaders['Connection'] = "keep-alive"
|
||||||
|
details.requestHeaders['Range'] = "bytes=0-"
|
||||||
|
callback({ cancel: false, requestHeaders: details.requestHeaders })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,6 +709,14 @@ function registerIpcHandlers() {
|
|||||||
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||||
|
return snsService.debugResource(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:proxyImage', async (_, url: string) => {
|
||||||
|
return snsService.proxyImage(url)
|
||||||
|
})
|
||||||
|
|
||||||
// 私聊克隆
|
// 私聊克隆
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 朋友圈
|
// 朋友圈
|
||||||
sns: {
|
sns: {
|
||||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||||
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),
|
||||||
|
proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ import { wcdbService } from './wcdbService'
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { ContactCacheService } from './contactCacheService'
|
import { ContactCacheService } from './contactCacheService'
|
||||||
|
|
||||||
|
export interface SnsLivePhoto {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
md5?: 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 SnsPost {
|
export interface SnsPost {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
@@ -10,11 +29,25 @@ export interface SnsPost {
|
|||||||
createTime: number
|
createTime: number
|
||||||
contentDesc: string
|
contentDesc: string
|
||||||
type?: number
|
type?: number
|
||||||
media: { url: string; thumb: string }[]
|
media: SnsMedia[]
|
||||||
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 // 原始 XML 数据
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fixSnsUrl = (url: string, token?: string) => {
|
||||||
|
if (!url) return url;
|
||||||
|
|
||||||
|
// 1. 统一使用 https
|
||||||
|
// 2. 将 /150 (缩略图) 强制改为 /0 (原图)
|
||||||
|
let fixedUrl = url.replace('http://', 'https://').replace(/\/150($|\?)/, '/0$1');
|
||||||
|
|
||||||
|
if (!token || fixedUrl.includes('token=')) return fixedUrl;
|
||||||
|
|
||||||
|
const connector = fixedUrl.includes('?') ? '&' : '?';
|
||||||
|
return `${fixedUrl}${connector}token=${token}&idx=1`;
|
||||||
|
};
|
||||||
|
|
||||||
class SnsService {
|
class SnsService {
|
||||||
private contactCache: ContactCacheService
|
private contactCache: ContactCacheService
|
||||||
|
|
||||||
@@ -35,14 +68,50 @@ class SnsService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.success && result.timeline) {
|
if (result.success && result.timeline) {
|
||||||
const enrichedTimeline = result.timeline.map((post: any) => {
|
const enrichedTimeline = result.timeline.map((post: any, index: number) => {
|
||||||
const contact = this.contactCache.get(post.username)
|
const contact = this.contactCache.get(post.username)
|
||||||
|
|
||||||
// 修复媒体 URL,如果是 http 则尝试用 https (虽然 qpic 可能不支持强制 https,但通常支持)
|
// 修复媒体 URL
|
||||||
const fixedMedia = post.media.map((m: any) => ({
|
const fixedMedia = post.media.map((m: any, mIdx: number) => {
|
||||||
url: m.url.replace('http://', 'https://'),
|
const base = {
|
||||||
thumb: m.thumb.replace('http://', 'https://')
|
url: fixSnsUrl(m.url, m.token),
|
||||||
}))
|
thumb: fixSnsUrl(m.thumb, m.token),
|
||||||
|
md5: m.md5,
|
||||||
|
token: m.token,
|
||||||
|
key: m.key,
|
||||||
|
encIdx: m.encIdx || m.enc_idx, // 兼容不同命名
|
||||||
|
livePhoto: m.livePhoto ? {
|
||||||
|
...m.livePhoto,
|
||||||
|
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token),
|
||||||
|
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token),
|
||||||
|
token: m.livePhoto.token,
|
||||||
|
key: m.livePhoto.key
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// [MOCK] 模拟数据:如果后端没返回 key (说明 DLL 未更新),注入一些 Mock 数据以便前端开发
|
||||||
|
if (!base.key) {
|
||||||
|
base.key = 'mock_key_for_dev'
|
||||||
|
if (!base.token) {
|
||||||
|
base.token = 'mock_token_for_dev'
|
||||||
|
base.url = fixSnsUrl(base.url, base.token)
|
||||||
|
base.thumb = fixSnsUrl(base.thumb, base.token)
|
||||||
|
}
|
||||||
|
base.encIdx = '1'
|
||||||
|
|
||||||
|
// 强制给第一个帖子的第一张图加 LivePhoto 模拟
|
||||||
|
if (index === 0 && mIdx === 0 && !base.livePhoto) {
|
||||||
|
base.livePhoto = {
|
||||||
|
url: fixSnsUrl('https://tm.sh/d4cb0.mp4', 'mock_live_token'),
|
||||||
|
thumb: base.thumb,
|
||||||
|
token: 'mock_live_token',
|
||||||
|
key: 'mock_live_key'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...post,
|
...post,
|
||||||
@@ -59,6 +128,128 @@ class SnsService {
|
|||||||
console.log('[SnsService] Returning result:', result)
|
console.log('[SnsService] Returning result:', result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const { app, net } = require('electron')
|
||||||
|
// Remove mocking 'require' if it causes issues, but here we need 'net' or 'https'
|
||||||
|
// implementing with 'https' for reliability if 'net' is main-process only special
|
||||||
|
const https = require('https')
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: urlObj.hostname,
|
||||||
|
path: urlObj.pathname + urlObj.search,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||||||
|
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||||
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
"Referer": "https://servicewechat.com/",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Range": "bytes=0-10" // Keep our range check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = https.request(options, (res: any) => {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
status: res.statusCode,
|
||||||
|
headers: {
|
||||||
|
'x-enc': res.headers['x-enc'],
|
||||||
|
'content-length': res.headers['content-length'],
|
||||||
|
'content-type': res.headers['content-type']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.destroy() // We only need headers
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('error', (e: any) => {
|
||||||
|
resolve({ success: false, error: e.message })
|
||||||
|
})
|
||||||
|
|
||||||
|
req.end()
|
||||||
|
} catch (e: any) {
|
||||||
|
resolve({ success: false, error: e.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private imageCache = new Map<string, string>()
|
||||||
|
|
||||||
|
async proxyImage(url: string): Promise<{ success: boolean; dataUrl?: string; error?: string }> {
|
||||||
|
// Check cache
|
||||||
|
if (this.imageCache.has(url)) {
|
||||||
|
return { success: true, dataUrl: this.imageCache.get(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const https = require('https')
|
||||||
|
const zlib = require('zlib')
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: urlObj.hostname,
|
||||||
|
path: urlObj.pathname + urlObj.search,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||||||
|
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||||
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
"Referer": "https://servicewechat.com/",
|
||||||
|
"Connection": "keep-alive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = https.request(options, (res: any) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
resolve({ success: false, error: `HTTP ${res.statusCode}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
let stream = res
|
||||||
|
|
||||||
|
// Handle gzip compression
|
||||||
|
const encoding = res.headers['content-encoding']
|
||||||
|
if (encoding === 'gzip') {
|
||||||
|
stream = res.pipe(zlib.createGunzip())
|
||||||
|
} else if (encoding === 'deflate') {
|
||||||
|
stream = res.pipe(zlib.createInflate())
|
||||||
|
} else if (encoding === 'br') {
|
||||||
|
stream = res.pipe(zlib.createBrotliDecompress())
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
stream.on('end', () => {
|
||||||
|
const buffer = Buffer.concat(chunks)
|
||||||
|
const contentType = res.headers['content-type'] || 'image/jpeg'
|
||||||
|
const base64 = buffer.toString('base64')
|
||||||
|
const dataUrl = `data:${contentType};base64,${base64}`
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
this.imageCache.set(url, dataUrl)
|
||||||
|
|
||||||
|
resolve({ success: true, dataUrl })
|
||||||
|
})
|
||||||
|
stream.on('error', (e: any) => {
|
||||||
|
resolve({ success: false, error: e.message })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('error', (e: any) => {
|
||||||
|
resolve({ success: false, error: e.message })
|
||||||
|
})
|
||||||
|
|
||||||
|
req.end()
|
||||||
|
} catch (e: any) {
|
||||||
|
resolve({ success: false, error: e.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const snsService = new SnsService()
|
export const snsService = new SnsService()
|
||||||
|
|||||||
Binary file not shown.
@@ -739,6 +739,59 @@
|
|||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
transform: scale(1.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.download-btn-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.media-error-placeholder {
|
.media-error-placeholder {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -938,3 +991,196 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug Dialog Styles
|
||||||
|
.debug-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
.debug-dialog-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-dialog-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.debug-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.debug-key {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 140px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-value {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace;
|
||||||
|
user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-debug-item {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.media-debug-header {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-photo-debug {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--border-color);
|
||||||
|
|
||||||
|
.live-photo-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent-color);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
user-select: all;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-json-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react'
|
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download } from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import { ImagePreview } from '../components/ImagePreview'
|
import { ImagePreview } from '../components/ImagePreview'
|
||||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||||
@@ -13,29 +13,65 @@ interface SnsPost {
|
|||||||
createTime: number
|
createTime: number
|
||||||
contentDesc: string
|
contentDesc: string
|
||||||
type?: number
|
type?: number
|
||||||
media: { url: string; thumb: string }[]
|
media: {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
md5?: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
livePhoto?: {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
}
|
||||||
|
}[]
|
||||||
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 // 原始 XML 数据
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
|
const MediaItem = ({ media, onPreview }: { media: any, onPreview: () => void }) => {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const { url, thumb, livePhoto } = media;
|
||||||
|
const isLive = !!livePhoto;
|
||||||
|
const targetUrl = thumb || url;
|
||||||
|
|
||||||
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
let downloadUrl = url;
|
||||||
|
let downloadKey = media.key || '';
|
||||||
|
|
||||||
|
if (isLive && media.livePhoto) {
|
||||||
|
downloadUrl = media.livePhoto.url;
|
||||||
|
downloadKey = media.livePhoto.key || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 调用后端下载服务
|
||||||
|
// window.electronAPI.sns.download(downloadUrl, downloadKey);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`media-item ${error ? 'error' : ''}`}>
|
<div className={`media-item ${error ? 'error' : ''}`} onClick={onPreview}>
|
||||||
{!error ? (
|
<img
|
||||||
<img
|
src={targetUrl}
|
||||||
src={thumb || url}
|
alt=""
|
||||||
alt=""
|
referrerPolicy="no-referrer"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onClick={onPreview}
|
onError={() => setError(true)}
|
||||||
onError={() => setError(true)}
|
/>
|
||||||
/>
|
{isLive && (
|
||||||
) : (
|
<div className="live-badge">
|
||||||
<div className="media-error-placeholder" onClick={onPreview}>
|
<Zap size={10} fill="currentColor" />
|
||||||
<ImageIcon size={24} style={{ opacity: 0.3 }} />
|
<span>LIVE</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button className="download-btn-overlay" onClick={handleDownload} title="下载原图">
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -65,6 +101,7 @@ export default function SnsPage() {
|
|||||||
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||||
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
|
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||||
|
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
|
||||||
|
|
||||||
const postsContainerRef = useRef<HTMLDivElement>(null)
|
const postsContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -264,7 +301,7 @@ export default function SnsPage() {
|
|||||||
setHasNewer(false)
|
setHasNewer(false)
|
||||||
setSelectedUsernames([])
|
setSelectedUsernames([])
|
||||||
setSearchKeyword('')
|
setSearchKeyword('')
|
||||||
setJumpTargetDate(null)
|
setJumpTargetDate(undefined)
|
||||||
loadContacts()
|
loadContacts()
|
||||||
loadPosts({ reset: true })
|
loadPosts({ reset: true })
|
||||||
}
|
}
|
||||||
@@ -515,6 +552,19 @@ export default function SnsPage() {
|
|||||||
<div className="nickname">{post.nickname}</div>
|
<div className="nickname">{post.nickname}</div>
|
||||||
<div className="time">{formatTime(post.createTime)}</div>
|
<div className="time">{formatTime(post.createTime)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="debug-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDebugPost(post);
|
||||||
|
}}
|
||||||
|
title="查看原始数据"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="16 18 22 12 16 6"></polyline>
|
||||||
|
<polyline points="8 6 2 12 8 18"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="post-body">
|
<div className="post-body">
|
||||||
@@ -528,7 +578,7 @@ export default function SnsPage() {
|
|||||||
) : post.media.length > 0 && (
|
) : post.media.length > 0 && (
|
||||||
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
||||||
{post.media.map((m, idx) => (
|
{post.media.map((m, idx) => (
|
||||||
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
|
<MediaItem key={idx} media={m} onPreview={() => setPreviewImage(m.url)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -605,6 +655,154 @@ export default function SnsPage() {
|
|||||||
}}
|
}}
|
||||||
currentDate={jumpTargetDate || new Date()}
|
currentDate={jumpTargetDate || new Date()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Debug Info Dialog */}
|
||||||
|
{debugPost && (
|
||||||
|
<div className="modal-overlay" onClick={() => setDebugPost(null)}>
|
||||||
|
<div className="debug-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="debug-dialog-header">
|
||||||
|
<h3>原始数据 - {debugPost.nickname}</h3>
|
||||||
|
<button className="close-btn" onClick={() => setDebugPost(null)}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="debug-dialog-body">
|
||||||
|
|
||||||
|
<div className="debug-section">
|
||||||
|
<h4>ℹ 基本信息</h4>
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">ID:</span>
|
||||||
|
<span className="debug-value">{debugPost.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">用户名:</span>
|
||||||
|
<span className="debug-value">{debugPost.username}</span>
|
||||||
|
</div>
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">昵称:</span>
|
||||||
|
<span className="debug-value">{debugPost.nickname}</span>
|
||||||
|
</div>
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">时间:</span>
|
||||||
|
<span className="debug-value">{new Date(debugPost.createTime * 1000).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">类型:</span>
|
||||||
|
<span className="debug-value">{debugPost.type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-section">
|
||||||
|
<h4> 媒体信息 ({debugPost.media.length} 项)</h4>
|
||||||
|
{debugPost.media.map((media, idx) => (
|
||||||
|
<div key={idx} className="media-debug-item">
|
||||||
|
<div className="media-debug-header">媒体 {idx + 1}</div>
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">URL:</span>
|
||||||
|
<span className="debug-value">{media.url}</span>
|
||||||
|
</div>
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">缩略图:</span>
|
||||||
|
<span className="debug-value">{media.thumb}</span>
|
||||||
|
</div>
|
||||||
|
{media.md5 && (
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">MD5:</span>
|
||||||
|
<span className="debug-value">{media.md5}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{media.token && (
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">Token:</span>
|
||||||
|
<span className="debug-value">{media.token}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{media.key && (
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">Key (解密密钥):</span>
|
||||||
|
<span className="debug-value">{media.key}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{media.encIdx && (
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">Enc Index:</span>
|
||||||
|
<span className="debug-value">{media.encIdx}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{media.livePhoto && (
|
||||||
|
<div className="live-photo-debug">
|
||||||
|
<div className="live-photo-label"> Live Photo 视频部分:</div>
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">视频 URL:</span>
|
||||||
|
<span className="debug-value">{media.livePhoto.url}</span>
|
||||||
|
</div>
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">视频缩略图:</span>
|
||||||
|
<span className="debug-value">{media.livePhoto.thumb}</span>
|
||||||
|
</div>
|
||||||
|
{media.livePhoto.token && (
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">视频 Token:</span>
|
||||||
|
<span className="debug-value">{media.livePhoto.token}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{media.livePhoto.key && (
|
||||||
|
<div className="debug-item">
|
||||||
|
<span className="debug-key">视频 Key:</span>
|
||||||
|
<span className="debug-value">{media.livePhoto.key}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 原始 XML */}
|
||||||
|
{debugPost.rawXml && (
|
||||||
|
<div className="debug-section">
|
||||||
|
<h4> 原始 XML 数据</h4>
|
||||||
|
<pre className="json-code">{(() => {
|
||||||
|
// XML 缩进格式化
|
||||||
|
let formatted = '';
|
||||||
|
let indent = 0;
|
||||||
|
const tab = ' ';
|
||||||
|
const parts = debugPost.rawXml.split(/(<[^>]+>)/g).filter(p => p.trim());
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part.startsWith('<')) {
|
||||||
|
if (part.trim()) formatted += part;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.startsWith('</')) {
|
||||||
|
indent = Math.max(0, indent - 1);
|
||||||
|
formatted += '\n' + tab.repeat(indent) + part;
|
||||||
|
} else if (part.endsWith('/>')) {
|
||||||
|
formatted += '\n' + tab.repeat(indent) + part;
|
||||||
|
} else {
|
||||||
|
formatted += '\n' + tab.repeat(indent) + part;
|
||||||
|
indent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted.trim();
|
||||||
|
})()}</pre>
|
||||||
|
<button
|
||||||
|
className="copy-json-btn"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(debugPost.rawXml || '');
|
||||||
|
alert('已复制 XML 到剪贴板');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制 XML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -349,6 +349,8 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
||||||
|
proxyImage: (url: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user