diff --git a/.gitignore b/.gitignore index c424a77..d1425df 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,5 @@ wcdb/ 概述.md chatlab-format.md *.bak -AGENTS.md \ No newline at end of file +AGENTS.md +.claude/ \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index 09fd39a..81186a2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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] } + }) + // 私聊克隆 diff --git a/electron/preload.ts b/electron/preload.ts index d25b65f..3adee1c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -278,7 +278,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), 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 diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index f4b7fff..0b468ca 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -38,6 +38,8 @@ export interface SnsPost { likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] rawXml?: string + linkTitle?: string + linkUrl?: string } @@ -266,6 +268,367 @@ class SnsService { 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() + 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 { + const escapeHtml = (str: string) => str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/\n/g, '
') + + 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 `
` + } + return `
` + } + return `` + }).join('') + + const linkHtml = post.linkTitle && post.linkUrl + ? `${escapeHtml(post.linkTitle)}` + : '' + + const likesHtml = post.likes.length > 0 + ? `
` + : '' + + const commentsHtml = post.comments.length > 0 + ? `
${post.comments.map(c => { + const ref = c.refNickname ? `回复${escapeHtml(c.refNickname)}` : '' + return `
${escapeHtml(c.nickname)}${ref}:${escapeHtml(c.content)}
` + }).join('')}
` + : '' + + const avatarSrc = avatarMap?.get(post.username) + const avatarHtml = avatarSrc + ? `
` + : `
${avatarLetter(post.nickname)}
` + + return `
+${avatarHtml} +
+
${escapeHtml(post.nickname)}${formatTime(post.createTime)}
+${post.contentDesc ? `
${escapeHtml(post.contentDesc)}
` : ''} +${mediaHtml ? `
${mediaHtml}
` : ''} +${linkHtml} +${likesHtml} +${commentsHtml} +
` + }).join('\n') + + return ` + + + + +朋友圈导出 + + + +
+

朋友圈

共 ${posts.length} 条${filterInfo ? ` · ${filterInfo}` : ''}
+ ${postsHtml} +
由 WeFlow 导出 · ${new Date().toLocaleString('zh-CN')}
+
+
+ + + +` + } + 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 不能为空' } @@ -321,7 +684,6 @@ class SnsService { } res.pipe(fileStream) - fileStream.on('finish', async () => { fileStream.close() @@ -381,6 +743,12 @@ class SnsService { resolve({ success: false, error: e.message }) }) + req.setTimeout(15000, () => { + req.destroy() + fs.unlink(tmpPath, () => { }) + resolve({ success: false, error: '请求超时' }) + }) + req.end() } catch (e: any) { @@ -467,6 +835,10 @@ class SnsService { }) req.on('error', (e: any) => resolve({ success: false, error: e.message })) + req.setTimeout(15000, () => { + req.destroy() + resolve({ success: false, error: '请求超时' }) + }) req.end() } catch (e: any) { resolve({ success: false, error: e.message }) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index a18fba0..a364791 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -54,6 +54,12 @@ color: var(--text-primary); } + .header-actions { + display: flex; + align-items: center; + gap: 10px; + } + .icon-btn { background: var(--bg-tertiary); border: 1px solid var(--border-color); @@ -69,8 +75,14 @@ transform: scale(1.05); } - &.spinning { - animation: spin 1s linear infinite; + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + .spinning { + animation: spin 0.8s linear infinite; } } } @@ -746,6 +758,53 @@ } } +/* Initial Loading Animation */ +.initial-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 120px 0; + + .loading-pulse { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + + .pulse-circle { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--primary, #576b95); + opacity: 0.25; + animation: pulse-ring 1.4s ease-in-out infinite; + } + + span { + font-size: 14px; + color: var(--text-tertiary); + letter-spacing: 0.5px; + } + } +} + +@keyframes pulse-ring { + 0% { + transform: scale(0.6); + opacity: 0.15; + } + + 50% { + transform: scale(1.0); + opacity: 0.35; + } + + 100% { + transform: scale(0.6); + opacity: 0.15; + } +} + .no-results { display: flex; flex-direction: column; @@ -872,4 +931,748 @@ opacity: 1; transform: translateY(0); } +} + +/* ========================================= + Export Dialog + ========================================= */ +.export-dialog { + background: rgba(255, 255, 255, 0.88); + border-radius: var(--sns-border-radius-lg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); + width: 480px; + max-width: 92vw; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + overflow: hidden; + animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + .export-dialog-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: space-between; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + + &:hover { + background: rgba(0, 0, 0, 0.05); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + } + + .export-dialog-body { + padding: 20px; + display: flex; + flex-direction: column; + gap: 18px; + } +} + +.export-filter-info { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 14px; + background: var(--bg-tertiary); + border-radius: 8px; + border: 1px solid var(--border-color); + + .filter-badge { + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--primary, #576b95); + padding: 2px 8px; + border-radius: 10px; + } + + .filter-tag { + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-primary); + padding: 2px 10px; + border-radius: 6px; + border: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 4px; + + svg { + flex-shrink: 0; + } + + .sync-hint { + font-size: 11px; + color: var(--text-tertiary); + font-style: italic; + } + } +} + +.export-section { + display: flex; + flex-direction: column; + gap: 8px; +} + + +.export-format-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + + .format-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 14px 10px; + border-radius: 10px; + border: 2px solid var(--border-color); + background: var(--bg-primary); + cursor: pointer; + transition: all 0.2s; + + span { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + small { + font-size: 11px; + color: var(--text-tertiary); + } + + svg { + color: var(--text-tertiary); + } + + &:hover:not(:disabled) { + border-color: var(--primary, #576b95); + background: var(--bg-tertiary); + } + + &.active { + border-color: var(--primary, #576b95); + background: rgba(87, 107, 149, 0.08); + + svg { + color: var(--primary, #576b95); + } + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.export-path-row { + display: flex; + gap: 8px; + + .export-path-input { + flex: 1; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + outline: none; + cursor: default; + + &::placeholder { + color: var(--text-tertiary); + } + } + + .export-browse-btn { + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: var(--primary, #576b95); + color: #fff; + border-color: var(--primary, #576b95); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.export-date-row { + display: flex; + align-items: center; + gap: 8px; + + .date-picker-trigger { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + min-height: 36px; + + &:hover { + border-color: var(--primary, #576b95); + } + + &>svg:first-child { + color: var(--text-tertiary); + flex-shrink: 0; + } + + .placeholder { + color: var(--text-tertiary); + } + + .clear-date { + margin-left: auto; + color: var(--text-tertiary); + cursor: pointer; + border-radius: 50%; + padding: 1px; + + &:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + } + } + } + + .date-separator { + font-size: 13px; + color: var(--text-tertiary); + flex-shrink: 0; + } +} + +.calendar-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2100; + animation: fadeIn 0.2s ease-out; +} + +.calendar-modal { + background: var(--card-bg); + width: 340px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + .calendar-header { + padding: 18px 20px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-color); + + .title-area { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-primary); + + svg { + color: var(--primary); + } + + h3 { + font-size: 16px; + font-weight: 600; + margin: 0; + } + } + + .close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + .calendar-view { + padding: 20px; + + .calendar-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + .current-month { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .nav-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + color: var(--primary); + } + } + } + + .calendar-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 8px; + + .weekday { + text-align: center; + font-size: 12px; + font-weight: 500; + color: var(--text-tertiary); + padding: 4px 0; + } + } + + .calendar-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: repeat(6, 36px); + gap: 4px; + + .day-cell { + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--text-primary); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + position: relative; + + &.empty { + cursor: default; + } + + &:not(.empty):hover { + background: var(--bg-hover); + } + + &.selected { + background: var(--primary); + color: #fff; + font-weight: 600; + } + + &.today:not(.selected) { + color: var(--primary); + font-weight: 600; + background: var(--primary-light); + } + } + } + } + + .quick-options { + display: flex; + gap: 8px; + padding: 0 20px 16px; + + button { + flex: 1; + padding: 8px; + font-size: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--primary); + border-color: var(--primary); + } + } + } + + .dialog-footer { + padding: 16px 20px; + display: flex; + gap: 12px; + background: var(--bg-secondary); + + button { + flex: 1; + padding: 10px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .cancel-btn { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes modalSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.export-label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 4px; + + svg { + flex-shrink: 0; + } +} + +.export-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + + .toggle-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--text-primary); + + svg { + color: var(--text-tertiary); + } + } + + .toggle-switch { + width: 44px; + height: 24px; + border-radius: 12px; + border: none; + background: var(--bg-tertiary, #555); + cursor: pointer; + position: relative; + transition: background 0.25s; + padding: 0; + flex-shrink: 0; + + &.active { + background: var(--primary, #576b95); + } + + .toggle-knob { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + transition: transform 0.25s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + + &.active .toggle-knob { + transform: translateX(20px); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.export-media-hint { + font-size: 12px; + color: var(--text-tertiary); + margin: 0; + padding-left: 24px; + line-height: 1.4; +} + +.export-progress { + display: flex; + flex-direction: column; + gap: 6px; + + .export-progress-bar { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; + + .export-progress-fill { + height: 100%; + background: var(--primary, #576b95); + border-radius: 3px; + transition: width 0.3s ease; + } + } + + .export-progress-text { + font-size: 12px; + color: var(--text-tertiary); + text-align: center; + } +} + +.export-result { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-align: center; + padding: 10px 0; + + .export-result-icon { + &.success svg { + color: #52c41a; + } + + &.error svg { + color: #ff4d4f; + } + } + + h4 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + } + + p { + margin: 0; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; + + &.error-text { + color: #ff4d4f; + word-break: break-all; + } + } + + .export-result-actions { + display: flex; + gap: 10px; + margin-top: 8px; + } + + .export-open-btn, + .export-done-btn { + padding: 8px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.15s; + } + + .export-open-btn { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + + &:hover { + background: var(--bg-primary); + } + } + + .export-done-btn { + background: var(--primary, #576b95); + color: #fff; + + &:hover { + filter: brightness(1.1); + } + } +} + +.export-sync-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 8px; + margin: 8px 0; + color: var(--text-tertiary); + font-size: 12px; + border: 1px dashed var(--border-color); + + svg { + color: var(--primary); + flex-shrink: 0; + } +} + +.export-actions { + display: flex; + gap: 12px; + margin-top: 24px; + + button { + flex: 1; + height: 40px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + + .export-cancel-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + + &:hover { + background: var(--hover-bg); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .export-start-btn { + background: var(--primary, #576b95); + border: none; + color: #fff; + box-shadow: 0 4px 12px rgba(87, 107, 149, 0.2); + + &:hover:not(:disabled) { + filter: brightness(1.1); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(87, 107, 149, 0.3); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + background: var(--bg-tertiary); + color: var(--text-tertiary); + box-shadow: none; + cursor: not-allowed; + } + } } \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 9853ede..ae30e01 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef, useCallback } from 'react' -import { RefreshCw, Search, X, Download, FolderOpen } from 'lucide-react' +import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' import './SnsPage.scss' @@ -34,6 +34,18 @@ export default function SnsPage() { const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null) const [debugPost, setDebugPost] = useState(null) + // 导出相关状态 + const [showExportDialog, setShowExportDialog] = useState(false) + const [exportFormat, setExportFormat] = useState<'json' | 'html'>('html') + const [exportFolder, setExportFolder] = useState('') + const [exportMedia, setExportMedia] = useState(false) + const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' }) + const [isExporting, setIsExporting] = useState(false) + const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null) + const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null) + const [refreshSpin, setRefreshSpin] = useState(false) + const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null) + const postsContainerRef = useRef(null) const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) @@ -257,12 +269,28 @@ export default function SnsPage() {

朋友圈

+
@@ -291,10 +319,21 @@ export default function SnsPage() { ))} - {loading &&
- - 正在加载更多... -
} + {loading && posts.length === 0 && ( +
+
+
+ 正在加载朋友圈... +
+
+ )} + + {loading && posts.length > 0 && ( +
+ + 正在加载更多... +
+ )} {!hasMore && posts.length > 0 && (
已经到底啦
@@ -367,6 +406,338 @@ export default function SnsPage() { )} + + {/* 导出对话框 */} + {showExportDialog && ( +
!isExporting && setShowExportDialog(false)}> +
e.stopPropagation()}> +
+

导出朋友圈

+ +
+ +
+ {/* 筛选条件提示 */} + {(selectedUsernames.length > 0 || searchKeyword) && ( +
+ 筛选导出 + {searchKeyword && 关键词: "{searchKeyword}"} + {selectedUsernames.length > 0 && ( + + + {selectedUsernames.length} 个联系人 + (同步自侧栏筛选) + + )} +
+ )} + + {!exportResult ? ( + <> + {/* 格式选择 */} +
+ +
+ + +
+
+ + {/* 输出路径 */} +
+ +
+ + +
+
+ + {/* 时间范围 */} +
+ +
+
{ + if (!isExporting) setCalendarPicker(prev => prev?.field === 'start' ? null : { field: 'start', month: exportDateRange.start ? new Date(exportDateRange.start) : new Date() }) + }}> + + + {exportDateRange.start || '开始日期'} + + {exportDateRange.start && ( + { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, start: '' })) }} /> + )} +
+ +
{ + if (!isExporting) setCalendarPicker(prev => prev?.field === 'end' ? null : { field: 'end', month: exportDateRange.end ? new Date(exportDateRange.end) : new Date() }) + }}> + + + {exportDateRange.end || '结束日期'} + + {exportDateRange.end && ( + { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, end: '' })) }} /> + )} +
+
+
+ + {/* 媒体导出 */} +
+
+
+ + 导出媒体文件(图片/视频) +
+ +
+ {exportMedia && ( +

媒体文件将保存到输出目录的 media 子目录中,可能需要较长时间

+ )} +
+ + {/* 同步提示 */} +
+ + 将同步主页面的联系人范围筛选及关键词搜索 +
+ + {/* 进度条 */} + {isExporting && exportProgress && ( +
+
+
0 ? `${Math.round((exportProgress.current / exportProgress.total) * 100)}%` : '100%' }} + /> +
+ {exportProgress.status} +
+ )} + + {/* 操作按钮 */} +
+ + +
+ + ) : ( + /* 导出结果 */ +
+ {exportResult.success ? ( + <> +
+ +
+

导出成功

+

共导出 {exportResult.postCount} 条动态{exportResult.mediaCount ? `,${exportResult.mediaCount} 个媒体文件` : ''}

+
+ + +
+ + ) : ( + <> +
+ +
+

导出失败

+

{exportResult.error}

+ + + )} +
+ )} +
+
+
+ )} + + {/* 日期选择弹窗 */} + {calendarPicker && ( +
setCalendarPicker(null)}> +
e.stopPropagation()}> +
+
+ +

选择{calendarPicker.field === 'start' ? '开始' : '结束'}日期

+
+ +
+
+
+ + + {calendarPicker.month.getFullYear()}年{calendarPicker.month.getMonth() + 1}月 + + +
+
+ {['日', '一', '二', '三', '四', '五', '六'].map(d =>
{d}
)} +
+
+ {(() => { + const y = calendarPicker.month.getFullYear() + const m = calendarPicker.month.getMonth() + const firstDay = new Date(y, m, 1).getDay() + const daysInMonth = new Date(y, m + 1, 0).getDate() + const cells: (number | null)[] = [] + for (let i = 0; i < firstDay; i++) cells.push(null) + for (let i = 1; i <= daysInMonth; i++) cells.push(i) + const today = new Date() + return cells.map((day, i) => { + if (day === null) return
+ const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` + const isToday = day === today.getDate() && m === today.getMonth() && y === today.getFullYear() + const currentVal = calendarPicker.field === 'start' ? exportDateRange.start : exportDateRange.end + const isSelected = dateStr === currentVal + return ( +
{ + setExportDateRange(prev => ({ ...prev, [calendarPicker.field]: dateStr })) + setCalendarPicker(null) + }} + >{day}
+ ) + }) + })()} +
+
+
+ + +
+
+ +
+
+
+ )}
) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 6c4136c..2863202 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -491,6 +491,18 @@ export interface ElectronAPI { }> 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 }> + 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: { loadModel: (modelPath: string) => Promise