diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbe70d8..9482455 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,13 +39,23 @@ jobs: npx tsc npx vite build - - name: Inject Configuration - shell: bash - run: | - npm pkg set build.releaseInfo.releaseNotes=$'仅适配微信 4.0 及以上版本\n\n修复了一些已知问题\n\n详情前往 Telegram 群查看' - - name: Package and Publish env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --publish always \ No newline at end of file + npx electron-builder --publish always + + - name: Update Release Notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + cat < release_notes.md + ## 更新日志 + 修复了一些已知问题 + + ## 加入我们的群 + [点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl) + EOF + + gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md \ No newline at end of file diff --git a/3wm.png b/3wm.png new file mode 100644 index 0000000..9761fd8 Binary files /dev/null and b/3wm.png differ diff --git a/README.md b/README.md index 6e190f9..02185fb 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,22 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析

+ > [!TIP] > 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/) +> [!TIP] +> 仅支持微信 **4.0** 及以上版本 + # 加入微信交流群 > 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。

- WeFlow 微信交流群二维码 + WeFlow 微信交流群二维码(一群) + WeFlow 微信交流群二维码(二群)

+

一群满了加二群

## 主要功能 diff --git a/electron/main.ts b/electron/main.ts index c59b487..95a06c4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -673,6 +673,10 @@ function registerIpcHandlers() { return chatService.getMessageById(sessionId, localId) }) + ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => { + return chatService.execQuery(kind, path, sql) + }) + ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) }) diff --git a/electron/preload.ts b/electron/preload.ts index 6b02eba..6fa3c36 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -118,7 +118,9 @@ contextBridge.exposeInMainWorld('electronAPI', { const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) ipcRenderer.on('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) - } + }, + execQuery: (kind: string, path: string | null, sql: string) => + ipcRenderer.invoke('chat:execQuery', kind, path, sql) }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 66f6795..2c3c143 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -3384,6 +3384,19 @@ class ChatService { } return parsed } + + async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + return wcdbService.execQuery(kind, path, sql) + } catch (e) { + console.error('ChatService: 执行自定义查询失败:', e) + return { success: false, error: String(e) } + } + } } export const chatService = new ChatService() diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css new file mode 100644 index 0000000..53698d2 --- /dev/null +++ b/electron/services/exportHtml.css @@ -0,0 +1,301 @@ +:root { + color-scheme: light; + --bg: #f6f7fb; + --card: #ffffff; + --text: #1f2a37; + --muted: #6b7280; + --accent: #4f46e5; + --sent: #dbeafe; + --received: #ffffff; + --border: #e5e7eb; + --shadow: 0 12px 30px rgba(15, 23, 42, 0.08); + --radius: 16px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); +} + +.page { + max-width: 1080px; + margin: 32px auto 60px; + padding: 0 20px; +} + +.header { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 24px; + margin-bottom: 24px; +} + +.title { + font-size: 24px; + font-weight: 600; + margin: 0 0 8px; +} + +.meta { + color: var(--muted); + font-size: 14px; + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 20px; +} + +.control { + display: flex; + flex-direction: column; + gap: 6px; +} + +.control label { + font-size: 13px; + color: var(--muted); +} + +.control input, +.control select, +.control button { + border-radius: 12px; + border: 1px solid var(--border); + padding: 10px 12px; + font-size: 14px; + font-family: inherit; +} + +.control button { + background: var(--accent); + color: #fff; + border: none; + cursor: pointer; + transition: transform 0.1s ease; +} + +.control button:active { + transform: scale(0.98); +} + +.stats { + font-size: 13px; + color: var(--muted); + display: flex; + align-items: flex-end; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 18px; +} + +.message { + display: flex; + flex-direction: column; + gap: 8px; +} + +.message.hidden { + display: none; +} + +.message-time { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.message-row { + display: flex; + gap: 12px; + align-items: flex-end; +} + +.message.sent .message-row { + flex-direction: row-reverse; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 12px; + background: #eef2ff; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + color: #475569; + font-weight: 600; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.bubble { + max-width: min(70%, 720px); + background: var(--received); + border-radius: 18px; + padding: 12px 14px; + border: 1px solid var(--border); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); +} + +.message.sent .bubble { + background: var(--sent); + border-color: transparent; +} + +.sender-name { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.message-content { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 14px; + line-height: 1.6; +} + +.message-text { + word-break: break-word; +} + +.inline-emoji { + width: 22px; + height: 22px; + vertical-align: text-bottom; + margin: 0 2px; +} + +.message-media { + border-radius: 14px; + max-width: 100%; +} + +.previewable { + cursor: zoom-in; +} + +.message-media.image, +.message-media.emoji { + max-height: 260px; + object-fit: contain; + background: #f1f5f9; + padding: 6px; +} + +.message-media.emoji { + max-height: 160px; + width: auto; +} + +.message-media.video { + max-height: 360px; + background: #111827; +} + +.message-media.audio { + width: 260px; +} + +.image-preview { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.7); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 999; +} + +.image-preview.active { + opacity: 1; + pointer-events: auto; +} + +.image-preview img { + max-width: min(90vw, 1200px); + max-height: 90vh; + border-radius: 18px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35); + background: #0f172a; + transition: transform 0.1s ease; + cursor: zoom-out; +} + +body[data-theme="cloud-dancer"] { + --accent: #6b8cff; + --sent: #e0e7ff; + --received: #ffffff; + --border: #d8e0f7; + --bg: #f6f7fb; +} + +body[data-theme="corundum-blue"] { + --accent: #2563eb; + --sent: #dbeafe; + --received: #ffffff; + --border: #c7d2fe; + --bg: #eef2ff; +} + +body[data-theme="kiwi-green"] { + --accent: #16a34a; + --sent: #dcfce7; + --received: #ffffff; + --border: #bbf7d0; + --bg: #f0fdf4; +} + +body[data-theme="spicy-red"] { + --accent: #e11d48; + --sent: #ffe4e6; + --received: #ffffff; + --border: #fecdd3; + --bg: #fff1f2; +} + +body[data-theme="teal-water"] { + --accent: #0f766e; + --sent: #ccfbf1; + --received: #ffffff; + --border: #99f6e4; + --bg: #f0fdfa; +} + +.highlight { + outline: 2px solid var(--accent); + outline-offset: 4px; + border-radius: 18px; +} + +.empty { + text-align: center; + color: var(--muted); + padding: 40px; +} diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts new file mode 100644 index 0000000..adb3e61 --- /dev/null +++ b/electron/services/exportHtmlStyles.ts @@ -0,0 +1,302 @@ +export const EXPORT_HTML_STYLES = `:root { + color-scheme: light; + --bg: #f6f7fb; + --card: #ffffff; + --text: #1f2a37; + --muted: #6b7280; + --accent: #4f46e5; + --sent: #dbeafe; + --received: #ffffff; + --border: #e5e7eb; + --shadow: 0 12px 30px rgba(15, 23, 42, 0.08); + --radius: 16px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); +} + +.page { + max-width: 1080px; + margin: 32px auto 60px; + padding: 0 20px; +} + +.header { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 24px; + margin-bottom: 24px; +} + +.title { + font-size: 24px; + font-weight: 600; + margin: 0 0 8px; +} + +.meta { + color: var(--muted); + font-size: 14px; + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 20px; +} + +.control { + display: flex; + flex-direction: column; + gap: 6px; +} + +.control label { + font-size: 13px; + color: var(--muted); +} + +.control input, +.control select, +.control button { + border-radius: 12px; + border: 1px solid var(--border); + padding: 10px 12px; + font-size: 14px; + font-family: inherit; +} + +.control button { + background: var(--accent); + color: #fff; + border: none; + cursor: pointer; + transition: transform 0.1s ease; +} + +.control button:active { + transform: scale(0.98); +} + +.stats { + font-size: 13px; + color: var(--muted); + display: flex; + align-items: flex-end; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 18px; +} + +.message { + display: flex; + flex-direction: column; + gap: 8px; +} + +.message.hidden { + display: none; +} + +.message-time { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.message-row { + display: flex; + gap: 12px; + align-items: flex-end; +} + +.message.sent .message-row { + flex-direction: row-reverse; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 12px; + background: #eef2ff; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + color: #475569; + font-weight: 600; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.bubble { + max-width: min(70%, 720px); + background: var(--received); + border-radius: 18px; + padding: 12px 14px; + border: 1px solid var(--border); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); +} + +.message.sent .bubble { + background: var(--sent); + border-color: transparent; +} + +.sender-name { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.message-content { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 14px; + line-height: 1.6; +} + +.message-text { + word-break: break-word; +} + +.inline-emoji { + width: 22px; + height: 22px; + vertical-align: text-bottom; + margin: 0 2px; +} + +.message-media { + border-radius: 14px; + max-width: 100%; +} + +.previewable { + cursor: zoom-in; +} + +.message-media.image, +.message-media.emoji { + max-height: 260px; + object-fit: contain; + background: #f1f5f9; + padding: 6px; +} + +.message-media.emoji { + max-height: 160px; + width: auto; +} + +.message-media.video { + max-height: 360px; + background: #111827; +} + +.message-media.audio { + width: 260px; +} + +.image-preview { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.7); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 999; +} + +.image-preview.active { + opacity: 1; + pointer-events: auto; +} + +.image-preview img { + max-width: min(90vw, 1200px); + max-height: 90vh; + border-radius: 18px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35); + background: #0f172a; + transition: transform 0.1s ease; + cursor: zoom-out; +} + +body[data-theme="cloud-dancer"] { + --accent: #6b8cff; + --sent: #e0e7ff; + --received: #ffffff; + --border: #d8e0f7; + --bg: #f6f7fb; +} + +body[data-theme="corundum-blue"] { + --accent: #2563eb; + --sent: #dbeafe; + --received: #ffffff; + --border: #c7d2fe; + --bg: #eef2ff; +} + +body[data-theme="kiwi-green"] { + --accent: #16a34a; + --sent: #dcfce7; + --received: #ffffff; + --border: #bbf7d0; + --bg: #f0fdf4; +} + +body[data-theme="spicy-red"] { + --accent: #e11d48; + --sent: #ffe4e6; + --received: #ffffff; + --border: #fecdd3; + --bg: #fff1f2; +} + +body[data-theme="teal-water"] { + --accent: #0f766e; + --sent: #ccfbf1; + --received: #ffffff; + --border: #99f6e4; + --bg: #f0fdfa; +} + +.highlight { + outline: 2px solid var(--accent); + outline-offset: 4px; + border-radius: 18px; +} + +.empty { + text-align: center; + color: var(--muted); + padding: 40px; +} +`; diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 76c3633..b89e68c 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -4,10 +4,13 @@ import * as http from 'http' import * as https from 'https' import { fileURLToPath } from 'url' import ExcelJS from 'exceljs' +import { getEmojiPath } from 'wechat-emojis' import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' +import { videoService } from './videoService' +import { EXPORT_HTML_STYLES } from './exportHtmlStyles' // ChatLab 格式类型定义 interface ChatLabHeader { @@ -72,12 +75,25 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' } +const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ + { id: 'index', label: '序号' }, + { id: 'time', label: '时间' }, + { id: 'senderRole', label: '发送者身份' }, + { id: 'messageType', label: '消息类型' }, + { id: 'content', label: '内容' }, + { id: 'senderNickname', label: '发送者昵称' }, + { id: 'senderWxid', label: '发送者微信ID' }, + { id: 'senderRemark', label: '发送者备注' } +] + interface MediaExportItem { relativePath: string - kind: 'image' | 'voice' | 'emoji' + kind: 'image' | 'voice' | 'emoji' | 'video' + posterDataUrl?: string } export interface ExportProgress { @@ -115,6 +131,8 @@ async function parallelLimit( class ExportService { private configService: ConfigService private contactCache: Map = new Map() + private inlineEmojiCache: Map = new Map() + private htmlStyleCache: string | null = null constructor() { this.configService = new ConfigService() @@ -204,6 +222,9 @@ class ExportService { if (!raw) return '' if (typeof raw === 'string') { if (raw.length === 0) return '' + if (/^[0-9]+$/.test(raw)) { + return raw + } if (this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) return this.decodeBinaryContent(bytes) @@ -285,6 +306,121 @@ class ExportService { } } + private formatPlainExportContent( + content: string, + localType: number, + options: { exportVoiceAsText?: boolean }, + voiceTranscript?: string + ): string { + const safeContent = content || '' + + if (localType === 3) return '[图片]' + if (localType === 1) return this.stripSenderPrefix(safeContent) + if (localType === 34) { + if (options.exportVoiceAsText) { + return voiceTranscript || '[语音消息 - 转文字失败]' + } + return '[其他消息]' + } + if (localType === 42) { + const normalized = this.normalizeAppMessageContent(safeContent) + const nickname = + this.extractXmlValue(normalized, 'nickname') || + this.extractXmlValue(normalized, 'displayname') || + this.extractXmlValue(normalized, 'name') + return nickname ? `[名片]${nickname}` : '[名片]' + } + if (localType === 43) { + const normalized = this.normalizeAppMessageContent(safeContent) + const lengthValue = + this.extractXmlValue(normalized, 'playlength') || + this.extractXmlValue(normalized, 'playLength') || + this.extractXmlValue(normalized, 'length') || + this.extractXmlValue(normalized, 'duration') + const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null + return seconds ? `[视频]${seconds}s` : '[视频]' + } + if (localType === 48) { + const normalized = this.normalizeAppMessageContent(safeContent) + const location = + this.extractXmlValue(normalized, 'label') || + this.extractXmlValue(normalized, 'poiname') || + this.extractXmlValue(normalized, 'poiName') || + this.extractXmlValue(normalized, 'name') + return location ? `[定位]${location}` : '[定位]' + } + if (localType === 50) { + return this.parseVoipMessage(safeContent) + } + if (localType === 10000 || localType === 266287972401) { + return this.cleanSystemMessage(safeContent) + } + + const normalized = this.normalizeAppMessageContent(safeContent) + const isAppMessage = normalized.includes('') + if (localType === 49 || isAppMessage) { + const typeMatch = /(\d+)<\/type>/i.exec(normalized) + const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 + const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') + if (subType === 3 || normalized.includes('= 1000) return Math.round(numeric / 1000) + return Math.round(numeric) + } + + private extractAmountFromText(text: string): string | null { + if (!text) return null + const match = /([¥¥]\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?)/.exec(text) + return match ? match[1].replace(/\s+/g, '') : null + } + private stripSenderPrefix(content: string): string { return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '') } @@ -428,6 +564,126 @@ class ExportService { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` } + private normalizeTxtColumns(columns?: string[] | null): string[] { + const fallback = ['index', 'time', 'senderRole', 'messageType', 'content'] + const selected = new Set((columns && columns.length > 0 ? columns : fallback).filter(Boolean)) + const ordered = TXT_COLUMN_DEFINITIONS.map((col) => col.id).filter((id) => selected.has(id)) + return ordered.length > 0 ? ordered : fallback + } + + private sanitizeTxtValue(value: string): string { + return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim() + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + private escapeAttribute(value: string): string { + return this.escapeHtml(value).replace(/`/g, '`') + } + + private getAvatarFallback(name: string): string { + if (!name) return '?' + return [...name][0] || '?' + } + + private renderMultilineText(value: string): string { + return this.escapeHtml(value).replace(/\r?\n/g, '
') + } + + private loadExportHtmlStyles(): string { + if (this.htmlStyleCache !== null) { + return this.htmlStyleCache + } + const candidates = [ + path.join(__dirname, 'exportHtml.css'), + path.join(process.cwd(), 'electron', 'services', 'exportHtml.css') + ] + for (const filePath of candidates) { + if (fs.existsSync(filePath)) { + try { + const content = fs.readFileSync(filePath, 'utf-8') + if (content.trim().length > 0) { + this.htmlStyleCache = content + return content + } + } catch { + continue + } + } + } + this.htmlStyleCache = EXPORT_HTML_STYLES + return this.htmlStyleCache + } + + private normalizeAppMessageContent(content: string): string { + if (!content) return '' + if (content.includes('<') && content.includes('>')) { + return content + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + } + return content + } + + private getInlineEmojiDataUrl(name: string): string | null { + if (!name) return null + const cached = this.inlineEmojiCache.get(name) + if (cached) return cached + const emojiPath = getEmojiPath(name as any) + if (!emojiPath) return null + const baseDir = path.dirname(require.resolve('wechat-emojis')) + const absolutePath = path.join(baseDir, emojiPath) + if (!fs.existsSync(absolutePath)) return null + try { + const buffer = fs.readFileSync(absolutePath) + const dataUrl = `data:image/png;base64,${buffer.toString('base64')}` + this.inlineEmojiCache.set(name, dataUrl) + return dataUrl + } catch { + return null + } + } + + private renderTextWithEmoji(text: string): string { + if (!text) return '' + const parts = text.split(/\[(.*?)\]/g) + const rendered = parts.map((part, index) => { + if (index % 2 === 1) { + const emojiDataUrl = this.getInlineEmojiDataUrl(part) + if (emojiDataUrl) { + return `[${this.escapeAttribute(part)}]` + } + return this.escapeHtml(`[${part}]`) + } + return this.escapeHtml(part) + }) + return rendered.join('') + } + + private formatHtmlMessageText(content: string, localType: number): string { + if (!content) return '' + + if (localType === 1) { + return this.stripSenderPrefix(content) + } + + if (localType === 34) { + return this.parseMessageContent(content, localType) || '' + } + + return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }) + } + /** * 导出媒体文件到指定目录 */ @@ -436,7 +692,14 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } + options: { + exportImages?: boolean + exportVoices?: boolean + exportEmojis?: boolean + exportVoiceAsText?: boolean + includeVoiceWithTranscript?: boolean + exportVideos?: boolean + } ): Promise { const localType = msg.localType @@ -450,14 +713,13 @@ class ExportService { // 语音消息 if (localType === 34) { - // 如果开启了语音转文字,优先转文字(不导出语音文件) - if (options.exportVoiceAsText) { - return null // 转文字逻辑在消息内容处理中完成 - } - // 否则导出语音文件 - if (options.exportVoices) { + const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText + if (shouldKeepVoiceFile && options.exportVoices) { return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) } + if (options.exportVoiceAsText) { + return null + } } // 动画表情 @@ -468,6 +730,10 @@ class ExportService { return result } + if (localType === 43 && options.exportVideos) { + return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix) + } + return null } @@ -678,6 +944,47 @@ class ExportService { } } + /** + * 导出视频文件 + */ + private async exportVideo( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { + try { + const videoMd5 = msg.videoMd5 + if (!videoMd5) return null + + const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos') + if (!fs.existsSync(videosDir)) { + fs.mkdirSync(videosDir, { recursive: true }) + } + + const videoInfo = await videoService.getVideoInfo(videoMd5) + if (!videoInfo.exists || !videoInfo.videoUrl) { + return null + } + + const sourcePath = videoInfo.videoUrl + const fileName = path.basename(sourcePath) + const destPath = path.join(videosDir, fileName) + + if (!fs.existsSync(destPath)) { + fs.copyFileSync(sourcePath, destPath) + } + + return { + relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), + kind: 'video', + posterDataUrl: videoInfo.coverUrl || videoInfo.thumbUrl + } + } catch (e) { + return null + } + } + /** * 从消息内容提取图片 MD5 */ @@ -736,6 +1043,16 @@ class ExportService { return match?.[1] } + private extractVideoMd5(content: string): string | undefined { + if (!content) return undefined + const attrMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (attrMatch) { + return attrMatch[1].toLowerCase() + } + const tagMatch = /([^<]+)<\/md5>/i.exec(content) + return tagMatch?.[1]?.toLowerCase() + } + /** * 从 data URL 获取扩展名 */ @@ -858,6 +1175,7 @@ class ExportService { let imageDatName: string | undefined let emojiCdnUrl: string | undefined let emojiMd5: string | undefined + let videoMd5: string | undefined if (localType === 3 && content) { // 图片消息 @@ -867,6 +1185,9 @@ class ExportService { // 动画表情 emojiCdnUrl = this.extractEmojiUrl(content) emojiMd5 = this.extractEmojiMd5(content) + } else if (localType === 43 && content) { + // 视频消息 + videoMd5 = this.extractVideoMd5(content) } rows.push({ @@ -879,7 +1200,8 @@ class ExportService { imageMd5, imageDatName, emojiCdnUrl, - emojiMd5 + emojiMd5, + videoMd5 }) if (firstTime === null || createTime < firstTime) firstTime = createTime @@ -1747,10 +2069,6 @@ class ExportService { for (let i = 0; i < sortedMessages.length; i++) { const msg = sortedMessages[i] - // 从缓存获取媒体信息 - const mediaKey = `${msg.localType}_${msg.localId}` - const mediaItem = mediaCache.get(mediaKey) || null - // 确定发送者信息 let senderRole: string let senderWxid: string @@ -1798,16 +2116,12 @@ class ExportService { const row = worksheet.getRow(currentRow) row.height = 24 - // 确定内容:优先使用预处理的缓存 - let contentValue: string - if (mediaItem) { - contentValue = mediaItem.relativePath - } else if (msg.localType === 34 && options.exportVoiceAsText) { - // 使用预处理的语音转文字结果 - contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' - } else { - contentValue = this.parseMessageContent(msg.content, msg.localType) || '' - } + const contentValue = this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + ) // 调试日志 if (msg.localType === 3 || msg.localType === 47) { @@ -1880,6 +2194,523 @@ class ExportService { } } + /** + * 导出单个会话为 TXT 格式(默认与 Excel 精简列一致) + */ + async exportSessionToTxt( + sessionId: string, + outputPath: string, + options: ExportOptions, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ success: boolean; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const cleanedMyWxid = conn.cleanedWxid + const isGroup = sessionId.includes('@chatroom') + const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + + onProgress?.({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing' + }) + + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) + const mediaMessages = exportMediaEnabled + ? sortedMessages.filter(msg => { + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) + : [] + + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 25, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media' + }) + + const MEDIA_CONCURRENCY = 8 + await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + const mediaKey = `${msg.localType}_${msg.localId}` + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText + }) + mediaCache.set(mediaKey, mediaItem) + } + }) + } + + const voiceMessages = options.exportVoiceAsText + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 45, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice' + }) + + const VOICE_CONCURRENCY = 4 + await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + voiceTranscriptMap.set(msg.localId, transcript) + }) + } + + onProgress?.({ + current: 60, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + + const lines: string[] = [] + + for (let i = 0; i < sortedMessages.length; i++) { + const msg = sortedMessages[i] + const contentValue = this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + ) + + let senderRole: string + let senderWxid: string + let senderNickname: string + let senderRemark = '' + + if (msg.isSend) { + senderRole = '我' + senderWxid = cleanedMyWxid + senderNickname = myInfo.displayName || cleanedMyWxid + } else if (isGroup && msg.senderUsername) { + senderWxid = msg.senderUsername + const contactDetail = await wcdbService.getContact(msg.senderUsername) + if (contactDetail.success && contactDetail.contact) { + senderNickname = contactDetail.contact.nickName || msg.senderUsername + senderRemark = contactDetail.contact.remark || '' + senderRole = senderRemark || senderNickname + } else { + senderNickname = msg.senderUsername + senderRole = msg.senderUsername + } + } else { + senderWxid = sessionId + const contactDetail = await wcdbService.getContact(sessionId) + if (contactDetail.success && contactDetail.contact) { + senderNickname = contactDetail.contact.nickName || sessionId + senderRemark = contactDetail.contact.remark || '' + senderRole = senderRemark || senderNickname + } else { + senderNickname = sessionInfo.displayName || sessionId + senderRole = senderNickname + } + } + + lines.push(`${this.formatTimestamp(msg.createTime)} '${senderRole}'`) + lines.push(contentValue) + lines.push('') + + if ((i + 1) % 200 === 0) { + const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30) + onProgress?.({ + current: progress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + } + } + + onProgress?.({ + current: 92, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + /** + * 导出单个会话为 HTML 格式 + */ + async exportSessionToHtml( + sessionId: string, + outputPath: string, + options: ExportOptions, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ success: boolean; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const cleanedMyWxid = conn.cleanedWxid + const isGroup = sessionId.includes('@chatroom') + const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + + onProgress?.({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing' + }) + + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + if (isGroup) { + await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) + } + const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) + const mediaMessages = exportMediaEnabled + ? sortedMessages.filter(msg => { + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices) || + t === 43 + }) + : [] + + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media' + }) + + const MEDIA_CONCURRENCY = 6 + await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + const mediaKey = `${msg.localType}_${msg.localId}` + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText, + includeVoiceWithTranscript: true, + exportVideos: true + }) + mediaCache.set(mediaKey, mediaItem) + } + }) + } + + const useVoiceTranscript = options.exportVoiceAsText !== false + const voiceMessages = useVoiceTranscript + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 40, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice' + }) + + const VOICE_CONCURRENCY = 4 + await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + voiceTranscriptMap.set(msg.localId, transcript) + }) + } + + const avatarMap = options.exportAvatars + ? await this.exportAvatars( + [ + ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ + username, + avatarUrl: info.avatarUrl + })), + { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, + { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } + ] + ) + : new Map() + + const renderedMessages = sortedMessages.map((msg, index) => { + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) || null + + const isSenderMe = msg.isSend + const senderInfo = collected.memberSet.get(msg.senderUsername)?.member + const senderName = isSenderMe + ? (myInfo.displayName || '我') + : (isGroup + ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) + : (sessionInfo.displayName || sessionId)) + const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername) + const avatarHtml = avatarData + ? `${this.escapeAttribute(senderName)}` + : `${this.escapeHtml(this.getAvatarFallback(senderName))}` + + const timeText = this.formatTimestamp(msg.createTime) + const typeName = this.getMessageTypeName(msg.localType) + + let textContent = this.formatHtmlMessageText(msg.content, msg.localType) + if (msg.localType === 34 && useVoiceTranscript) { + textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } + if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { + textContent = '' + } + + let mediaHtml = '' + if (mediaItem?.kind === 'image') { + const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath)) + mediaHtml = `${this.escapeAttribute(typeName)}` + } else if (mediaItem?.kind === 'emoji') { + const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath)) + mediaHtml = `${this.escapeAttribute(typeName)}` + } else if (mediaItem?.kind === 'voice') { + mediaHtml = `` + } else if (mediaItem?.kind === 'video') { + const posterAttr = mediaItem.posterDataUrl ? ` poster="${this.escapeAttribute(mediaItem.posterDataUrl)}"` : '' + mediaHtml = `` + } + + const textHtml = textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '' + const senderHtml = isGroup + ? `
${this.escapeHtml(senderName)}
` + : '' + const timeHtml = `
${this.escapeHtml(timeText)}
` + const messageBody = ` + ${timeHtml} + ${senderHtml} +
+ ${mediaHtml} + ${textHtml} +
+ ` + + return ` +
+
+
${avatarHtml}
+
+ ${messageBody} +
+
+
+ ` + }).join('\n') + + onProgress?.({ + current: 85, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup) + const htmlStyles = this.loadExportHtmlStyles() + const html = ` + + + + + ${this.escapeHtml(sessionInfo.displayName)} - 聊天记录 + + + +
+
+

${this.escapeHtml(sessionInfo.displayName)} 的聊天记录

+
+ 导出时间:${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))} + 消息数量:${sortedMessages.length} + 会话类型:${isGroup ? '群聊' : '私聊'} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ 共 ${sortedMessages.length} 条 +
+
+
+
+ ${renderedMessages || '
暂无消息
'} +
+
+
+ 预览 +
+ + +` + + fs.writeFileSync(outputPath, html, 'utf-8') + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 批量导出多个会话 */ @@ -1930,6 +2761,8 @@ class ExportService { let ext = '.json' if (options.format === 'chatlab-jsonl') ext = '.jsonl' else if (options.format === 'excel') ext = '.xlsx' + else if (options.format === 'txt') ext = '.txt' + else if (options.format === 'html') ext = '.html' const outputPath = path.join(sessionDir, `${safeName}${ext}`) let result: { success: boolean; error?: string } @@ -1939,6 +2772,10 @@ class ExportService { result = await this.exportSessionToChatLab(sessionId, outputPath, options) } else if (options.format === 'excel') { result = await this.exportSessionToExcel(sessionId, outputPath, options) + } else if (options.format === 'txt') { + result = await this.exportSessionToTxt(sessionId, outputPath, options) + } else if (options.format === 'html') { + result = await this.exportSessionToHtml(sessionId, outputPath, options) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 09af338..2692201 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 11ee295..bd9365b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -22,6 +22,7 @@ interface ExportOptions { exportEmojis: boolean exportVoiceAsText: boolean excelCompactColumns: boolean + txtColumns: string[] } interface ExportResult { @@ -34,6 +35,7 @@ interface ExportResult { type SessionLayout = 'shared' | 'per-session' function ExportPage() { + const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const [sessions, setSessions] = useState([]) const [filteredSessions, setFilteredSessions] = useState([]) const [selectedSessions, setSelectedSessions] = useState>(new Set()) @@ -61,7 +63,8 @@ function ExportPage() { exportVoices: true, exportEmojis: true, exportVoiceAsText: true, - excelCompactColumns: true + excelCompactColumns: true, + txtColumns: defaultTxtColumns }) const buildDateRangeFromPreset = (preset: string) => { @@ -125,17 +128,20 @@ function ExportPage() { savedRange, savedMedia, savedVoiceAsText, - savedExcelCompactColumns + savedExcelCompactColumns, + savedTxtColumns ] = await Promise.all([ configService.getExportDefaultFormat(), configService.getExportDefaultDateRange(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), - configService.getExportDefaultExcelCompactColumns() + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultTxtColumns() ]) const preset = savedRange || 'today' const rangeDefaults = buildDateRangeFromPreset(preset) + const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions((prev) => ({ ...prev, @@ -144,7 +150,8 @@ function ExportPage() { dateRange: rangeDefaults.dateRange, exportMedia: savedMedia ?? false, exportVoiceAsText: savedVoiceAsText ?? true, - excelCompactColumns: savedExcelCompactColumns ?? true + excelCompactColumns: savedExcelCompactColumns ?? true, + txtColumns })) } catch (e) { console.error('加载导出默认设置失败:', e) @@ -222,6 +229,23 @@ function ExportPage() { return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) } + const handleFormatChange = (format: ExportOptions['format']) => { + setOptions((prev) => { + const next = { ...prev, format } + if (format === 'html') { + return { + ...next, + exportMedia: true, + exportImages: true, + exportVoices: true, + exportEmojis: true, + exportVoiceAsText: true + } + } + return next + }) + } + const openExportFolder = async () => { if (exportFolder) { await window.electronAPI.shell.openPath(exportFolder) @@ -246,6 +270,7 @@ function ExportPage() { exportEmojis: options.exportMedia && options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 excelCompactColumns: options.excelCompactColumns, + txtColumns: options.txtColumns, sessionLayout, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), @@ -254,7 +279,7 @@ function ExportPage() { } : null } - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') { + if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') { const result = await window.electronAPI.export.exportSessions( sessionList, exportFolder, @@ -460,7 +485,7 @@ function ExportPage() {
setOptions({ ...options, format: fmt.value as any })} + onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])} > {fmt.label} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 0037a5f..8bd2d45 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -174,6 +174,7 @@ function SettingsPage() { await configService.setTranscribeLanguages(defaultLanguages) } + if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) } catch (e) { console.error('加载配置失败:', e) @@ -1151,6 +1152,7 @@ function SettingsPage() { )}
+ ) } @@ -1302,4 +1304,3 @@ function SettingsPage() { } export default SettingsPage - diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 005e2b4..2bcac8e 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -10,47 +10,63 @@ } .sns-sidebar { - width: 280px; + width: 300px; background: var(--bg-secondary); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); flex-shrink: 0; + z-index: 10; &.closed { width: 0; opacity: 0; + transform: translateX(-100%); pointer-events: none; } .sidebar-header { - padding: 16px 20px; + padding: 18px 20px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-color); - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; + .title-wrapper { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + + .title-icon { + color: var(--accent-color); + } + + h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.5px; + } } .toggle-btn { - background: none; - border: none; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); color: var(--text-secondary); cursor: pointer; - padding: 4px; + padding: 5px; display: flex; align-items: center; justify-content: center; - border-radius: 4px; + border-radius: 6px; + transition: all 0.2s; &:hover { background: var(--hover-bg); - color: var(--text-primary); + color: var(--accent-color); + border-color: var(--accent-color); } } } @@ -58,93 +74,114 @@ .filter-content { flex: 1; overflow-y: auto; + padding: 16px; display: flex; flex-direction: column; + gap: 16px; - /* 自定义滚动条 */ - &::-webkit-scrollbar { - width: 4px; - } - - &::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 10px; - } - } - - .filter-group { - padding-bottom: 4px; - border-bottom: 1px solid var(--border-color); - } - - .filter-section { - padding: 10px 20px; - - label { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - color: var(--text-secondary); - margin-bottom: 8px; - font-weight: 500; - } - - input[type="text"] { - width: 100%; - background: var(--bg-tertiary); + .filter-card { + background: var(--bg-primary); + border-radius: 12px; border: 1px solid var(--border-color); - border-radius: 6px; - padding: 8px 12px; - color: var(--text-primary); - font-size: 13px; - outline: none; + padding: 14px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); + transition: transform 0.2s, box-shadow 0.2s; - &:focus { - border-color: var(--accent-color); + &:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04); } - } - .date-inputs { - display: flex; - flex-direction: column; - gap: 6px; + &.jump-date-card { + .jump-date-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px 14px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; - input[type="date"] { - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 6px 10px; - color: var(--text-primary); - font-size: 12px; - outline: none; - width: 100%; + &.active { + border-color: var(--accent-color); + color: var(--text-primary); + font-weight: 500; + background: rgba(var(--accent-color-rgb), 0.05); - &:focus { - border-color: var(--accent-color); + .icon { + color: var(--accent-color); + opacity: 1; + } + } + + &:hover { + border-color: var(--accent-color); + background: var(--bg-primary); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.08); + } + + &:active { + transform: translateY(0); + } + + .text { + flex: 1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .icon { + opacity: 0.5; + transition: all 0.2s; + margin-left: 8px; + } + } + + .clear-jump-date-inline { + width: 100%; + margin-top: 10px; + background: rgba(var(--accent-color-rgb), 0.06); + border: 1px dashed rgba(var(--accent-color-rgb), 0.3); + color: var(--accent-color); + font-size: 12px; + cursor: pointer; + text-align: center; + padding: 6px; + border-radius: 8px; + transition: all 0.2s; + font-weight: 500; + + &:hover { + background: var(--accent-color); + color: white; + border-style: solid; + } } } - span { - font-size: 11px; - color: var(--text-tertiary); - text-align: center; + &.contact-card { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; // 改为 0 以支持 flex 压缩 + padding: 0; + overflow: hidden; } } - } - .contact-filter-section { - flex: 1; - display: flex; - flex-direction: column; - min-height: 200px; - padding: 12px 0 0 0; - .section-header { - padding: 0 20px 8px 20px; - display: flex; - justify-content: space-between; - align-items: center; + + .filter-section { + margin-bottom: 20px; label { display: flex; @@ -152,118 +189,294 @@ gap: 6px; font-size: 13px; color: var(--text-secondary); - font-weight: 500; - } + margin-bottom: 10px; + font-weight: 600; - .selected-count { - font-size: 11px; - background: var(--accent-color); - color: white; - padding: 1px 6px; - border-radius: 10px; - } - } - - .contact-search { - margin: 0 20px 10px 20px; - position: relative; - - .search-icon { - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - color: var(--text-tertiary); - } - - input { - width: 100%; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 4px; - padding: 6px 10px 6px 28px; - font-size: 12px; - color: var(--text-primary); - outline: none; - - &:focus { - border-color: var(--accent-color); + svg { + color: var(--accent-color); + opacity: 0.8; } } - } - .contact-list { - flex: 1; - overflow-y: auto; - padding: 0 10px; - - .contact-item { + .search-input-wrapper { + position: relative; display: flex; align-items: center; - padding: 8px 10px; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - gap: 10px; - margin-bottom: 2px; - position: relative; - &:hover { - background: var(--hover-bg); + .input-icon { + position: absolute; + left: 12px; + color: var(--text-tertiary); + pointer-events: none; } - &.active { - background: rgba(var(--accent-color-rgb), 0.1); + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px 10px 10px 36px; + color: var(--text-primary); + font-size: 13px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - .contact-name { - color: var(--accent-color); - font-weight: 600; + &::placeholder { + color: var(--text-tertiary); + opacity: 0.6; + } + + &:focus { + outline: none; + border-color: var(--accent-color); + background: var(--bg-primary); + box-shadow: 0 0 0 4px rgba(var(--accent-color-rgb), 0.1); } } - .contact-name { - flex: 1; - font-size: 13px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--text-secondary); - } + .clear-input { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + display: flex; + border-radius: 50%; + transition: all 0.2s; - .check-mark { - color: var(--accent-color); - font-size: 12px; - font-weight: bold; + &:hover { + color: var(--text-secondary); + background: var(--hover-bg); + transform: rotate(90deg); + } + } + } + } + + .contact-filter-section { + flex: 1; + display: flex; + flex-direction: column; + + .section-header { + padding: 16px 16px 1px 16px; + display: flex; + justify-content: space-between; + align-items: center; + + .header-actions { + display: flex; + align-items: center; + gap: 8px; + + .clear-selection-btn { + background: none; + border: none; + color: var(--text-tertiary); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.2s; + + &:hover { + color: var(--accent-color); + background: rgba(var(--accent-color-rgb), 0.1); + } + } + + .selected-count { + font-size: 10px; + background: var(--accent-color); + color: white; + min-width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-weight: bold; + } } } - .empty-contacts { - text-align: center; - padding: 20px; - font-size: 12px; - color: var(--text-tertiary); + .contact-search { + padding: 0 16px 12px 16px; + position: relative; + display: flex; + align-items: center; + + .search-icon { + position: absolute; + left: 26px; + color: var(--text-tertiary); + pointer-events: none; + z-index: 1; + opacity: 0.6; + } + + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 30px 8px 30px; + font-size: 12px; + color: var(--text-primary); + transition: all 0.2s; + + &:focus { + outline: none; + border-color: var(--accent-color); + background: var(--bg-primary); + } + } + + .clear-search-icon { + position: absolute; + right: 24px; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 50%; + transition: all 0.2s; + + &:hover { + color: var(--text-secondary); + background: var(--hover-bg); + } + } + } + + .contact-list { + flex: 1; + overflow-y: auto; + padding: 4px 8px; + margin: 0 4px 8px 4px; + + .contact-item { + display: flex; + align-items: center; + padding: 8px 12px; + border-radius: 10px; + cursor: pointer; + gap: 12px; + margin-bottom: 2px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + + &:hover { + background: var(--hover-bg); + transform: translateX(2px); + } + + &.active { + background: rgba(var(--accent-color-rgb), 0.08); + + .contact-name { + color: var(--accent-color); + font-weight: 600; + } + + .check-box { + border-color: var(--accent-color); + background: var(--accent-color); + + .inner-check { + transform: scale(1); + } + } + } + + .avatar-wrapper { + position: relative; + display: flex; + + .active-badge { + position: absolute; + bottom: -1px; + right: -1px; + width: 10px; + height: 10px; + background: var(--accent-color); + border: 2px solid var(--bg-secondary); + border-radius: 50%; + animation: badge-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + } + + .contact-name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-secondary); + transition: color 0.2s; + } + + .check-box { + width: 16px; + height: 16px; + border: 2px solid var(--border-color); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + .inner-check { + width: 8px; + height: 8px; + border-radius: 1px; + background: white; + transform: scale(0); + transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + } + } + + .empty-contacts { + padding: 32px 16px; + text-align: center; + font-size: 13px; + color: var(--text-tertiary); + font-style: italic; + } } } } .sidebar-footer { - padding: 16px 20px; + padding: 16px; border-top: 1px solid var(--border-color); .clear-btn { width: 100%; - padding: 8px; - background: transparent; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px; + background: var(--bg-tertiary); border: 1px solid var(--border-color); color: var(--text-secondary); - border-radius: 6px; + border-radius: 8px; cursor: pointer; font-size: 13px; + font-weight: 500; transition: all 0.2s; &:hover { - background: var(--hover-bg); - color: var(--text-primary); + background: var(--accent-color); + color: white; + border-color: var(--accent-color); + box-shadow: 0 4px 10px rgba(var(--accent-color-rgb), 0.2); + } + + &:active { + transform: scale(0.98); } } } @@ -274,25 +487,40 @@ display: flex; flex-direction: column; min-width: 0; + background: var(--bg-primary); .sns-header { display: flex; align-items: center; justify-content: space-between; - padding: 0 20px; - height: 60px; + padding: 0 24px; + height: 64px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); + backdrop-filter: blur(10px); + z-index: 5; .header-left { display: flex; align-items: center; - gap: 12px; + gap: 16px; h2 { margin: 0; font-size: 18px; - font-weight: 600; + font-weight: 700; + color: var(--text-primary); + } + + .sidebar-trigger { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + + &:hover { + background: var(--hover-bg); + color: var(--accent-color); + } } } @@ -302,15 +530,21 @@ color: var(--text-secondary); cursor: pointer; padding: 8px; - border-radius: 4px; + border-radius: 8px; transition: all 0.2s; display: flex; align-items: center; justify-content: center; &:hover { - background: var(--hover-bg); color: var(--text-primary); + background: var(--hover-bg); + } + + &.refresh-btn { + &:hover { + color: var(--accent-color); + } } } @@ -319,249 +553,364 @@ } } + .sns-content-wrapper { + flex: 1; + display: flex; + overflow: hidden; + position: relative; + } + + .sns-content { flex: 1; overflow-y: auto; - padding: 24px; + padding: 24px 0; scroll-behavior: smooth; - .active-filters { + .active-filters-bar { max-width: 680px; - margin: 0 auto 16px auto; + margin: 0 auto 24px auto; display: flex; align-items: center; justify-content: space-between; - background: rgba(var(--accent-color-rgb), 0.05); + background: rgba(var(--accent-color-rgb), 0.08); border: 1px solid rgba(var(--accent-color-rgb), 0.2); - padding: 8px 16px; - border-radius: 8px; + padding: 10px 16px; + border-radius: 10px; font-size: 13px; color: var(--accent-color); + .filter-info { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + } + .clear-chip-btn { - background: none; + background: var(--accent-color); border: none; - color: var(--text-tertiary); + color: white; cursor: pointer; - font-size: 12px; - text-decoration: underline; + font-size: 11px; + padding: 4px 10px; + border-radius: 4px; + font-weight: 600; &:hover { - color: var(--text-secondary); + background: var(--accent-color-hover); } } } - .sns-post { - background: var(--bg-secondary); - border-radius: 12px; - padding: 20px; - margin-bottom: 24px; - max-width: 680px; - margin-left: auto; - margin-right: auto; - border: 1px solid var(--border-color); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + .posts-list { + display: flex; + flex-direction: column; + gap: 32px; + } - .post-header { - display: flex; - align-items: center; - margin-bottom: 14px; + .sns-post-row { + display: flex; + width: 100%; + max-width: 800px; + position: relative; + } - .post-info { - margin-left: 12px; + } - .nickname { - font-weight: 600; - margin-bottom: 2px; - color: var(--accent-color); - } + .sns-post-wrapper { + width: 100%; + padding: 0 20px; + } - .time { - font-size: 12px; - color: var(--text-tertiary); + .sns-post { + background: var(--bg-secondary); + border-radius: 16px; + padding: 24px; + border: 1px solid var(--border-color); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03); + transition: transform 0.2s; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06); + } + + .post-header { + display: flex; + align-items: center; + margin-bottom: 18px; + + .post-info { + margin-left: 14px; + + .nickname { + font-size: 15px; + font-weight: 700; + margin-bottom: 4px; + color: var(--accent-color); + } + + .time { + font-size: 12px; + color: var(--text-tertiary); + display: flex; + align-items: center; + gap: 4px; + + &::before { + content: ''; + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + opacity: 0.5; } } } + } - .post-body { - margin-bottom: 16px; + .post-body { + margin-bottom: 20px; - .post-text { - margin-bottom: 12px; - white-space: pre-wrap; - line-height: 1.6; - font-size: 15px; - word-break: break-word; - } + .post-text { + margin-bottom: 14px; + white-space: pre-wrap; + line-height: 1.7; + font-size: 15px; + color: var(--text-primary); + word-break: break-word; + } - .post-media-grid { - display: grid; - gap: 4px; - width: fit-content; - max-width: 100%; + .post-media-grid { + display: grid; + gap: 6px; + width: fit-content; + max-width: 100%; - &.media-count-1 { - grid-template-columns: 1fr; - - .media-item { - max-width: 400px; - aspect-ratio: unset; - } - - img { - height: auto; - max-height: 500px; - } - } - - &.media-count-2, - &.media-count-4 { - grid-template-columns: repeat(2, 1fr); - } - - &.media-count-3, - &.media-count-5, - &.media-count-6, - &.media-count-7, - &.media-count-8, - &.media-count-9 { - grid-template-columns: repeat(3, 1fr); - } + &.media-count-1 { + grid-template-columns: 1fr; .media-item { - aspect-ratio: 1; + width: 320px; + height: 240px; + max-width: 100%; + border-radius: 12px; + aspect-ratio: auto; background: var(--bg-tertiary); - border-radius: 4px; - overflow: hidden; + border: 1px solid var(--border-color); + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &.media-count-2, + &.media-count-4 { + grid-template-columns: repeat(2, 1fr); + } + + &.media-count-3, + &.media-count-5, + &.media-count-6, + &.media-count-7, + &.media-count-8, + &.media-count-9 { + grid-template-columns: repeat(3, 1fr); + } + + .media-item { + width: 160px; // 多图模式下项固定大小(或由 grid 控制,但确保有高度) + height: 160px; + aspect-ratio: 1; + background: var(--bg-tertiary); + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border-color); + position: relative; + + img { + width: 100%; + height: 100%; + object-fit: cover; + cursor: zoom-in; + } + + .media-error-placeholder { + position: absolute; + inset: 0; display: flex; align-items: center; justify-content: center; - - &.error { - background: transparent; - border: 1px dashed var(--border-color); - color: var(--text-tertiary); - font-size: 12px; - min-width: 100px; - min-height: 100px; - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - cursor: pointer; - transition: opacity 0.2s; - - &:hover { - opacity: 0.85; - } - } + background: var(--bg-deep); + color: var(--text-tertiary); + cursor: default; } } - - .post-video-placeholder { - display: inline-flex; - align-items: center; - background: rgba(var(--accent-color-rgb), 0.1); - color: var(--accent-color); - padding: 6px 12px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - margin-bottom: 8px; - } } - .post-footer { - background: var(--bg-tertiary); - border-radius: 6px; - padding: 10px 12px; - font-size: 13.5px; + .post-video-placeholder { + display: inline-flex; + align-items: center; + gap: 10px; + background: rgba(var(--accent-color-rgb), 0.08); + color: var(--accent-color); + padding: 10px 18px; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + border: 1px solid rgba(var(--accent-color-rgb), 0.1); + cursor: pointer; - .likes-section { - display: flex; - align-items: flex-start; - color: var(--accent-color); - padding-bottom: 8px; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - margin-bottom: 8px; - - &:last-child { - padding-bottom: 0; - border-bottom: none; - margin-bottom: 0; - } - - .icon { - margin-top: 3.5px; - margin-right: 8px; - flex-shrink: 0; - } - - .likes-list { - line-height: 1.5; - } - } - - .comments-section { - .comment-item { - margin-bottom: 6px; - line-height: 1.5; - - &:last-child { - margin-bottom: 0; - } - - .comment-user { - color: var(--accent-color); - font-weight: 600; - cursor: pointer; - - &:hover { - text-decoration: underline; - } - } - - .reply-text { - color: var(--text-tertiary); - margin: 0 4px; - font-size: 13px; - } - - .comment-separator { - color: var(--text-secondary); - margin-left: -2px; - margin-right: 4px; - } - - .comment-content { - color: var(--text-secondary); - } - } + &:hover { + background: rgba(var(--accent-color-rgb), 0.12); } } } - .loading-more, - .no-more, - .no-results { - text-align: center; - padding: 40px 20px; - color: var(--text-tertiary); - font-size: 14px; + .post-footer { + background: var(--bg-tertiary); + border-radius: 10px; + padding: 14px; + position: relative; - .reset-inline { - margin-top: 12px; - background: var(--accent-color); - color: white; - border: none; - padding: 8px 20px; - border-radius: 6px; - cursor: pointer; + &::after { + content: ''; + position: absolute; + top: -8px; + left: 20px; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid var(--bg-tertiary); + } + + .likes-section { + display: flex; + align-items: flex-start; + color: var(--accent-color); + padding-bottom: 10px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 10px; font-size: 13px; - box-shadow: 0 2px 6px rgba(var(--accent-color-rgb), 0.3); + + &:last-child { + padding-bottom: 0; + border-bottom: none; + margin-bottom: 0; + } + + .icon { + margin-top: 3px; + margin-right: 10px; + flex-shrink: 0; + opacity: 0.8; + } + + .likes-list { + line-height: 1.6; + font-weight: 500; + } + } + + .comments-section { + .comment-item { + margin-bottom: 8px; + line-height: 1.6; + font-size: 13px; + + &:last-child { + margin-bottom: 0; + } + + .comment-user { + color: var(--accent-color); + font-weight: 700; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .reply-text { + color: var(--text-tertiary); + margin: 0 6px; + font-size: 12px; + } + + .comment-content { + color: var(--text-secondary); + } + } + } + } + } + + .status-indicator { + text-align: center; + padding: 40px; + color: var(--text-tertiary); + font-size: 14px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + + &.loading-more, + &.loading-newer { + color: var(--accent-color); + } + + &.newer-hint { + background: rgba(var(--accent-color-rgb), 0.08); + padding: 12px; + border-radius: 12px; + cursor: pointer; + border: 1px dashed rgba(var(--accent-color-rgb), 0.2); + transition: all 0.2s; + margin-bottom: 16px; + + &:hover { + background: rgba(var(--accent-color-rgb), 0.15); + border-style: solid; + transform: translateY(-2px); + } + } + } + + .no-results { + text-align: center; + padding: 80px 20px; + color: var(--text-tertiary); + + .no-results-icon { + margin-bottom: 20px; + opacity: 0.2; + } + + p { + font-size: 16px; + margin-bottom: 24px; + } + + .reset-inline { + background: var(--accent-color); + color: white; + border: none; + padding: 10px 24px; + border-radius: 10px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + box-shadow: 0 4px 15px rgba(var(--accent-color-rgb), 0.3); + transition: all 0.2s; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(var(--accent-color-rgb), 0.4); } } } @@ -576,4 +925,16 @@ to { transform: rotate(360deg); } +} + +@keyframes badge-pop { + from { + transform: scale(0); + opacity: 0; + } + + to { + transform: scale(1); + opacity: 1; + } } \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index a0dbb14..18bf21a 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState, useRef, useCallback } from 'react' -import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react' +import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react' import { Avatar } from '../components/Avatar' import { ImagePreview } from '../components/ImagePreview' +import JumpToDateDialog from '../components/JumpToDateDialog' import './SnsPage.scss' interface SnsPost { @@ -20,23 +21,21 @@ interface SnsPost { const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => { const [error, setError] = useState(false); - if (error) { - return ( -
- 无法加载 -
- ); - } - return ( -
- setError(true)} - /> +
+ {!error ? ( + setError(true)} + /> + ) : ( +
+ +
+ )}
); }; @@ -57,32 +56,102 @@ export default function SnsPage() { // 筛选与搜索状态 const [searchKeyword, setSearchKeyword] = useState('') const [selectedUsernames, setSelectedUsernames] = useState([]) - const [startDate, setStartDate] = useState('') - const [endDate, setEndDate] = useState('') const [isSidebarOpen, setIsSidebarOpen] = useState(true) // 联系人列表状态 const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) + const [showJumpDialog, setShowJumpDialog] = useState(false) + const [jumpTargetDate, setJumpTargetDate] = useState(undefined) const [previewImage, setPreviewImage] = useState(null) - const loadPosts = useCallback(async (reset = false) => { + const postsContainerRef = useRef(null) + + const [hasNewer, setHasNewer] = useState(false) + const [loadingNewer, setLoadingNewer] = useState(false) + const postsRef = useRef([]) + const scrollAdjustmentRef = useRef(0) + + // 同步 posts 到 ref 供 loadPosts 使用 + useEffect(() => { + postsRef.current = posts + }, [posts]) + + // 处理向上加载动态时的滚动位置保持 + useEffect(() => { + if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) { + const container = postsContainerRef.current; + const newHeight = container.scrollHeight; + const diff = newHeight - scrollAdjustmentRef.current; + if (diff > 0) { + container.scrollTop += diff; + } + scrollAdjustmentRef.current = 0; + } + }, [posts]) + + const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { + const { reset = false, direction = 'older' } = options if (loadingRef.current) return + loadingRef.current = true - setLoading(true) + if (direction === 'newer') setLoadingNewer(true) + else setLoading(true) try { - const currentOffset = reset ? 0 : offset const limit = 20 + let startTs: number | undefined = undefined + let endTs: number | undefined = undefined - // 转换日期为秒级时间戳 - const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined - const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天 + if (reset) { + if (jumpTargetDate) { + endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399 + } + } else if (direction === 'newer') { + const currentPosts = postsRef.current + if (currentPosts.length > 0) { + const topTs = currentPosts[0].createTime + console.log('[SnsPage] Fetching newer posts starts from:', topTs + 1); + + const result = await window.electronAPI.sns.getTimeline( + limit, + 0, + selectedUsernames, + searchKeyword, + topTs + 1, + undefined + ); + + if (result.success && result.timeline && result.timeline.length > 0) { + if (postsContainerRef.current) { + scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight; + } + + const existingIds = new Set(currentPosts.map(p => p.id)); + const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id)); + + if (uniqueNewer.length > 0) { + setPosts(prev => [...uniqueNewer, ...prev]); + } + setHasNewer(result.timeline.length >= limit); + } else { + setHasNewer(false); + } + } + setLoadingNewer(false); + loadingRef.current = false; + return; + } else { + const currentPosts = postsRef.current + if (currentPosts.length > 0) { + endTs = currentPosts[currentPosts.length - 1].createTime - 1 + } + } const result = await window.electronAPI.sns.getTimeline( limit, - currentOffset, + 0, selectedUsernames, searchKeyword, startTs, @@ -92,11 +161,24 @@ export default function SnsPage() { if (result.success && result.timeline) { if (reset) { setPosts(result.timeline) - setOffset(limit) setHasMore(result.timeline.length >= limit) + + // 探测上方是否还有新动态(利用 DLL 过滤,而非底层 SQL) + const topTs = result.timeline[0]?.createTime || 0; + if (topTs > 0) { + const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined); + setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0)); + } else { + setHasNewer(false); + } + + if (postsContainerRef.current) { + postsContainerRef.current.scrollTop = 0 + } } else { - setPosts(prev => [...prev, ...result.timeline!]) - setOffset(prev => prev + limit) + if (result.timeline.length > 0) { + setPosts(prev => [...prev, ...result.timeline!]) + } if (result.timeline.length < limit) { setHasMore(false) } @@ -106,9 +188,10 @@ export default function SnsPage() { console.error('Failed to load SNS timeline:', error) } finally { setLoading(false) + setLoadingNewer(false) loadingRef.current = false } - }, [offset, selectedUsernames, searchKeyword, startDate, endDate]) + }, [selectedUsernames, searchKeyword, jumpTargetDate]) // 获取联系人列表 const loadContacts = useCallback(async () => { @@ -116,30 +199,14 @@ export default function SnsPage() { try { const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { - // 系统账号和特殊前缀 const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder']; - - // 初步提取并过滤联系人 const initialContacts = result.sessions .filter((s: any) => { if (!s.username) return false; const u = s.username.toLowerCase(); - - // 1. 排除群聊 (WeChat 群组以 @chatroom 结尾) - if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) { - return false; - } - - // 2. 排除公众号 (通常以 gh_ 开头) - if (u.startsWith('gh_')) { - return false; - } - - // 3. 排除系统账号 - if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) { - return false; - } - + if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false; + if (u.startsWith('gh_')) return false; + if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false; return true; }) .map((s: any) => ({ @@ -149,7 +216,6 @@ export default function SnsPage() { })) setContacts(initialContacts) - // 异步进一步富化(获取更多准确的昵称和头像) const usernames = initialContacts.map(c => c.username) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) if (enriched.success && enriched.contacts) { @@ -173,7 +239,21 @@ export default function SnsPage() { } }, []) + // 初始加载 useEffect(() => { + const checkSchema = async () => { + try { + const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)"); + console.log('[SnsPage] SnsTimeLine Schema:', schema); + if (schema.success && schema.rows) { + const columns = schema.rows.map((r: any) => r.name); + console.log('[SnsPage] Available columns:', columns); + } + } catch (e) { + console.error('[SnsPage] Failed to check schema:', e); + } + }; + checkSchema(); loadContacts() }, [loadContacts]) @@ -193,13 +273,33 @@ export default function SnsPage() { }, [loadContacts, loadPosts]) useEffect(() => { - loadPosts(true) - }, [selectedUsernames, searchKeyword, startDate, endDate]) + loadPosts({ reset: true }) + }, [selectedUsernames, searchKeyword, jumpTargetDate]) const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget - if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !loading) { - loadPosts() + + // 加载更旧的动态(触底) + if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { + loadPosts({ direction: 'older' }) + } + + // 加载更新的动态(触顶触发) + // 这里的阈值可以保留,但主要依赖下面的 handleWheel 捕获到顶后的上划 + if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) { + loadPosts({ direction: 'newer' }) + } + } + + // 处理到顶后的手动上滚意图 + const handleWheel = (e: React.WheelEvent) => { + const container = postsContainerRef.current + if (!container) return + + // deltaY < 0 表示向上滚,scrollTop === 0 表示已经在最顶端 + if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) { + console.log('[SnsPage] Wheel-up detected at top, loading newer posts...'); + loadPosts({ direction: 'newer' }) } } @@ -217,6 +317,11 @@ export default function SnsPage() { } const toggleUserSelection = (username: string) => { + // 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑 + // 或者保持原样。根据用户反馈“乱跳”,我们在这里选择: + // 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。 + setJumpTargetDate(undefined); + setSelectedUsernames(prev => { if (prev.includes(username)) { return prev.filter(u => u !== username) @@ -229,8 +334,7 @@ export default function SnsPage() { const clearFilters = () => { setSearchKeyword('') setSelectedUsernames([]) - setStartDate('') - setEndDate('') + setJumpTargetDate(undefined) } const filteredContacts = contacts.filter(c => @@ -238,89 +342,122 @@ export default function SnsPage() { c.username.toLowerCase().includes(contactSearch.toLowerCase()) ) + + return (
{/* 侧边栏:过滤与搜索 */} @@ -328,109 +465,146 @@ export default function SnsPage() {
{!isSidebarOpen && ( - )} -

朋友圈

+

社交动态

-
-
- {selectedUsernames.length > 0 && ( -
- 筛选中: {selectedUsernames.length} 位好友 - -
- )} - - {posts.map(post => ( -
-
- -
-
{post.nickname}
-
{formatTime(post.createTime)}
+
+
+
+ {loadingNewer && ( +
+ + 正在检查更新的动态...
-
+ )} + {!loadingNewer && hasNewer && ( +
loadPosts({ direction: 'newer' })}> + 查看更新的动态 +
+ )} + {posts.map((post, index) => { + return ( +
+
+
+
+ +
+
{post.nickname}
+
{formatTime(post.createTime)}
+
+
-
- {post.contentDesc &&
{post.contentDesc}
} +
+ {post.contentDesc &&
{post.contentDesc}
} - {post.type === 15 ? ( -
- [视频] -
- ) : post.media.length > 0 && ( -
- {post.media.map((m, idx) => ( - setPreviewImage(m.url)} /> - ))} + {post.type === 15 ? ( +
+ + 视频动态 +
+ ) : post.media.length > 0 && ( +
+ {post.media.map((m, idx) => ( + setPreviewImage(m.url)} /> + ))} +
+ )} +
+ + {(post.likes.length > 0 || post.comments.length > 0) && ( +
+ {post.likes.length > 0 && ( +
+ + + {post.likes.join('、')} + +
+ )} + + {post.comments.length > 0 && ( +
+ {post.comments.map((c, idx) => ( +
+ {c.nickname} + {c.refNickname && ( + <> + 回复 + {c.refNickname} + + )} + : + {c.content} +
+ ))} +
+ )} +
+ )} +
+
+ ) + })} +
+ + {loading &&
+ + 正在加载更多... +
} + {!hasMore && posts.length > 0 &&
已经到底啦
} + {!loading && posts.length === 0 && ( +
+
+

未找到相关动态

+ {(selectedUsernames.length > 0 || searchKeyword) && ( + )}
- - {(post.likes.length > 0 || post.comments.length > 0) && ( -
- {post.likes.length > 0 && ( -
- - - {post.likes.join('、')} - -
- )} - - {post.comments.length > 0 && ( -
- {post.comments.map((c, idx) => ( -
- {c.nickname} - {c.refNickname && ( - <> - 回复 - {c.refNickname} - - )} - : - {c.content} -
- ))} -
- )} -
- )} -
- ))} - - {loading &&
加载中...
} - {!hasMore && posts.length > 0 &&
没有更多了
} - {!loading && posts.length === 0 && ( -
-

没有找到符合条件的朋友圈

- {selectedUsernames.length > 0 && ( - - )} -
- )} + )} +
{previewImage && ( setPreviewImage(null)} /> )} + { + setShowJumpDialog(false) + }} + onSelect={(date) => { + setJumpTargetDate(date) + setShowJumpDialog(false) + }} + currentDate={jumpTargetDate || new Date()} + />
) } diff --git a/src/services/config.ts b/src/services/config.ts index 13b7583..063ff68 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -28,7 +28,8 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange', EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', - EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns' + EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', + EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns' } as const export interface WxidConfig { @@ -340,3 +341,14 @@ export async function getExportDefaultExcelCompactColumns(): Promise { await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled) } + +// 获取导出默认 TXT 列配置 +export async function getExportDefaultTxtColumns(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS) + return Array.isArray(value) ? (value as string[]) : null +} + +// 设置导出默认 TXT 列配置 +export async function setExportDefaultTxtColumns(columns: string[]): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns) +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 7893cf7..cce6785 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -100,6 +100,7 @@ export interface ElectronAPI { resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void + execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }> } image: { @@ -351,6 +352,7 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' }