diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index d90ea6b..15f5450 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -105,9 +105,13 @@ jobs: - name: Package macOS arm64 dev artifacts shell: bash run: | + set -euo pipefail export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}' + if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then + echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only." + npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}' + fi - name: Upload macOS arm64 assets to fixed release env: @@ -314,6 +318,9 @@ jobs: WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")" WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")" MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")" + if [ -z "$MAC_ASSET" ]; then + MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")" + fi LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")" LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")" diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index 94ad390..08b8556 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -134,9 +134,13 @@ jobs: CSC_IDENTITY_AUTO_DISCOVERY: "false" shell: bash run: | + set -euo pipefail export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}' + if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then + echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only." + npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}' + fi - name: Upload macOS arm64 assets to fixed preview release env: @@ -359,6 +363,9 @@ jobs: fi WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")" MAC_ASSET="$(pick_asset "[.]dmg$")" + if [ -z "$MAC_ASSET" ]; then + MAC_ASSET="$(pick_asset "[.]zip$")" + fi LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")" LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a257720..33ae8f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,9 +49,13 @@ jobs: CSC_IDENTITY_AUTO_DISCOVERY: "false" shell: bash run: | + set -euo pipefail export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - npx electron-builder --mac dmg zip --arm64 --publish always + if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then + echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only." + npx electron-builder --mac zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' + fi - name: Inject minimumVersion into latest yml env: @@ -114,7 +118,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --linux --publish always + npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' - name: Inject minimumVersion into latest yml env: @@ -167,7 +171,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}' + npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}' - name: Inject minimumVersion into latest yml env: @@ -220,7 +224,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' + npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' - name: Inject minimumVersion into latest yml env: @@ -274,6 +278,9 @@ jobs: fi WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')" MAC_ASSET="$(pick_asset "\\.dmg$")" + if [ -z "$MAC_ASSET" ]; then + MAC_ASSET="$(pick_asset "arm64\\.zip$")" + fi LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")" LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")" diff --git a/electron/main.ts b/electron/main.ts index 4d11ca1..5b3a16e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3555,6 +3555,24 @@ function registerIpcHandlers() { } }) + ipcMain.handle('annualReport:captureCurrentWindow', async (event) => { + try { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win || win.isDestroyed()) { + return { success: false, error: '窗口不可用' } + } + + const image = await win.webContents.capturePage() + return { + success: true, + dataUrl: image.toDataURL(), + size: image.getSize() + } + } catch (e) { + return { success: false, error: String(e) } + } + }) + // 密钥获取 ipcMain.handle('key:autoGetDbKey', async (event) => { return keyService.autoGetDbKey(180_000, (message: string, level: number) => { diff --git a/electron/preload.ts b/electron/preload.ts index 09126a7..1e05df2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -412,6 +412,7 @@ contextBridge.exposeInMainWorld('electronAPI', { generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year), exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => ipcRenderer.invoke('annualReport:exportImages', payload), + captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'), onAvailableYearsProgress: (callback: (payload: { taskId: string years?: number[] diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index 3020b4c..9686ec3 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -59,6 +59,8 @@ export interface AnnualReportData { initiatedChats: number receivedChats: number initiativeRate: number + topInitiatedFriend?: string + topInitiatedCount?: number } | null responseSpeed: { avgResponseTime: number @@ -1190,7 +1192,9 @@ class AnnualReportService { topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] } | undefined - const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime) + const snsBeginTime = isAllTime ? 0 : actualStartTime + const snsEndTime = isAllTime ? Math.floor(Date.now() / 1000) : actualEndTime + const snsStats = await wcdbService.getSnsAnnualStats(snsBeginTime, snsEndTime) if (snsStats.success && snsStats.data) { const d = snsStats.data @@ -1217,6 +1221,20 @@ class AnnualReportService { } } + // ALL YEARS 兼容:部分底层实现 begin/end 为 0 时会返回 0,兜底使用导出统计总数。 + if (isAllTime && (!snsStatsResult || Number(snsStatsResult.totalPosts || 0) <= 0)) { + const snsExportStats = await wcdbService.getSnsExportStats(cleanedWxid || rawWxid) + if (snsExportStats.success && snsExportStats.data) { + const fallbackTotalPosts = Math.max(0, Number(snsExportStats.data.totalPosts || 0)) + snsStatsResult = { + totalPosts: fallbackTotalPosts, + typeCounts: snsStatsResult?.typeCounts, + topLikers: snsStatsResult?.topLikers || [], + topLiked: snsStatsResult?.topLiked || [] + } + } + } + this.reportProgress('整理联系人信息...', 85, onProgress) const contactIds = Array.from(contactStats.keys()) @@ -1346,16 +1364,27 @@ class AnnualReportService { let socialInitiative: AnnualReportData['socialInitiative'] = null let totalInitiated = 0 let totalReceived = 0 - for (const stats of conversationStarts.values()) { + let topInitiatedSessionId = '' + let topInitiatedCount = 0 + for (const [sessionId, stats] of conversationStarts.entries()) { totalInitiated += stats.initiated totalReceived += stats.received + if (stats.initiated > topInitiatedCount) { + topInitiatedCount = stats.initiated + topInitiatedSessionId = sessionId + } } const totalConversations = totalInitiated + totalReceived if (totalConversations > 0) { + const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null socialInitiative = { initiatedChats: totalInitiated, receivedChats: totalReceived, - initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10 + initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10, + topInitiatedFriend: topInitiatedCount > 0 + ? (topInitiatedInfo?.displayName || topInitiatedSessionId) + : undefined, + topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined } } diff --git a/electron/services/config.ts b/electron/services/config.ts index a1066f6..35a382d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -85,6 +85,8 @@ interface ConfigSchema { aiInsightSilenceDays: number aiInsightAllowContext: boolean aiInsightAllowSocialContext: boolean + aiInsightFilterMode: 'whitelist' | 'blacklist' + aiInsightFilterList: string[] aiInsightWhitelistEnabled: boolean aiInsightWhitelist: string[] /** 活跃分析冷却时间(分钟),0 表示无冷却 */ @@ -202,6 +204,8 @@ export class ConfigService { aiInsightSilenceDays: 3, aiInsightAllowContext: false, aiInsightAllowSocialContext: false, + aiInsightFilterMode: 'whitelist', + aiInsightFilterList: [], aiInsightWhitelistEnabled: false, aiInsightWhitelist: [], aiInsightCooldownMinutes: 120, diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 3688afd..3f660d1 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -79,6 +79,9 @@ const MESSAGE_TYPE_MAP: Record = { 34: 2, // 语音 -> VOICE 43: 3, // 视频 -> VIDEO 49: 7, // 链接/文件 -> LINK (需要进一步判断) + 34359738417: 7, // 文件消息变体 -> LINK + 103079215153: 7, // 文件消息变体 -> LINK + 25769803825: 7, // 文件消息变体 -> LINK 47: 5, // 表情包 -> EMOJI 48: 8, // 位置 -> LOCATION 42: 27, // 名片 -> CONTACT @@ -86,9 +89,13 @@ const MESSAGE_TYPE_MAP: Record = { 10000: 80, // 系统消息 -> SYSTEM } +// 与 chatService 的资源消息识别保持一致,覆盖桌面微信里的多种文件消息 localType。 +const FILE_APP_LOCAL_TYPES = [49, 34359738417, 103079215153, 25769803825] as const +const FILE_APP_LOCAL_TYPE_SET = new Set(FILE_APP_LOCAL_TYPES) + export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' - contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' + contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string @@ -137,11 +144,19 @@ interface ExportDisplayProfile { } type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' -type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' +type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' | 'file' interface FileExportCandidate { sourcePath: string matchedBy: 'md5' | 'name' yearMonth?: string + preferredMonth?: boolean + mtimeMs: number + searchOrder: number +} +interface FileAttachmentSearchRoot { + accountDir: string + msgFileRoot?: string + fileStorageRoot?: string } export interface ExportProgress { @@ -501,6 +516,13 @@ class ExportService { .trim() } + private resolveFileAttachmentExtensionDir(msg: any, fileName: string): string { + const rawExt = String(msg?.fileExt || '').trim() || path.extname(String(fileName || '')) + const normalizedExt = rawExt.replace(/^\.+/, '').trim().toLowerCase() + const safeExt = this.sanitizeExportFileNamePart(normalizedExt).replace(/\s+/g, '_') + return safeExt || 'no-extension' + } + private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' { return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic' } @@ -947,7 +969,7 @@ class ExportService { private getMediaContentType(options: ExportOptions): MediaContentType | null { const value = options.contentType - if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') { + if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji' || value === 'file') { return value } return null @@ -963,15 +985,117 @@ class ExportService { if (mediaContentType === 'image') return new Set([3]) if (mediaContentType === 'video') return new Set([43]) if (mediaContentType === 'emoji') return new Set([47]) + if (mediaContentType === 'file') return new Set(FILE_APP_LOCAL_TYPES) const selected = new Set() if (options.exportImages) selected.add(3) if (options.exportVoices) selected.add(34) if (options.exportVideos) selected.add(43) - if (options.exportFiles) selected.add(49) + if (options.exportFiles) { + for (const fileType of FILE_APP_LOCAL_TYPES) { + selected.add(fileType) + } + } return selected } + private isFileAppLocalType(localType: number): boolean { + return FILE_APP_LOCAL_TYPE_SET.has(localType) + } + + private isFileOnlyMediaFilter(targetMediaTypes: Set | null): boolean { + return Boolean( + targetMediaTypes && + targetMediaTypes.size === FILE_APP_LOCAL_TYPES.length && + FILE_APP_LOCAL_TYPES.every((fileType) => targetMediaTypes.has(fileType)) + ) + } + + private getFileAppMessageHints(message: Record | null | undefined): { + xmlType?: string + fileName?: string + fileSize?: number + fileExt?: string + fileMd5?: string + } { + const xmlType = String(message?.xmlType ?? message?.xml_type ?? '').trim() || undefined + const fileName = String(message?.fileName ?? message?.file_name ?? '').trim() || undefined + const fileExt = String(message?.fileExt ?? message?.file_ext ?? '').trim() || undefined + const fileSizeRaw = Number(message?.fileSize ?? message?.file_size ?? message?.total_len ?? message?.totalLen ?? message?.totallen ?? 0) + const fileSize = Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : undefined + const fileMd5Raw = String(message?.fileMd5 ?? message?.file_md5 ?? '').trim() + const fileMd5 = /^[a-f0-9]{32}$/i.test(fileMd5Raw) ? fileMd5Raw.toLowerCase() : undefined + return { xmlType, fileName, fileSize, fileExt, fileMd5 } + } + + private hasFileAppMessageHints(message: Record | null | undefined): boolean { + const hints = this.getFileAppMessageHints(message) + if (hints.xmlType) return hints.xmlType === '6' + return Boolean(hints.fileName || hints.fileExt || hints.fileMd5 || hints.fileSize) + } + + private isFileAppMessage(msg: { + localType?: unknown + xmlType?: unknown + xml_type?: unknown + content?: unknown + fileName?: unknown + file_name?: unknown + fileSize?: unknown + file_size?: unknown + fileExt?: unknown + file_ext?: unknown + fileMd5?: unknown + file_md5?: unknown + }): boolean { + const { xmlType, fileName, fileExt, fileMd5, fileSize } = this.getFileAppMessageHints(msg as Record) + if (xmlType) return xmlType === '6' + if (fileName || fileExt || fileMd5 || fileSize) return true + + const normalized = this.normalizeAppMessageContent(String(msg?.content || '')) + if (!normalized || (!normalized.includes(''))) { + return false + } + return this.extractAppMessageType(normalized) === '6' + } + + private extractFileAppMessageMeta(content: string): { + xmlType?: string + fileName?: string + fileSize?: number + fileExt?: string + fileMd5?: string + } | null { + const normalized = this.normalizeAppMessageContent(content || '') + if (!normalized || (!normalized.includes(''))) { + return null + } + + const xmlType = this.extractAppMessageType(normalized) + if (!xmlType) return null + + const rawFileName = this.extractXmlValue(normalized, 'filename') || this.extractXmlValue(normalized, 'title') + const rawFileExt = this.extractXmlValue(normalized, 'fileext') + const rawFileSize = + this.extractXmlValue(normalized, 'totallen') || + this.extractXmlValue(normalized, 'datasize') || + this.extractXmlValue(normalized, 'filesize') + const rawFileMd5 = + this.extractXmlValue(normalized, 'md5') || + this.extractXmlAttribute(normalized, 'appattach', 'md5') || + this.extractLooseHexMd5(normalized) + const fileSize = Number.parseInt(rawFileSize, 10) + const fileMd5 = String(rawFileMd5 || '').trim() + + return { + xmlType, + fileName: this.decodeHtmlEntities(rawFileName).trim() || undefined, + fileSize: Number.isFinite(fileSize) && fileSize > 0 ? fileSize : undefined, + fileExt: this.decodeHtmlEntities(rawFileExt).trim() || undefined, + fileMd5: /^[a-f0-9]{32}$/i.test(fileMd5) ? fileMd5.toLowerCase() : undefined + } + } + private resolveCollectMode(options: ExportOptions): MessageCollectMode { if (this.isMediaContentBatchExport(options)) { return 'media-fast' @@ -1020,12 +1144,17 @@ class ExportService { return true } - private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set | null): boolean { - if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false + private shouldDecodeMessageContentInMediaMode( + localType: number, + targetMediaTypes: Set | null, + options?: { allowFileProbe?: boolean } + ): boolean { + const allowFileProbe = options?.allowFileProbe === true + if (!targetMediaTypes || (!targetMediaTypes.has(localType) && !allowFileProbe)) return false // 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容 if (localType === 34) return false - // 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl - if (localType === 3 || localType === 43 || localType === 47) return true + // 图片/视频/表情/文件可能需要从 XML 提取 md5/datName/附件信息 + if (localType === 3 || localType === 43 || localType === 47 || this.isFileAppLocalType(localType) || allowFileProbe) return true return false } @@ -3628,7 +3757,7 @@ class ExportService { ) } - if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') { + if (options.exportFiles && this.isFileAppMessage(msg)) { return this.exportFileAttachment( msg, mediaRootDir, @@ -4183,33 +4312,104 @@ class ExportService { return this.normalizeVideoFileToken(this.extractVideoMd5(content || '')) } - private resolveFileAttachmentRoots(): string[] { + private isFileAttachmentAccountDir(dirPath: string): boolean { + if (!dirPath) return false + return fs.existsSync(path.join(dirPath, 'db_storage')) || + fs.existsSync(path.join(dirPath, 'msg', 'file')) || + fs.existsSync(path.join(dirPath, 'FileStorage', 'File')) || + fs.existsSync(path.join(dirPath, 'FileStorage', 'Image')) || + fs.existsSync(path.join(dirPath, 'FileStorage', 'Image2')) + } + + private resolveAccountDirForFileExport(basePath: string, wxid: string): string | null { + const cleanedWxid = this.cleanAccountDirName(wxid) + if (!basePath || !cleanedWxid) return null + + const normalized = path.resolve(basePath.replace(/[\\/]+$/, '')) + const parentDir = path.dirname(normalized) + const dbStorageParent = path.basename(normalized).toLowerCase() === 'db_storage' + ? path.dirname(normalized) + : '' + const fileInsideDbStorageParent = path.basename(parentDir).toLowerCase() === 'db_storage' + ? path.dirname(parentDir) + : '' + const candidateBases = Array.from(new Set([ + normalized, + parentDir, + path.join(normalized, 'WeChat Files'), + path.join(parentDir, 'WeChat Files'), + dbStorageParent, + fileInsideDbStorageParent + ].filter(Boolean))) + + const lowerWxid = cleanedWxid.toLowerCase() + const tryResolveBase = (candidateBase: string): string | null => { + if (!candidateBase || !fs.existsSync(candidateBase)) return null + if (this.isFileAttachmentAccountDir(candidateBase)) return candidateBase + + const direct = path.join(candidateBase, cleanedWxid) + if (this.isFileAttachmentAccountDir(direct)) return direct + + try { + const entries = fs.readdirSync(candidateBase, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory()) continue + const lowerEntry = entry.name.toLowerCase() + if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { + const entryPath = path.join(candidateBase, entry.name) + if (this.isFileAttachmentAccountDir(entryPath)) { + return entryPath + } + } + } + } catch { + return null + } + + return null + } + + for (const candidateBase of candidateBases) { + const resolved = tryResolveBase(candidateBase) + if (resolved) return resolved + } + + return null + } + + private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] { const dbPath = String(this.configService.get('dbPath') || '').trim() const rawWxid = String(this.configService.get('myWxid') || '').trim() const cleanedWxid = this.cleanAccountDirName(rawWxid) if (!dbPath) return [] - const normalized = dbPath.replace(/[\\/]+$/, '') - const roots = new Set() - const tryAddRoot = (candidate: string) => { - const fileRoot = path.join(candidate, 'msg', 'file') - if (fs.existsSync(fileRoot)) { - roots.add(fileRoot) + const normalized = path.resolve(dbPath.replace(/[\\/]+$/, '')) + const accountDirs = new Set() + const maybeAddAccountDir = (candidate: string | null | undefined) => { + if (!candidate) return + const resolved = path.resolve(candidate) + if (this.isFileAttachmentAccountDir(resolved)) { + accountDirs.add(resolved) } } - tryAddRoot(normalized) - if (rawWxid) tryAddRoot(path.join(normalized, rawWxid)) - if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid)) + maybeAddAccountDir(normalized) + maybeAddAccountDir(path.dirname(normalized)) - const dbStoragePath = - this.resolveDbStoragePathForExport(normalized, cleanedWxid) || - this.resolveDbStoragePathForExport(normalized, rawWxid) - if (dbStoragePath) { - tryAddRoot(path.dirname(dbStoragePath)) + const wxidCandidates = Array.from(new Set([cleanedWxid, rawWxid].filter(Boolean))) + for (const wxid of wxidCandidates) { + maybeAddAccountDir(this.resolveAccountDirForFileExport(normalized, wxid)) } - return Array.from(roots) + return Array.from(accountDirs).map((accountDir) => { + const msgFileRoot = path.join(accountDir, 'msg', 'file') + const fileStorageRoot = path.join(accountDir, 'FileStorage', 'File') + return { + accountDir, + msgFileRoot: fs.existsSync(msgFileRoot) ? msgFileRoot : undefined, + fileStorageRoot: fs.existsSync(fileStorageRoot) ? fileStorageRoot : undefined + } + }).filter((root) => Boolean(root.msgFileRoot || root.fileStorageRoot)) } private buildPreferredFileYearMonths(createTime?: unknown): string[] { @@ -4241,52 +4441,147 @@ class ExportService { } } - private async resolveFileAttachmentCandidates(msg: any): Promise { - const fileName = String(msg?.fileName || '').trim() - if (!fileName) return [] + private collectFileStorageCandidatesByName(rootDir: string, fileName: string, maxDepth = 3): string[] { + const normalizedName = String(fileName || '').trim().toLowerCase() + if (!rootDir || !normalizedName) return [] - const roots = this.resolveFileAttachmentRoots() - if (roots.length === 0) return [] + const matches: string[] = [] + const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }] - const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() - const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime) - const candidates: FileExportCandidate[] = [] - const seen = new Set() - - for (const root of roots) { - let monthDirs: string[] = [] + while (stack.length > 0) { + const current = stack.pop()! + let entries: fs.Dirent[] try { - monthDirs = fs.readdirSync(root) - .filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry))) - .sort() + entries = fs.readdirSync(current.dir, { withFileTypes: true }) } catch { continue } - const orderedMonths = Array.from(new Set([ - ...preferredMonths, - ...monthDirs.slice().reverse() - ])) - - for (const month of orderedMonths) { - const sourcePath = path.join(root, month, fileName) - if (!fs.existsSync(sourcePath)) continue - const resolvedPath = path.resolve(sourcePath) - if (seen.has(resolvedPath)) continue - seen.add(resolvedPath) - - if (normalizedMd5) { - const ok = await this.verifyFileHash(resolvedPath, normalizedMd5) - if (ok) { - candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month }) - continue - } + for (const entry of entries) { + const entryPath = path.join(current.dir, entry.name) + if (entry.isFile() && entry.name.toLowerCase() === normalizedName) { + matches.push(entryPath) + continue + } + if (entry.isDirectory() && current.depth < maxDepth) { + stack.push({ dir: entryPath, depth: current.depth + 1 }) } - - candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month }) } } + return matches + } + + private getFileAttachmentLogContext(msg: any): Record { + return { + localId: msg?.localId, + createTime: msg?.createTime, + localType: msg?.localType, + xmlType: msg?.xmlType, + fileName: msg?.fileName, + fileMd5: msg?.fileMd5 + } + } + + private logFileAttachmentEvent( + level: 'warn' | 'error', + action: string, + msg: any, + extra: Record = {} + ): void { + const logger = level === 'error' ? console.error : console.warn + logger(`[Export][File] ${action}`, { + ...this.getFileAttachmentLogContext(msg), + ...extra + }) + } + + private recordFileAttachmentMiss(msg: any, action: string, extra: Record = {}): void { + this.logFileAttachmentEvent('warn', action, msg, extra) + this.noteMediaTelemetry({ cacheMissFiles: 1 }) + } + + private async resolveFileAttachmentCandidates(msg: any): Promise { + const fileName = String(msg?.fileName || '').trim() + if (!fileName) return [] + + const roots = this.resolveFileAttachmentSearchRoots() + if (roots.length === 0) return [] + + const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() + const preferredMonths = new Set(this.buildPreferredFileYearMonths(msg?.createTime)) + const candidates: FileExportCandidate[] = [] + const seen = new Set() + let searchOrder = 0 + + const appendCandidate = async (sourcePath: string, yearMonth?: string) => { + if (!sourcePath || !fs.existsSync(sourcePath)) return + + const resolvedPath = path.resolve(sourcePath) + if (seen.has(resolvedPath)) return + + let stat: fs.Stats + try { + stat = await fs.promises.stat(resolvedPath) + } catch { + return + } + if (!stat.isFile()) return + + seen.add(resolvedPath) + const matchedBy = normalizedMd5 && await this.verifyFileHash(resolvedPath, normalizedMd5) ? 'md5' : 'name' + candidates.push({ + sourcePath: resolvedPath, + matchedBy, + yearMonth, + preferredMonth: Boolean(yearMonth && preferredMonths.has(yearMonth)), + mtimeMs: Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : 0, + searchOrder: searchOrder++ + }) + } + + for (const root of roots) { + if (root.msgFileRoot) { + for (const month of preferredMonths) { + await appendCandidate(path.join(root.msgFileRoot, month, fileName), month) + } + + let monthDirs: string[] = [] + try { + monthDirs = fs.readdirSync(root.msgFileRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name) && !preferredMonths.has(entry.name)) + .map(entry => entry.name) + .sort() + } catch { + monthDirs = [] + } + + for (const month of monthDirs) { + await appendCandidate(path.join(root.msgFileRoot, month, fileName), month) + } + await appendCandidate(path.join(root.msgFileRoot, fileName)) + } + + if (root.fileStorageRoot) { + for (const candidatePath of this.collectFileStorageCandidatesByName(root.fileStorageRoot, fileName, 3)) { + await appendCandidate(candidatePath) + } + } + } + + candidates.sort((left, right) => { + if (left.matchedBy !== right.matchedBy) { + return left.matchedBy === 'md5' ? -1 : 1 + } + if (left.preferredMonth !== right.preferredMonth) { + return left.preferredMonth ? -1 : 1 + } + if (left.mtimeMs !== right.mtimeMs) { + return right.mtimeMs - left.mtimeMs + } + return left.searchOrder - right.searchOrder + }) + return candidates } @@ -4301,14 +4596,20 @@ class ExportService { const fileNameRaw = String(msg?.fileName || '').trim() if (!fileNameRaw) return null - const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files') - if (!dirCache?.has(filesDir)) { - await fs.promises.mkdir(filesDir, { recursive: true }) - dirCache?.add(filesDir) + const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw) + const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir) + if (!dirCache?.has(fileDir)) { + await fs.promises.mkdir(fileDir, { recursive: true }) + dirCache?.add(fileDir) } const candidates = await this.resolveFileAttachmentCandidates(msg) - if (candidates.length === 0) return null + if (candidates.length === 0) { + this.recordFileAttachmentMiss(msg, '附件候选未命中', { + searchRoots: this.resolveFileAttachmentSearchRoots().map(root => root.accountDir) + }) + return null + } const maxBytes = Number.isFinite(maxFileSizeMb) ? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024)) @@ -4316,28 +4617,54 @@ class ExportService { const selected = candidates[0] const stat = await fs.promises.stat(selected.sourcePath) - if (!stat.isFile()) return null - if (maxBytes > 0 && stat.size > maxBytes) return null + if (!stat.isFile()) { + this.recordFileAttachmentMiss(msg, '附件候选不是普通文件', { + sourcePath: selected.sourcePath + }) + return null + } + if (maxBytes > 0 && stat.size > maxBytes) { + this.recordFileAttachmentMiss(msg, '附件超过大小限制', { + sourcePath: selected.sourcePath, + size: stat.size, + maxBytes + }) + return null + } const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() if (normalizedMd5 && selected.matchedBy !== 'md5') { - const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5) - if (!verified) return null + this.recordFileAttachmentMiss(msg, '附件哈希校验失败', { + sourcePath: selected.sourcePath, + expectedMd5: normalizedMd5 + }) + return null } const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file' const messageId = String(msg?.localId || Date.now()) const destFileName = `${messageId}_${safeBaseName}` - const destPath = path.join(filesDir, destFileName) + const destPath = path.join(fileDir, destFileName) const copied = await this.copyFileOptimized(selected.sourcePath, destPath) - if (!copied.success) return null + if (!copied.success) { + this.recordFileAttachmentMiss(msg, '附件复制失败', { + sourcePath: selected.sourcePath, + destPath, + code: copied.code + }) + return null + } this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size }) return { - relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName), + relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName), kind: 'file' } - } catch { + } catch (error) { + this.logFileAttachmentEvent('error', '附件导出异常', msg, { + error: error instanceof Error ? error.message : String(error || 'unknown') + }) + this.noteMediaTelemetry({ cacheMissFiles: 1 }) return null } } @@ -4420,6 +4747,38 @@ class ExportService { return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix } } + private collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] { + if (!this.isMediaExportEnabled(options)) return [] + + return messages.filter((msg) => { + const localType = Number(msg?.localType || 0) + return (localType === 3 && options.exportImages) || + (localType === 47 && options.exportEmojis) || + (localType === 43 && options.exportVideos) || + (localType === 34 && options.exportVoices) || + (options.exportFiles === true && this.isFileAppMessage(msg)) + }) + } + + private getMediaDoneFilesCount(): number { + return this.mediaExportTelemetry?.doneFiles ?? 0 + } + + private buildFileOnlyExportFailure( + options: ExportOptions, + mediaMessages: any[], + beforeDoneFiles: number + ): { success: boolean; error?: string } | null { + if (options.contentType !== 'file') return null + if (!mediaMessages.some(msg => this.isFileAppMessage(msg))) return null + if (this.getMediaDoneFilesCount() > beforeDoneFiles) return null + + return { + success: false, + error: '检测到文件消息,但未找到可导出的源文件,请检查数据库路径或文件存储目录配置' + } + } + /** * 下载文件 */ @@ -4485,6 +4844,7 @@ class ExportService { const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0 ? targetMediaTypes : null + const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(mediaTypeFilter) // 修复时间范围:0 表示不限制,而不是时间戳 0 const beginTime = dateRange?.start || 0 @@ -4545,12 +4905,14 @@ class ExportService { const localType = this.getIntFromRow(row, [ 'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type' ], 1) - if (mediaTypeFilter && !mediaTypeFilter.has(localType)) { + const rowFileHints = this.getFileAppMessageHints(row) + const allowFileProbe = fileOnlyMediaFilter && this.hasFileAppMessageHints(row) + if (mediaTypeFilter && !mediaTypeFilter.has(localType) && !allowFileProbe) { continue } const shouldDecodeContent = collectMode === 'full' || (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType)) - || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter)) + || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter, { allowFileProbe })) const content = shouldDecodeContent ? this.decodeMessageContent(row.message_content, row.compress_content) : '' @@ -4619,6 +4981,11 @@ class ExportService { let locationLabel: string | undefined let chatRecordList: any[] | undefined let emojiCaption: string | undefined + let xmlType: string | undefined + let fileName: string | undefined + let fileSize: number | undefined + let fileExt: string | undefined + let fileMd5: string | undefined if (localType === 48 && content) { const locationMeta = this.extractLocationMeta(content, localType) @@ -4649,6 +5016,22 @@ class ExportService { imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined videoMd5 = this.extractVideoFileNameFromRow(row, content) + xmlType = rowFileHints.xmlType + fileName = rowFileHints.fileName + fileExt = rowFileHints.fileExt + fileSize = rowFileHints.fileSize + fileMd5 = rowFileHints.fileMd5 + + if (content && (this.isFileAppLocalType(localType) || allowFileProbe || this.hasFileAppMessageHints({ xmlType, fileName, fileSize, fileExt, fileMd5 }))) { + const fileMeta = this.extractFileAppMessageMeta(content) + if (fileMeta) { + xmlType = fileMeta.xmlType || xmlType + fileName = fileMeta.fileName || fileName + fileSize = fileMeta.fileSize || fileSize + fileExt = fileMeta.fileExt || fileExt + fileMd5 = fileMeta.fileMd5 || fileMd5 + } + } if (localType === 3 && content) { // 图片消息 @@ -4667,6 +5050,10 @@ class ExportService { } } + if (fileOnlyMediaFilter && !this.isFileAppMessage({ localType, xmlType, content, fileName, fileExt, fileMd5, fileSize })) { + continue + } + rows.push({ localId, serverId, @@ -4682,6 +5069,11 @@ class ExportService { emojiMd5, emojiCaption, videoMd5, + xmlType, + fileName, + fileSize, + fileExt, + fileMd5, locationLat, locationLng, locationPoiname, @@ -4746,7 +5138,12 @@ class ExportService { targetMediaTypes: Set, control?: ExportTaskControl ): Promise { + const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(targetMediaTypes) const needsBackfill = rows.filter((msg) => { + const isFileCandidate = this.isFileAppLocalType(Number(msg.localType || 0)) || (fileOnlyMediaFilter && this.hasFileAppMessageHints(msg)) + if (isFileCandidate) { + return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt + } if (!targetMediaTypes.has(msg.localType)) return false if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName if (msg.localType === 47) return !msg.emojiMd5 @@ -4803,6 +5200,24 @@ class ExportService { if (msg.localType === 43) { const videoMd5 = this.extractVideoFileNameFromRow(row, content) if (videoMd5) msg.videoMd5 = videoMd5 + return + } + + if (this.isFileAppLocalType(Number(msg.localType || 0)) || this.hasFileAppMessageHints(msg)) { + const rowFileHints = this.getFileAppMessageHints(row) + const fileMeta = this.extractFileAppMessageMeta(content) + const mergedFileMeta = { + xmlType: fileMeta?.xmlType || rowFileHints.xmlType, + fileName: fileMeta?.fileName || rowFileHints.fileName, + fileSize: fileMeta?.fileSize || rowFileHints.fileSize, + fileExt: fileMeta?.fileExt || rowFileHints.fileExt, + fileMd5: fileMeta?.fileMd5 || rowFileHints.fileMd5 + } + if (mergedFileMeta.xmlType) msg.xmlType = mergedFileMeta.xmlType + if (mergedFileMeta.fileName) msg.fileName = mergedFileMeta.fileName + if (mergedFileMeta.fileSize) msg.fileSize = mergedFileMeta.fileSize + if (mergedFileMeta.fileExt) msg.fileExt = mergedFileMeta.fileExt + if (mergedFileMeta.fileMd5) msg.fileMd5 = mergedFileMeta.fileMd5 } } catch (error) { // 详情补取失败时保持降级导出(占位符),避免中断整批任务。 @@ -5329,19 +5744,11 @@ class ExportService { const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) // ========== 阶段1:并行导出媒体文件 ========== - const mediaMessages = exportMediaEnabled - ? allMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || // 图片 - (t === 47 && options.exportEmojis) || // 表情 - (t === 43 && options.exportVideos) || // 视频 - (t === 34 && options.exportVoices) || // 语音文件 - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(allMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -5400,6 +5807,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure // ========== 阶段2:并行语音转文字 ========== const voiceTranscriptMap = new Map() @@ -5840,19 +6249,11 @@ class ExportService { const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) // ========== 阶段1:并行导出媒体文件 ========== - const mediaMessages = exportMediaEnabled - ? collected.rows.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) || - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(collected.rows, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -5910,6 +6311,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure // ========== 阶段2:并行语音转文字 ========== const voiceTranscriptMap = new Map() @@ -6711,19 +7114,11 @@ class ExportService { 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 === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) || - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -6781,6 +7176,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure // ========== 并行预处理:语音转文字 ========== const voiceTranscriptMap = new Map() @@ -7461,19 +7858,11 @@ class ExportService { 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 === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) || - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -7531,6 +7920,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure const voiceTranscriptMap = new Map() @@ -7840,19 +8231,11 @@ class ExportService { } 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 === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) || - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -7910,6 +8293,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure const voiceTranscriptMap = new Map() @@ -8263,18 +8648,11 @@ class ExportService { 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 && options.exportVideos) - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -8333,6 +8711,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure const useVoiceTranscript = options.exportVoiceAsText === true const voiceMessages = useVoiceTranscript @@ -9051,7 +9431,7 @@ class ExportService { : options const exportMediaEnabled = effectiveOptions.exportMedia === true && - Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis) + Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles) attachMediaTelemetry = exportMediaEnabled if (exportMediaEnabled) { this.triggerMediaFileCacheCleanup() diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index c552ea1..90c5f81 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -104,8 +104,6 @@ export class ImageDecryptService { const timestamp = new Date().toISOString() const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` - - // 只写入文件,不输出到控制台 this.writeLog(logLine) } @@ -115,11 +113,7 @@ export class ImageDecryptService { const errorStr = error ? ` Error: ${String(error)}` : '' const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n` - - // 同时输出到控制台 console.error(message, error, meta) - - // 写入日志文件 this.writeLog(logLine) } @@ -143,7 +137,7 @@ export class ImageDecryptService { } for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) - if (cached && existsSync(cached) && this.isImageFile(cached)) { + if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) { const upgraded = !this.isHdPath(cached) ? await this.tryPromoteThumbnailCache(payload, key, cached) : null @@ -161,7 +155,7 @@ export class ImageDecryptService { this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath)) return { success: true, localPath, hasUpdate } } - if (cached && !this.isImageFile(cached)) { + if (cached && !this.isUsableImageCacheFile(cached)) { this.resolvedCache.delete(key) } } @@ -219,7 +213,7 @@ export class ImageDecryptService { if (payload.force) { for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) - if (cached && existsSync(cached) && this.isImageFile(cached) && this.isHdPath(cached)) { + if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached) && this.isHdPath(cached)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) @@ -227,7 +221,7 @@ export class ImageDecryptService { this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') return { success: true, localPath } } - if (cached && !this.isImageFile(cached)) { + if (cached && !this.isUsableImageCacheFile(cached)) { this.resolvedCache.delete(key) } } @@ -236,7 +230,7 @@ export class ImageDecryptService { if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) - if (cached && existsSync(cached) && this.isImageFile(cached)) { + if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) { const upgraded = !this.isHdPath(cached) ? await this.tryPromoteThumbnailCache(payload, cacheKey, cached) : null @@ -246,7 +240,7 @@ export class ImageDecryptService { this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') return { success: true, localPath } } - if (cached && !this.isImageFile(cached)) { + if (cached && !this.isUsableImageCacheFile(cached)) { this.resolvedCache.delete(cacheKey) } } @@ -1404,7 +1398,8 @@ export class ImageDecryptService { private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null { const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd) for (const candidate of candidates) { - if (existsSync(candidate)) return candidate + if (!existsSync(candidate)) continue + if (this.isUsableImageCacheFile(candidate)) return candidate } return null } @@ -1630,6 +1625,73 @@ export class ImageDecryptService { return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp' } + private isUsableImageCacheFile(filePath: string): boolean { + if (!this.isImageFile(filePath)) return false + if (!existsSync(filePath)) return false + if (this.isLikelyCorruptedDecodedImage(filePath)) { + this.logInfo('[ImageDecrypt] 跳过疑似损坏缓存文件', { filePath }) + void rm(filePath, { force: true }).catch(() => { }) + return false + } + return true + } + + private isLikelyCorruptedDecodedImage(filePath: string): boolean { + try { + const ext = extname(filePath).toLowerCase() + if (ext !== '.jpg' && ext !== '.jpeg') return false + const data = readFileSync(filePath) + return this.isLikelyCorruptedJpegBuffer(data) + } catch { + return false + } + } + + private isLikelyCorruptedJpegBuffer(data: Buffer): boolean { + if (data.length < 4096) return false + let zeroCount = 0 + for (let i = 0; i < data.length; i += 1) { + if (data[i] === 0x00) zeroCount += 1 + } + const zeroRatio = zeroCount / data.length + if (zeroRatio >= 0.985) return true + + const hasLavcTag = data.length >= 24 && data.subarray(0, 24).includes(Buffer.from('Lavc')) + if (!hasLavcTag) return false + + // JPEG 扫描段若几乎全是 0,通常表示解码失败但被编码器强行输出。 + let sosPos = -1 + for (let i = 2; i < data.length - 1; i += 1) { + if (data[i] === 0xff && data[i + 1] === 0xda) { + sosPos = i + break + } + } + if (sosPos < 0 || sosPos + 4 >= data.length) return zeroRatio >= 0.95 + + const sosLength = (data[sosPos + 2] << 8) | data[sosPos + 3] + const scanStart = sosPos + 2 + sosLength + if (scanStart >= data.length - 2) return zeroRatio >= 0.95 + + let eoiPos = -1 + for (let i = data.length - 2; i >= scanStart; i -= 1) { + if (data[i] === 0xff && data[i + 1] === 0xd9) { + eoiPos = i + break + } + } + if (eoiPos < 0 || eoiPos <= scanStart) return zeroRatio >= 0.95 + + const scanData = data.subarray(scanStart, eoiPos) + if (scanData.length < 1024) return zeroRatio >= 0.95 + let scanZeroCount = 0 + for (let i = 0; i < scanData.length; i += 1) { + if (scanData[i] === 0x00) scanZeroCount += 1 + } + const scanZeroRatio = scanZeroCount / scanData.length + return scanZeroRatio >= 0.985 + } + /** * 解包 wxgf 格式 * wxgf 是微信的图片格式,内部使用 HEVC 编码 @@ -1653,41 +1715,96 @@ export class ImageDecryptService { } } - // 提取 HEVC NALU 裸流 - const hevcData = this.extractHevcNalu(buffer) - // 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据 - const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4) + const hevcCandidates = this.buildWxgfHevcCandidates(buffer) this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', { - naluExtracted: !!(hevcData && hevcData.length >= 100), - feedSize: feedData.length + candidateCount: hevcCandidates.length, + candidates: hevcCandidates.map((item) => `${item.name}:${item.data.length}`) }) - // 尝试用 ffmpeg 转换 - try { - const jpgData = await this.convertHevcToJpg(feedData) - if (jpgData && jpgData.length > 0) { + for (const candidate of hevcCandidates) { + try { + const jpgData = await this.convertHevcToJpg(candidate.data) + if (!jpgData || jpgData.length === 0) continue return { data: jpgData, isWxgf: false } + } catch (e) { + this.logError('unwrapWxgf: 候选流转换失败', e, { candidate: candidate.name }) } - } catch (e) { - this.logError('unwrapWxgf: ffmpeg 转换失败', e) } - return { data: feedData, isWxgf: true } + const fallback = hevcCandidates[0]?.data || buffer.subarray(4) + return { data: fallback, isWxgf: true } } - /** - * 从 wxgf 数据中提取 HEVC NALU 裸流 - */ - private extractHevcNalu(buffer: Buffer): Buffer | null { + private buildWxgfHevcCandidates(buffer: Buffer): Array<{ name: string; data: Buffer }> { + const units = this.extractHevcNaluUnits(buffer) + const candidates: Array<{ name: string; data: Buffer }> = [] + + const addCandidate = (name: string, data: Buffer | null | undefined): void => { + if (!data || data.length < 100) return + if (candidates.some((item) => item.data.equals(data))) return + candidates.push({ name, data }) + } + + // 1) 优先尝试按 VPS(32) 分组后的候选流 + const vpsStarts: number[] = [] + for (let i = 0; i < units.length; i += 1) { + const unit = units[i] + if (!unit || unit.length < 2) continue + const type = (unit[0] >> 1) & 0x3f + if (type === 32) vpsStarts.push(i) + } + const groups: Array<{ index: number; data: Buffer; size: number }> = [] + for (let i = 0; i < vpsStarts.length; i += 1) { + const start = vpsStarts[i] + const end = i + 1 < vpsStarts.length ? vpsStarts[i + 1] : units.length + const groupUnits = units.slice(start, end) + if (groupUnits.length === 0) continue + let hasVcl = false + for (const unit of groupUnits) { + if (!unit || unit.length < 2) continue + const type = (unit[0] >> 1) & 0x3f + if (type === 19 || type === 20 || type === 1) { + hasVcl = true + break + } + } + if (!hasVcl) continue + const merged = this.mergeHevcNaluUnits(groupUnits) + groups.push({ index: i, data: merged, size: merged.length }) + } + groups.sort((a, b) => b.size - a.size) + for (const group of groups) { + addCandidate(`group_${group.index}`, group.data) + } + + // 2) 全量扫描提取流 + addCandidate('scan_all_nalus', this.mergeHevcNaluUnits(units)) + + // 3) 兜底:直接跳过 wxgf 头喂 ffmpeg + addCandidate('raw_skip4', buffer.subarray(4)) + + return candidates + } + + private mergeHevcNaluUnits(units: Buffer[]): Buffer { + if (!Array.isArray(units) || units.length === 0) return Buffer.alloc(0) + const merged: Buffer[] = [] + for (const unit of units) { + if (!unit || unit.length < 2) continue + merged.push(Buffer.from([0x00, 0x00, 0x00, 0x01])) + merged.push(unit) + } + return Buffer.concat(merged) + } + + private extractHevcNaluUnits(buffer: Buffer): Buffer[] { const starts: number[] = [] let i = 4 - while (i < buffer.length - 3) { const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 && buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01 const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 && buffer[i + 2] === 0x01 - if (hasPrefix4 || hasPrefix3) { starts.push(i) i += hasPrefix4 ? 4 : 3 @@ -1695,10 +1812,11 @@ export class ImageDecryptService { } i += 1 } + if (starts.length === 0) return [] - if (starts.length === 0) return null - - const nalUnits: Buffer[] = [] + const units: Buffer[] = [] + let keptUnits = 0 + let droppedUnits = 0 for (let index = 0; index < starts.length; index += 1) { const start = starts[index] const end = index + 1 < starts.length ? starts[index + 1] : buffer.length @@ -1707,12 +1825,29 @@ export class ImageDecryptService { const prefixLength = hasPrefix4 ? 4 : 3 const payloadStart = start + prefixLength if (payloadStart >= end) continue - nalUnits.push(Buffer.from([0x00, 0x00, 0x00, 0x01])) - nalUnits.push(buffer.subarray(payloadStart, end)) + const payload = buffer.subarray(payloadStart, end) + if (payload.length < 2) { + droppedUnits += 1 + continue + } + if ((payload[0] & 0x80) !== 0) { + droppedUnits += 1 + continue + } + units.push(payload) + keptUnits += 1 } + return units + } - if (nalUnits.length === 0) return null - return Buffer.concat(nalUnits) + /** + * 从 wxgf 数据中提取 HEVC NALU 裸流 + */ + private extractHevcNalu(buffer: Buffer): Buffer | null { + const units = this.extractHevcNaluUnits(buffer) + if (units.length === 0) return null + const merged = this.mergeHevcNaluUnits(units) + return merged.length > 0 ? merged : null } /** @@ -1747,18 +1882,26 @@ export class ImageDecryptService { await writeFile(tmpInput, hevcData) // 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测 - const attempts: { label: string; inputArgs: string[] }[] = [ - { label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] }, - { label: 'h265 raw', inputArgs: ['-f', 'h265', '-i', tmpInput] }, - { label: 'auto detect', inputArgs: ['-i', tmpInput] }, + const attempts: { label: string; inputArgs: string[]; outputArgs?: string[] }[] = [ + { label: 'hevc raw frame0', inputArgs: ['-f', 'hevc', '-i', tmpInput] }, + { label: 'hevc raw frame1', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] }, + { label: 'hevc raw frame5', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] }, + { label: 'h265 raw frame0', inputArgs: ['-f', 'h265', '-i', tmpInput] }, + { label: 'h265 raw frame1', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] }, + { label: 'h265 raw frame5', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] }, + { label: 'auto detect frame0', inputArgs: ['-i', tmpInput] }, + { label: 'auto detect frame1', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] }, + { label: 'auto detect frame5', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] }, ] for (const attempt of attempts) { // 清理上一轮的输出 try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} - const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label) - if (result) return result + const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label, attempt.outputArgs) + if (!result) continue + if (this.isLikelyCorruptedJpegBuffer(result)) continue + return result } return null @@ -1771,7 +1914,13 @@ export class ImageDecryptService { } } - private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise { + private runFfmpegConvert( + ffmpeg: string, + inputArgs: string[], + tmpOutput: string, + label: string, + outputArgs?: string[] + ): Promise { return new Promise((resolve) => { const { spawn } = require('child_process') const errChunks: Buffer[] = [] @@ -1780,6 +1929,7 @@ export class ImageDecryptService { '-hide_banner', '-loglevel', 'error', '-y', ...inputArgs, + ...(outputArgs || []), '-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput ] this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') }) diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index f1ee5b4..0566571 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -50,6 +50,8 @@ const INSIGHT_CONFIG_KEYS = new Set([ 'aiModelApiKey', 'aiModelApiModel', 'aiModelApiMaxTokens', + 'aiInsightFilterMode', + 'aiInsightFilterList', 'aiInsightAllowSocialContext', 'aiInsightSocialContextCount', 'aiInsightWeiboCookie', @@ -73,6 +75,8 @@ interface SharedAiModelConfig { maxTokens: number } +type InsightFilterMode = 'whitelist' | 'blacklist' + // ─── 日志 ───────────────────────────────────────────────────────────────────── type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR' @@ -196,6 +200,11 @@ function normalizeApiMaxTokens(value: unknown): number { return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric))) } +function normalizeSessionIdList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) +} + /** * 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。 * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 @@ -495,7 +504,7 @@ class InsightService { return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id) }) if (!session) { - return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊)' } + return { success: false, message: '未找到任何可触发的私聊会话(请检查黑白名单模式与选择列表)' } } const sessionId = session.username?.trim() || '' const displayName = session.displayName || sessionId @@ -747,14 +756,23 @@ ${topMentionText} /** * 判断某个会话是否允许触发见解。 - * 若白名单未启用,则所有私聊会话均允许; - * 若白名单已启用,则只有在白名单中的会话才允许。 + * white/black 模式二选一: + * - whitelist:仅名单内允许 + * - blacklist:名单内屏蔽,其他允许 */ + private getInsightFilterConfig(): { mode: InsightFilterMode; list: string[] } { + const modeRaw = String(this.config.get('aiInsightFilterMode') || '').trim().toLowerCase() + const mode: InsightFilterMode = modeRaw === 'blacklist' ? 'blacklist' : 'whitelist' + const list = normalizeSessionIdList(this.config.get('aiInsightFilterList')) + return { mode, list } + } + private isSessionAllowed(sessionId: string): boolean { - const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean - if (!whitelistEnabled) return true - const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || [] - return whitelist.includes(sessionId) + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return false + const { mode, list } = this.getInsightFilterConfig() + if (mode === 'whitelist') return list.includes(normalizedSessionId) + return !list.includes(normalizedSessionId) } /** @@ -966,8 +984,8 @@ ${topMentionText} * 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新) * 2. 该会话距上次活跃分析已超过冷却期 * - * 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。 - * 白名单未启用时:从缓存拉取全量会话后过滤私聊。 + * whitelist 模式:直接使用名单里的 sessionId,完全跳过 getSessions()。 + * blacklist 模式:从缓存拉取会话后过滤名单。 */ private async analyzeRecentActivity(): Promise { if (!this.isEnabled()) return @@ -978,12 +996,11 @@ ${topMentionText} const now = Date.now() const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120 const cooldownMs = cooldownMinutes * 60 * 1000 - const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean - const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || [] + const { mode: filterMode, list: filterList } = this.getInsightFilterConfig() - // 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。 + // whitelist 模式且有勾选项时,直接用名单 sessionId,无需查数据库全量会话列表。 // 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。 - if (whitelistEnabled && whitelist.length > 0) { + if (filterMode === 'whitelist' && filterList.length > 0) { // 确保数据库已连接(首次时连接,之后复用) if (!this.dbConnected) { const connectResult = await chatService.connect() @@ -991,8 +1008,8 @@ ${topMentionText} this.dbConnected = true } - for (const sessionId of whitelist) { - if (!sessionId || sessionId.endsWith('@chatroom')) continue + for (const sessionId of filterList) { + if (!sessionId || sessionId.toLowerCase().includes('placeholder')) continue // 冷却期检查(先过滤,减少不必要的 DB 查询) if (cooldownMs > 0) { @@ -1029,16 +1046,22 @@ ${topMentionText} return } - // 白名单未启用:需要拉取全量会话列表,从中过滤私聊 + if (filterMode === 'whitelist' && filterList.length === 0) { + insightLog('INFO', '白名单模式且名单为空,跳过活跃分析') + return + } + + // blacklist 模式:拉取会话缓存后按过滤规则筛选 const sessions = await this.getSessionsCached() if (sessions.length === 0) return - const privateSessions = sessions.filter((s) => { + const candidateSessions = sessions.filter((s) => { const id = s.username?.trim() || '' - return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') + if (!id || id.toLowerCase().includes('placeholder')) return false + return this.isSessionAllowed(id) }) - for (const session of privateSessions.slice(0, 10)) { + for (const session of candidateSessions.slice(0, 10)) { const sessionId = session.username?.trim() || '' if (!sessionId) continue diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index d4c77ef..2f1957f 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -25,9 +25,7 @@ export class WcdbService { private logEnabled = false private monitorListener: ((type: string, json: string) => void) | null = null - constructor() { - this.initWorker() - } + constructor() {} /** * 初始化 Worker 线程 diff --git a/package.json b/package.json index 05ef287..7c6f375 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,13 @@ }, "//": "二改不应改变此处的作者与应用信息", "scripts": { - "postinstall": "electron-builder install-app-deps", + "postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs", "rebuild": "electron-rebuild", - "dev": "vite", + "dev": "node scripts/prepare-electron-runtime.cjs && vite", "typecheck": "tsc --noEmit", "build": "tsc && vite build && electron-builder", "preview": "vite preview", - "electron:dev": "vite --mode electron", + "electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron", "electron:build": "npm run build" }, "dependencies": { diff --git a/resources/fonts/annual-report/CormorantGaramond-Var.ttf b/resources/fonts/annual-report/CormorantGaramond-Var.ttf new file mode 100644 index 0000000..d992a83 Binary files /dev/null and b/resources/fonts/annual-report/CormorantGaramond-Var.ttf differ diff --git a/resources/fonts/annual-report/Inter-Var.ttf b/resources/fonts/annual-report/Inter-Var.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/resources/fonts/annual-report/Inter-Var.ttf differ diff --git a/resources/fonts/annual-report/NotoSerifSC-Var.ttf b/resources/fonts/annual-report/NotoSerifSC-Var.ttf new file mode 100644 index 0000000..eab063f Binary files /dev/null and b/resources/fonts/annual-report/NotoSerifSC-Var.ttf differ diff --git a/resources/fonts/annual-report/PlayfairDisplay-Var.ttf b/resources/fonts/annual-report/PlayfairDisplay-Var.ttf new file mode 100644 index 0000000..7a09eb7 Binary files /dev/null and b/resources/fonts/annual-report/PlayfairDisplay-Var.ttf differ diff --git a/resources/fonts/annual-report/SpaceMono-Bold.ttf b/resources/fonts/annual-report/SpaceMono-Bold.ttf new file mode 100644 index 0000000..2c4f268 Binary files /dev/null and b/resources/fonts/annual-report/SpaceMono-Bold.ttf differ diff --git a/resources/fonts/annual-report/SpaceMono-Regular.ttf b/resources/fonts/annual-report/SpaceMono-Regular.ttf new file mode 100644 index 0000000..1cfa365 Binary files /dev/null and b/resources/fonts/annual-report/SpaceMono-Regular.ttf differ diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so index b1f3ff3..74cd3bb 100644 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib index bc8eae2..7f45101 100644 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll index fda2f62..c664ad2 100644 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index dfbf542..ab3018d 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/scripts/prepare-electron-runtime.cjs b/scripts/prepare-electron-runtime.cjs new file mode 100644 index 0000000..1230732 --- /dev/null +++ b/scripts/prepare-electron-runtime.cjs @@ -0,0 +1,57 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const runtimeNames = [ + 'msvcp140.dll', + 'msvcp140_1.dll', + 'vcruntime140.dll', + 'vcruntime140_1.dll', +]; + +function copyIfDifferent(sourcePath, targetPath) { + const source = fs.statSync(sourcePath); + const targetExists = fs.existsSync(targetPath); + + if (targetExists) { + const target = fs.statSync(targetPath); + if (target.size === source.size && target.mtimeMs >= source.mtimeMs) { + return false; + } + } + + fs.copyFileSync(sourcePath, targetPath); + return true; +} + +function main() { + if (process.platform !== 'win32') { + return; + } + + const projectRoot = path.resolve(__dirname, '..'); + const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32'); + const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist'); + + if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) { + return; + } + + let copiedCount = 0; + + for (const name of runtimeNames) { + const sourcePath = path.join(sourceDir, name); + const targetPath = path.join(targetDir, name); + if (!fs.existsSync(sourcePath)) { + continue; + } + if (copyIfDifferent(sourcePath, targetPath)) { + copiedCount += 1; + } + } + + if (copiedCount > 0) { + console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`); + } +} + +main(); diff --git a/src/App.tsx b/src/App.tsx index 6265a8b..59e092a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -80,6 +80,7 @@ function App() { const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/') const isStandaloneChatWindow = location.pathname === '/chat-window' const isNotificationWindow = location.pathname === '/notification-window' + const isAnnualReportWindow = location.pathname === '/annual-report/view' const isSettingsRoute = location.pathname === '/settings' const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null const routeLocation = isSettingsRoute @@ -127,7 +128,7 @@ function App() { const body = document.body const appRoot = document.getElementById('app') - if (isOnboardingWindow || isNotificationWindow) { + if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) { root.style.background = 'transparent' body.style.background = 'transparent' body.style.overflow = 'hidden' @@ -144,7 +145,7 @@ function App() { appRoot.style.overflow = '' } } - }, [isOnboardingWindow]) + }, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow]) // 应用主题 useEffect(() => { @@ -165,7 +166,7 @@ function App() { } mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) - }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow]) + }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow]) // 读取已保存的主题设置 useEffect(() => { @@ -511,6 +512,11 @@ function App() { return } + // 独立年度报告全屏窗口 + if (isAnnualReportWindow) { + return + } + // 主窗口 - 完整布局 const handleCloseSettings = () => { const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current diff --git a/src/pages/AnnualReportPage.scss b/src/pages/AnnualReportPage.scss index 3e7beab..396441c 100644 --- a/src/pages/AnnualReportPage.scss +++ b/src/pages/AnnualReportPage.scss @@ -1,4 +1,5 @@ .annual-report-page { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -8,6 +9,11 @@ padding: 40px 24px; } +.annual-report-page.report-route-transitioning > :not(.report-launch-overlay) { + animation: report-page-exit 420ms cubic-bezier(0.4, 0, 0.2, 1) both; + pointer-events: none; +} + .header-icon { color: var(--primary); margin-bottom: 16px; @@ -199,6 +205,11 @@ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); } + &.disabled { + pointer-events: none; + opacity: 0.72; + } + &.selected { border-color: var(--primary); background: var(--primary-light); @@ -251,6 +262,10 @@ cursor: not-allowed; } + &.is-pending { + pointer-events: none; + } + &.secondary { background: var(--card-bg); color: var(--text-primary); @@ -259,6 +274,40 @@ } } +.report-launch-overlay { + position: fixed; + inset: 0; + z-index: 999; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg-primary) 78%, transparent); + backdrop-filter: blur(8px); + animation: report-launch-overlay-in 420ms ease-out both; +} + +.launch-core { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + text-align: center; + color: var(--text-primary); + animation: report-launch-core-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.launch-title { + margin: 4px 0 0; + font-size: 18px; + font-weight: 650; +} + +.launch-subtitle { + margin: 0; + font-size: 13px; + color: var(--text-tertiary); +} + .spin { animation: spin 1s linear infinite; } @@ -271,3 +320,36 @@ @keyframes dot-ellipsis { to { width: 1.4em; } } + +@keyframes report-page-exit { + from { + opacity: 1; + filter: blur(0); + transform: scale(1); + } + to { + opacity: 0; + filter: blur(8px); + transform: scale(0.985); + } +} + +@keyframes report-launch-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes report-launch-core-in { + from { + opacity: 0; + transform: translateY(18px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 88f77d0..5d0be95 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Calendar, Loader2, Sparkles, Users } from 'lucide-react' import { @@ -25,6 +25,8 @@ type YearsLoadPayload = { nativeTimedOut?: boolean } +const REPORT_LAUNCH_DELAY_MS = 420 + const formatLoadElapsed = (ms: number) => { const totalSeconds = Math.max(0, ms) / 1000 if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s` @@ -50,7 +52,10 @@ function AnnualReportPage() { const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false) const [nativeTimedOut, setNativeTimedOut] = useState(false) const [isGenerating, setIsGenerating] = useState(false) + const [isRouteTransitioning, setIsRouteTransitioning] = useState(false) + const [launchingYearLabel, setLaunchingYearLabel] = useState('') const [loadError, setLoadError] = useState(null) + const launchTimerRef = useRef(null) useEffect(() => { let disposed = false @@ -186,21 +191,37 @@ function AnnualReportPage() { } }, []) - const handleGenerateReport = async () => { - if (selectedYear === null) return - setIsGenerating(true) - try { - const yearParam = selectedYear === 'all' ? 0 : selectedYear - navigate(`/annual-report/view?year=${yearParam}`) - } catch (e) { - console.error('生成报告失败:', e) - } finally { - setIsGenerating(false) + useEffect(() => { + return () => { + if (launchTimerRef.current !== null) { + window.clearTimeout(launchTimerRef.current) + } } + }, []) + + const handleGenerateReport = () => { + if (selectedYear === null || isRouteTransitioning) return + const yearParam = selectedYear === 'all' ? 0 : selectedYear + const yearLabel = selectedYear === 'all' ? '全部时间' : `${selectedYear}年` + setIsGenerating(true) + setIsRouteTransitioning(true) + setLaunchingYearLabel(yearLabel) + if (launchTimerRef.current !== null) { + window.clearTimeout(launchTimerRef.current) + } + launchTimerRef.current = window.setTimeout(() => { + try { + navigate(`/annual-report/view?year=${yearParam}`) + } catch (e) { + console.error('生成报告失败:', e) + setIsGenerating(false) + setIsRouteTransitioning(false) + } + }, REPORT_LAUNCH_DELAY_MS) } const handleGenerateDualReport = () => { - if (selectedPairYear === null) return + if (selectedPairYear === null || isRouteTransitioning) return const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear navigate(`/dual-report?year=${yearParam}`) } @@ -251,7 +272,7 @@ function AnnualReportPage() { ) return ( -
+

年度报告

选择年份,回顾你在微信里的点点滴滴

@@ -270,8 +291,11 @@ function AnnualReportPage() { {yearOptions.map(option => (
setSelectedYear(option)} + className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`} + onClick={() => { + if (isRouteTransitioning) return + setSelectedYear(option) + }} > {option === 'all' ? '全部' : option} {option === 'all' ? '时间' : '年'} @@ -281,14 +305,14 @@ function AnnualReportPage() {
+ + {isRouteTransitioning && ( +
+
+ +

正在进入{launchingYearLabel}年度报告

+

正在整理你的聊天记忆...

+
+
+ )}
) } diff --git a/src/pages/AnnualReportWindow.scss b/src/pages/AnnualReportWindow.scss index 88c7c88..bc38030 100644 --- a/src/pages/AnnualReportWindow.scss +++ b/src/pages/AnnualReportWindow.scss @@ -1,163 +1,987 @@ +@font-face { + font-family: 'InterLocal'; + src: url('../../resources/fonts/annual-report/Inter-Var.ttf') format('truetype'); + font-style: normal; + font-weight: 100 900; + font-display: swap; +} + +@font-face { + font-family: 'PlayfairDisplayLocal'; + src: url('../../resources/fonts/annual-report/PlayfairDisplay-Var.ttf') format('truetype'); + font-style: normal; + font-weight: 400 900; + font-display: swap; +} + +@font-face { + font-family: 'CormorantGaramondLocal'; + src: url('../../resources/fonts/annual-report/CormorantGaramond-Var.ttf') format('truetype'); + font-style: normal; + font-weight: 300 700; + font-display: swap; +} + +@font-face { + font-family: 'NotoSerifSCLocal'; + src: url('../../resources/fonts/annual-report/NotoSerifSC-Var.ttf') format('truetype'); + font-style: normal; + font-weight: 200 900; + font-display: swap; +} + +@font-face { + font-family: 'SpaceMonoLocal'; + src: url('../../resources/fonts/annual-report/SpaceMono-Regular.ttf') format('truetype'); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: 'SpaceMonoLocal'; + src: url('../../resources/fonts/annual-report/SpaceMono-Bold.ttf') format('truetype'); + font-style: normal; + font-weight: 700; + font-display: swap; +} + .annual-report-window { - // 使用全局主题变量,带回退值 - --ar-primary: var(--primary, #07C160); - --ar-primary-rgb: var(--primary-rgb, 7, 193, 96); - --ar-accent: var(--accent, #F2AA00); - --ar-accent-rgb: 242, 170, 0; - --ar-text-main: var(--text-primary, #222222); - --ar-text-sub: var(--text-secondary, #555555); - --ar-bg-color: var(--bg-primary, #F9F8F6); - --ar-card-bg: var(--bg-secondary, rgba(255, 255, 255, 0.5)); - --ar-card-bg-hover: var(--bg-tertiary, rgba(255, 255, 255, 0.8)); - --ar-rank-bg: var(--bg-secondary, #f0f0f0); - --ar-rank-color: var(--text-secondary, #666); + --c-bg: #050505; + --c-bg-deep: #090909; + --c-text: #F2F2F0; + --c-text-bright: #FAFAF8; + --c-text-soft: rgba(250, 250, 248, 0.84); + --c-text-muted: rgba(250, 250, 248, 0.58); + --c-text-faint: rgba(250, 250, 248, 0.42); + --c-gold: #B8945A; + --c-gold-strong: #C8AA72; + --c-gold-rgb: 184, 148, 90; + --c-paper: #ECE8DF; + --c-paper-ink: #1A1710; + --c-paper-muted: #6E6757; + /* 顶级平滑缓动曲线 */ + --ease-epic: cubic-bezier(0.76, 0, 0.24, 1); + --ease-out: cubic-bezier(0.25, 1, 0.5, 1); - width: 100%; - height: 100vh; - background: var(--chat-pattern); - background-color: var(--ar-bg-color); - // overflow-y: auto; // Moved to .report-scroll-view - overflow: hidden; // Contain everything - position: relative; - -webkit-app-region: no-drag; // 确保主容器不可拖动 - - // 隐藏滚动条 - /* scrollbar-width: none; */ - // Moved - /* -ms-overflow-style: none; */ -} - -.report-scroll-view { - position: absolute; - inset: 0; - overflow-y: auto; - overflow-x: hidden; - z-index: 1; - - // 隐藏滚动条 - scrollbar-width: none; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } -} - -// 背景装饰圆点 - 毛玻璃效果 -.bg-decoration { - position: absolute; - inset: 0; - pointer-events: none; - z-index: 0; + background-color: var(--c-bg); + color: var(--c-text); + font-family: 'InterLocal', 'NotoSerifSCLocal', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; overflow: hidden; -} - -.deco-circle { - position: absolute; - border-radius: 50%; - background: rgba(var(--ar-primary-rgb), 0.03); - backdrop-filter: blur(40px); - -webkit-backdrop-filter: blur(40px); - border: 1px solid var(--border-color); - - &.c1 { - width: 280px; - height: 280px; - top: -80px; - right: -60px; - animation: float1 20s ease-in-out infinite; - } - - &.c2 { - width: 200px; - height: 200px; - bottom: 15%; - left: -70px; - animation: float2 25s ease-in-out infinite; - } - - &.c3 { - width: 120px; - height: 120px; - top: 45%; - right: -40px; - animation: float3 18s ease-in-out infinite; - } - - &.c4 { - width: 90px; - height: 90px; - top: 25%; - left: 8%; - animation: float1 22s ease-in-out infinite reverse; - } - - &.c5 { - width: 60px; + overscroll-behavior: none; + height: 100vh; + width: 100vw; + position: relative; + -webkit-app-region: no-drag; // 默认不可拖拽,给特定区域开放 + + // 顶部拖动控制条与关闭按钮 + .top-controls { + position: absolute; + top: 0; + left: 0; + width: 100%; height: 60px; - bottom: 25%; - right: 12%; - animation: float2 15s ease-in-out infinite reverse; - } -} + display: flex; + justify-content: flex-end; + align-items: center; + padding: 0 20px; + z-index: 10000; + -webkit-app-region: drag; -@keyframes float1 { + .close-btn { + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(var(--c-gold-rgb), 0.14); + border: 1px solid rgba(var(--c-gold-rgb), 0.34); + color: var(--c-text-soft); + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + -webkit-app-region: no-drag; + transition: all 0.3s var(--ease-out); - 0%, - 100% { - transform: translate(0, 0); + &:hover { + background: rgba(var(--c-gold-rgb), 0.24); + color: var(--c-text-bright); + box-shadow: 0 0 12px rgba(var(--c-gold-rgb), 0.24); + transform: scale(1.05); + } + } } - 50% { - transform: translate(-15px, 15px); - } -} - -@keyframes float2 { - - 0%, - 100% { - transform: translate(0, 0); + &[data-scene="10"] .top-controls .close-btn { + background: rgba(26, 20, 9, 0.08); + border-color: rgba(75, 58, 27, 0.28); + color: rgba(38, 29, 12, 0.72); } - 50% { - transform: translate(12px, -12px); - } -} - -@keyframes float3 { - - 0%, - 100% { - transform: translate(0, 0); + &[data-scene="10"] .top-controls .close-btn:hover { + background: rgba(30, 23, 10, 0.16); + color: #171006; } - 50% { - transform: translate(-8px, -15px); - } -} - -.annual-report-window { - - // 所有子元素默认不可拖动 - * { - -webkit-app-region: no-drag; - } - - // 背景渐变灯光 - &::before { - content: ""; - position: fixed; + .p0-bg-layer { + position: absolute; inset: 0; - background: - radial-gradient(circle 500px at 0% 0%, rgba(7, 193, 96, 0.06), transparent), - radial-gradient(circle 500px at 100% 0%, rgba(242, 170, 0, 0.05), transparent), - radial-gradient(circle 500px at 0% 100%, rgba(242, 170, 0, 0.05), transparent), - radial-gradient(circle 500px at 100% 100%, rgba(7, 193, 96, 0.06), transparent); + z-index: 1; pointer-events: none; - z-index: 0; + opacity: 0; + transition: opacity 1.1s var(--ease-out); } + &[data-scene="0"] .p0-bg-layer { + opacity: 1; + } + + &[data-scene="1"] .p0-bg-layer { + opacity: 0.16; + } + + .p0-particle-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0.48; + } + + .p0-center-glow { + position: absolute; + inset: 0; + background: radial-gradient(circle at center, rgba(var(--c-gold-rgb), 0.12) 0%, rgba(var(--c-gold-rgb), 0.03) 36%, rgba(255, 255, 255, 0.02) 50%, transparent 72%); + } + + /* 细微的电影噪点 */ + .film-grain { + position: absolute; + inset: 0; + z-index: 9999; + opacity: 0.018; + pointer-events: none; + mix-blend-mode: overlay; + background: url('data:image/svg+xml;utf8,%3Csvg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"%3E%3Cfilter id="noiseFilter"%3E%3CfeTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" stitchTiles="stitch"/%3E%3C/filter%3E%3Crect width="100%25" height="100%25" filter="url(%23noiseFilter)"/%3E%3C/svg%3E'); + } + + /* ========================================= + 标志性组件的演化 + ========================================= */ + #memory-core { + position: absolute; + transform: translate(-50%, -50%); + transition: all 1.5s var(--ease-epic); + z-index: 5; + pointer-events: none; + background: var(--c-gold-strong); + } + + /* S0: 年份下方引线(保留后续场景形变) */ + &[data-scene="0"] #memory-core { + top: 84vh; + left: 50vw; + width: clamp(120px, 16vw, 220px); + height: 1px; + border-radius: 999px; + opacity: 1; + box-shadow: 0 0 12px rgba(var(--c-gold-rgb), 0.48); + filter: blur(0px); + } + + @media (max-width: 1024px) { + &[data-scene="0"] #memory-core { + top: 81vh; + width: clamp(96px, 22vw, 180px); + } + } + + /* S1: 深海地平线底光 */ + &[data-scene="1"] #memory-core { + top: 100vh; + left: 50vw; + width: 200vw; + height: 60vh; + border-radius: 50%; + opacity: 0.15; + box-shadow: none; + filter: blur(80px); + } + + /* S2: 凌晨微光 */ + &[data-scene="2"] #memory-core { + top: 45vh; + left: 50vw; + width: 300px; + height: 150px; + border-radius: 50%; + opacity: 0.08; + box-shadow: none; + filter: blur(40px); + } + + /* S3: 竖直时间引线 (内容线段,可被 S4 过渡形变) */ + &[data-scene="3"] #memory-core { + top: var(--s3-line-top, 48vh); + left: var(--s3-line-left, calc(50vw - min(36vw, 440px) + 12px)); + width: 1px; + height: var(--s3-line-height, clamp(240px, 34vh, 320px)); + border-radius: 1px; + background: var(--c-gold); + opacity: 0.55; + box-shadow: 0 0 12px rgba(var(--c-gold-rgb), 0.38); + filter: blur(0px); + } + + /* S4: 内容横线 (由 S3 竖线平滑形变过来) */ + &[data-scene="4"] #memory-core { + top: 55vh; + left: 50vw; + width: 80vw; + height: 1px; + border-radius: 0; + background: rgba(var(--c-gold-rgb), 0.88); + opacity: 0.35; + box-shadow: 0 0 10px rgba(var(--c-gold-rgb), 0.28); + filter: blur(0px); + } + + /* S5: MUTUAL RESONANCE (底部弥散的主氛围光源) */ + &[data-scene="5"] #memory-core { + top: 100vh; + left: 50vw; + width: 150vw; + height: 80vh; + border-radius: 50%; + opacity: 0.04; + box-shadow: none; + filter: blur(80px); + } + + /* S6: SOCIAL KINETICS (大字背后的脉冲环境背光) */ + &[data-scene="6"] #memory-core { + top: 40vh; + left: 30vw; + width: 80vw; + height: 80vh; + border-radius: 50%; + opacity: 0.03; + box-shadow: none; + filter: blur(100px); + animation: corePulse 3s ease-in-out infinite alternate; + } + + @keyframes corePulse { + 0% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.02; } + 100% { transform: translate(-50%, -50%) scale(1.1); opacity: 0.05; } + } + + /* S7: THE SPARK (顶部的倾斜透射光束感) */ + &[data-scene="7"] #memory-core { + top: 0vh; + left: 20vw; + width: 120vw; + height: 100vh; + border-radius: 50%; + opacity: 0.05; + box-shadow: none; + filter: blur(90px); + transform: translate(-50%, -50%) rotate(-15deg); + } + + /* S8: FADING SIGNALS (迷雾) */ + &[data-scene="8"] #memory-core { + top: 50vh; + left: 50vw; + width: 80vw; + height: 80vh; + border-radius: 50%; + opacity: 0.05; + box-shadow: none; + filter: blur(80px); + } + + /* S9: LEXICON (大气) */ + &[data-scene="9"] #memory-core { + top: -20vh; + left: -20vw; + width: 150vw; + height: 150vw; + border-radius: 50%; + opacity: 0.08; + box-shadow: none; + filter: blur(60px); + } + + /* S10: EXTRACTION */ + &[data-scene="10"] #memory-core { + top: 50vh; + left: 50vw; + width: 250vmax; + height: 250vmax; + border-radius: 50%; + background: var(--c-paper); /* Explodes into warm bright color smoothly */ + opacity: 1; + box-shadow: none; + border: none; + filter: blur(0px); + transition: all 1s cubic-bezier(0.8, 0, 0.1, 1); + } + + /* ========================================= + 场景控制系统 & 遮罩出场动画 + ========================================= */ + .scene { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + pointer-events: none; + } + + .scene.active { + pointer-events: auto; + } + + .serif { + font-family: 'NotoSerifSCLocal', 'CormorantGaramondLocal', serif; + } + + .mono { + font-family: 'SpaceMonoLocal', 'NotoSerifSCLocal', monospace; + } + + .num-display { + font-family: 'InterLocal', -apple-system, sans-serif; + font-variant-numeric: tabular-nums; + font-weight: 500; + letter-spacing: -0.02em; + } + + /* 通用文案样式放大 */ + .en-tag { + position: absolute; + top: 6vh; + left: 4vw; + font-size: clamp(0.9rem, 1.05vw, 1.05rem); + color: var(--c-text-soft); + letter-spacing: 0.28em; + font-weight: 500; + text-rendering: optimizeLegibility; + z-index: 10; + } + + .desc-text { + font-size: 1.3rem; + line-height: 1.8; + color: var(--c-text); + margin-top: 35vh; + } + + /* Mask Reveal 动画 */ + .reveal-wrap { + overflow: hidden; + display: inline-block; + vertical-align: top; + } + + .reveal-inner { + transform: translateY(110%); + transition: transform 1.2s var(--ease-epic), opacity 1.2s var(--ease-epic); + opacity: 0; + } + + .scene.active .reveal-inner { + transform: translateY(0); + opacity: 1; + } + + .scene.prev .reveal-inner { + transform: translateY(-50%); + opacity: 0; + transition: all 0.6s ease; + } + + .scene.next .reveal-inner { + transform: translateY(50%); + opacity: 0; + transition: all 0.6s ease; + } + + &.exporting-scenes .top-controls, + &.exporting-scenes .pagination, + &.exporting-scenes .swipe-hint { + opacity: 0 !important; + visibility: hidden !important; + } + + .delay-1 { + transition-delay: 0.1s; + } + + .delay-2 { + transition-delay: 0.2s; + } + + .delay-3 { + transition-delay: 0.3s; + } + + /* 场景排版 */ + #scene-0 { + text-align: center; + } + + #scene-0 .scene0-cn-tag { + letter-spacing: 0.22em; + font-weight: 500; + } + + #scene-0 .title-year { + font-family: 'PlayfairDisplayLocal', 'CormorantGaramondLocal', serif; + font-size: clamp(6.8rem, 21vw, 18rem); + line-height: 1.02; + letter-spacing: -0.04em; + margin-top: 10vh; + text-shadow: 0 18px 45px rgba(0, 0, 0, 0.45); + max-width: 90vw; + overflow-wrap: anywhere; + } + + #scene-0 .title-year--numeric { + font-size: clamp(6.8rem, 21vw, 18rem); + letter-spacing: -0.04em; + } + + #scene-0 .title-year--text { + font-size: clamp(4.8rem, 14vw, 10rem); + letter-spacing: 0.01em; + line-height: 1.08; + } + + #scene-0 .title-year--text-long { + font-size: clamp(3.8rem, 10.5vw, 7.5rem); + letter-spacing: 0.02em; + line-height: 1.12; + } + + #scene-0 .title-year-wrap { + padding: clamp(6px, 0.8vh, 14px) 0; + } + + #scene-0 .p0-desc { + margin-top: clamp(9vh, 11vh, 13vh); + } + + #scene-0 .p0-desc-inner { + font-size: clamp(1rem, 1.35vw, 1.2rem); + line-height: 2; + color: var(--c-text-soft); + letter-spacing: 0.08em; + } + + #scene-1 .title-data { + font-size: clamp(5.5rem, 16vw, 13rem); + font-family: 'InterLocal'; + font-weight: 300; + letter-spacing: -0.05em; + line-height: 1; + margin-bottom: 4vh; + } + + #scene-2 { + padding: 0 10vw; + text-align: center; + } + + #scene-2 .title-time { + font-size: clamp(4.5rem, 12vw, 9rem); + line-height: 1; + margin-top: 10vh; + } + + #scene-2 .desc-text { + margin-top: 2vh; + } + + #scene-3 { + align-items: center; + justify-content: flex-start; + padding: 0 8vw; + } + + #scene-3 .en-tag { + left: 4vw; + top: 6vh; + } + + #scene-3 .s3-layout { + position: absolute; + top: 20vh; + left: 50%; + transform: translateX(-50%); + width: min(880px, 72vw); + max-width: 100%; + display: flex; + flex-direction: column; + gap: clamp(4vh, 5vh, 7vh); + padding-left: clamp(52px, 7vw, 108px); + } + + #scene-3 .s3-subtitle-wrap { + display: block; + width: 100%; + } + + #scene-3 .s3-subtitle { + font-size: clamp(1rem, 1.25vw, 1.15rem); + color: var(--c-text-muted); + letter-spacing: 0.05em; + line-height: 1.7; + } + + #scene-3 .contact-list { + display: flex; + flex-direction: column; + gap: clamp(3.2vh, 4vh, 5.5vh); + margin-top: 0; + width: 100%; + max-width: none; + } + + #scene-3 .s3-row-wrap { + display: block; + width: 100%; + } + + #scene-3 .c-item { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: end; + column-gap: clamp(36px, 8vw, 140px); + width: 100%; + min-height: clamp(58px, 8vh, 88px); + } + + #scene-3 .c-info { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + } + + #scene-3 .c-name { + font-size: clamp(2rem, 4.3vw, 3.2rem); + line-height: 1; + letter-spacing: 0.03em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: min(56vw, 460px); + } + + #scene-3 .c-sub { + font-size: 0.68rem; + color: var(--c-text-muted); + letter-spacing: 0.08em; + } + + #scene-3 .c-count { + font-size: clamp(1.4rem, 2.2vw, 2rem); + font-family: 'SpaceMonoLocal'; + line-height: 1; + text-align: right; + min-width: 7ch; + } + + @media (max-width: 1280px) { + #scene-3 .s3-layout { + width: min(820px, 76vw); + padding-left: clamp(44px, 6vw, 92px); + } + } + + @media (max-width: 1024px) { + #scene-3 .s3-layout { + top: 18.5vh; + width: min(760px, 86vw); + padding-left: clamp(30px, 5vw, 60px); + } + + #scene-3 .c-name { + font-size: clamp(1.65rem, 5.2vw, 2.4rem); + } + + #scene-3 .c-count { + font-size: clamp(1.2rem, 2.8vw, 1.75rem); + } + } + + @media (max-width: 760px) { + #scene-3 .s3-layout { + top: 17.5vh; + gap: clamp(3vh, 4.5vh, 5vh); + width: 90vw; + padding-left: 26px; + } + + #scene-3 .contact-list { + gap: clamp(2.8vh, 3.4vh, 4vh); + } + + #scene-3 .c-item { + column-gap: 24px; + min-height: 52px; + } + + #scene-3 .c-sub { + font-size: 0.62rem; + } + } + + #scene-8 { + align-items: flex-start; + justify-content: flex-start; + padding: 0 6vw; + } + + #scene-8 .s8-layout { + position: absolute; + top: 18vh; + left: 50%; + transform: translateX(-50%); + width: min(1240px, 86vw); + display: grid; + grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr); + column-gap: clamp(34px, 4.8vw, 84px); + align-items: start; + } + + #scene-8 .s8-left { + display: flex; + flex-direction: column; + gap: clamp(2.5vh, 3.2vh, 4vh); + padding-top: clamp(8vh, 9vh, 11vh); + } + + #scene-8 .s8-name-wrap, + #scene-8 .s8-summary-wrap, + #scene-8 .s8-quote-wrap, + #scene-8 .s8-letter-wrap { + display: block; + width: 100%; + } + + #scene-8 .s8-name { + font-size: clamp(3.2rem, 7.4vw, 5.6rem); + color: rgba(var(--c-gold-rgb), 0.88); + letter-spacing: 0.08em; + line-height: 1.05; + } + + #scene-8 .s8-summary { + max-width: 34ch; + font-size: clamp(1.06rem, 1.35vw, 1.35rem); + color: var(--c-text-soft); + line-height: 1.95; + letter-spacing: 0.02em; + } + + #scene-8 .s8-summary-count { + margin: 0 8px; + font-size: clamp(1.35rem, 2vw, 1.75rem); + color: var(--c-gold-strong); + white-space: nowrap; + } + + #scene-8 .s8-quote { + max-width: 32ch; + font-size: clamp(0.98rem, 1.12vw, 1.1rem); + color: var(--c-text-muted); + line-height: 1.9; + } + + #scene-8 .s8-letter-wrap { + margin-top: clamp(3vh, 4vh, 5.5vh); + } + + #scene-8 .s8-letter { + position: relative; + padding: clamp(24px, 3.2vh, 38px) clamp(20px, 2.6vw, 34px) clamp(24px, 3.2vh, 38px) clamp(30px, 3.2vw, 44px); + border-radius: 18px; + border: 1px solid rgba(var(--c-gold-rgb), 0.34); + background: linear-gradient(135deg, rgba(var(--c-gold-rgb), 0.16), rgba(var(--c-gold-rgb), 0.04)); + font-size: clamp(0.95rem, 1.05vw, 1.08rem); + line-height: 2; + color: var(--c-text-soft); + text-align: left; + text-shadow: 0 4px 16px rgba(0, 0, 0, 0.22); + } + + #scene-8 .s8-letter::before { + content: ''; + position: absolute; + top: 20px; + left: 14px; + width: 2px; + height: calc(100% - 40px); + border-radius: 2px; + background: linear-gradient(to bottom, rgba(var(--c-gold-rgb), 0.7), rgba(var(--c-gold-rgb), 0.08)); + } + + #scene-8 .s8-empty-wrap { + display: block; + width: min(760px, 78vw); + margin-top: 24vh; + text-align: center; + } + + #scene-8 .s8-empty-text { + color: var(--c-text); + line-height: 2; + } + + @media (max-width: 1280px) { + #scene-8 .s8-layout { + width: min(1120px, 88vw); + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + column-gap: clamp(28px, 4vw, 56px); + } + + #scene-8 .s8-left { + padding-top: clamp(6vh, 8vh, 9vh); + } + } + + @media (max-width: 1024px) { + #scene-8 .s8-layout { + top: 16vh; + width: min(900px, 90vw); + grid-template-columns: 1fr; + row-gap: clamp(3vh, 3.5vh, 4.5vh); + } + + #scene-8 .s8-left { + padding-top: 0; + gap: clamp(1.6vh, 2.2vh, 2.8vh); + } + + #scene-8 .s8-name { + font-size: clamp(2.4rem, 8.4vw, 4.2rem); + letter-spacing: 0.06em; + } + + #scene-8 .s8-summary, + #scene-8 .s8-quote { + max-width: none; + } + + #scene-8 .s8-letter-wrap { + margin-top: 0; + } + + #scene-8 .s8-letter { + font-size: clamp(0.9rem, 1.9vw, 1rem); + line-height: 1.95; + } + } + + @media (max-width: 760px) { + #scene-8 .s8-layout { + top: 14.5vh; + width: 92vw; + row-gap: clamp(2.2vh, 3vh, 3.8vh); + } + + #scene-8 .s8-name { + font-size: clamp(2rem, 10vw, 3rem); + } + + #scene-8 .s8-summary { + font-size: clamp(0.92rem, 3.9vw, 1rem); + line-height: 1.85; + } + + #scene-8 .s8-summary-count { + margin: 0 6px; + font-size: clamp(1.1rem, 4.8vw, 1.35rem); + } + + #scene-8 .s8-quote { + font-size: clamp(0.86rem, 3.5vw, 0.95rem); + line-height: 1.8; + } + + #scene-8 .s8-letter { + border-radius: 14px; + padding: 16px 16px 16px 24px; + font-size: clamp(0.82rem, 3.4vw, 0.9rem); + line-height: 1.82; + } + + #scene-8 .s8-letter::before { + top: 16px; + left: 11px; + height: calc(100% - 32px); + } + + #scene-8 .s8-empty-wrap { + width: 88vw; + margin-top: 23vh; + } + + #scene-8 .s8-empty-text { + font-size: 1rem; + line-height: 1.9; + } + } + + /* S4 宇宙 (彻底修复穿模 BUG) */ + #scene-4 { + color: var(--c-text-bright); + } + + #scene-4 .en-tag { + color: var(--c-text-muted); + } + + .word-burst { + position: absolute; + font-family: 'NotoSerifSCLocal'; + font-weight: 500; + white-space: nowrap; + transform: translate(-50%, -50%) scale(0.8); + opacity: 0; + transition: all 1s cubic-bezier(0.175, 0.885, 0.32, 1.2); + } + + /* 仅在 S9 显影 */ + &[data-scene="9"] .word-burst { + transform: translate(-50%, -50%) scale(1); + opacity: var(--target-op, 1); + } + + .float-el { + display: inline-block; + animation: floatWord 4s ease-in-out infinite alternate; + } + + @keyframes floatWord { + 0% { + transform: translateY(-8px); + } + 100% { + transform: translateY(8px); + } + } + + .btn-wrap { + position: absolute; + bottom: 8vh; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + opacity: 0; + transition: opacity 1s 0.8s; + } + + &[data-scene="10"] .btn-wrap { + opacity: 1; + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1rem 3rem; + color: var(--c-gold-strong); + background: #111111; + border-radius: 100px; + cursor: pointer; + transition: all 0.4s var(--ease-out); + margin-top: 3vh; + font-family: 'SpaceMonoLocal'; + font-size: 0.75rem; + letter-spacing: 0.15em; + border: 1px solid rgba(var(--c-gold-rgb), 0.36); + pointer-events: auto; + } + + .btn:hover { + transform: scale(1.05); + background: #1d1d1d; + box-shadow: 0 10px 24px rgba(var(--c-gold-rgb), 0.24); + } + + /* 导航系统 */ + .pagination { + position: absolute; + right: 4vw; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 12px; + z-index: 100; + mix-blend-mode: difference; + } + + .dot-nav { + width: 3px; + height: 12px; + background: rgba(var(--c-gold-rgb), 0.22); + transition: all 0.4s var(--ease-out); + cursor: pointer; + border-radius: 3px; + } + + .dot-nav:hover { + background: rgba(var(--c-gold-rgb), 0.56); + } + + .dot-nav.active { + background: var(--c-gold-strong); + height: 32px; + box-shadow: 0 0 10px rgba(var(--c-gold-rgb), 0.52); + } + + .swipe-hint { + position: absolute; + bottom: 5vh; + left: 50%; + transform: translateX(-50%); + font-family: 'SpaceMonoLocal'; + font-size: clamp(0.74rem, 0.9vw, 0.9rem); + letter-spacing: 0.28em; + color: var(--c-text-muted); + font-weight: 500; + text-rendering: geometricPrecision; + z-index: 100; + opacity: 0; + transition: opacity 0.8s ease; + pointer-events: none; + } + + &[data-scene="0"] .swipe-hint { + opacity: 0.6; + animation: hintPulse 2s infinite alternate; + } + + @keyframes hintPulse { + 0% { + transform: translateX(-50%) translateY(0); + } + 100% { + transform: translateX(-50%) translateY(-5px); + } + } + + // 加载状态 &.loading, &.error { display: flex; @@ -165,7 +989,7 @@ align-items: center; justify-content: center; gap: 16px; - color: var(--ar-text-sub); + color: var(--c-text-muted); p { font-size: 16px; @@ -173,10 +997,13 @@ } &.loading { + animation: loadingPageEnter 0.46s var(--ease-out) both; + .loading-ring { position: relative; width: 160px; height: 160px; + animation: loadingRingEnter 0.52s var(--ease-epic) both; svg { width: 100%; @@ -186,13 +1013,13 @@ .ring-bg { fill: none; - stroke: rgba(0, 0, 0, 0.08); + stroke: rgba(var(--c-gold-rgb), 0.2); stroke-width: 6; } .ring-progress { fill: none; - stroke: var(--ar-primary); + stroke: var(--c-gold-strong); stroke-width: 6; stroke-linecap: round; stroke-dasharray: 264; @@ -206,1216 +1033,58 @@ transform: translate(-50%, -50%); font-size: 36px; font-weight: 600; - color: var(--ar-primary); + color: var(--c-text-bright); } } .loading-stage { font-size: 20px; font-weight: 600; - color: var(--ar-text-main); + color: var(--c-gold-strong); margin-top: 24px; + animation: loadingTextEnter 0.52s var(--ease-out) both; + animation-delay: 0.06s; } .loading-hint { font-size: 14px; - color: var(--ar-text-sub); + color: var(--c-text-muted); margin-top: 4px; - } - } -} - -.report-container { - width: 80%; - margin: 0 auto; - padding: 32px 5% 60px; - padding-top: 48px; - position: relative; - z-index: 1; - -webkit-app-region: no-drag; -} - -.exporting-snapshot *::selection { - background: transparent; - color: inherit; -} - -.exporting-snapshot * { - caret-color: transparent; -} - -.exporting-snapshot { - - .hero-title, - .label-text, - .hero-desc, - .stat-num, - .stat-unit, - .hl, - .gold { - background: transparent !important; - box-shadow: none !important; - } - - .deco-circle { - background: transparent !important; - border: none !important; - } -} - -.section { - min-height: 80vh; - display: flex; - flex-direction: column; - justify-content: center; - padding: 60px 0; -} - -.label-text { - font-size: 12px; - letter-spacing: 3px; - text-transform: uppercase; - color: var(--ar-text-sub); - margin-bottom: 14px; - font-weight: 600; -} - -.hero-title { - font-size: clamp(28px, 5vw, 44px); - font-weight: 700; - line-height: 1.2; - margin-bottom: 16px; - color: var(--ar-text-main); -} - -.hero-desc { - font-size: 16px; - line-height: 1.8; - color: var(--ar-text-sub); - max-width: 500px; - - &.active-time { - font-size: 18px; - margin-bottom: 16px; - } -} - -.big-stat { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 8px; - margin: 20px 0; -} - -.stat-num { - font-size: clamp(40px, 8vw, 64px); - font-weight: 700; - color: var(--ar-primary); - line-height: 1; -} - -.stat-unit { - font-size: 18px; - color: var(--ar-text-sub); -} - -.divider { - width: 50px; - height: 3px; - background: var(--ar-accent); - margin: 24px 0; - border: none; - opacity: 0.8; -} - -.hl { - color: var(--ar-primary); - font-weight: 600; -} - -.gold { - color: var(--ar-accent); - font-weight: 600; -} - -// 头像组件 -.avatar { - border-radius: 50%; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - border: 2px solid #fff; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - color: #fff; - font-weight: 600; - flex-shrink: 0; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - &.sm { - width: 38px; - height: 38px; - font-size: 13px; - } - - &.md { - width: 48px; - height: 48px; - font-size: 16px; - } - - &.lg { - width: 64px; - height: 64px; - font-size: 20px; - border: 3px solid #fff; - box-shadow: 0 6px 20px rgba(7, 193, 96, 0.2); - } -} - -// 月度好友环形布局 -.monthly-orbit { - --radius: 180px; - position: relative; - width: 100%; - max-width: 500px; - height: 500px; - margin: 30px auto 0; -} - -.monthly-center { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - text-align: center; - z-index: 2; - - .avatar { - width: 80px; - height: 80px; - } -} - -.monthly-item { - position: absolute; - left: 50%; - top: 50%; - width: 90px; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - text-align: center; - transform: translate(-50%, -50%) rotate(calc(var(--i) * 30deg)) translateY(calc(-1 * var(--radius))) rotate(calc(var(--i) * -30deg)); - z-index: 1; - - .avatar { - width: 48px; - height: 48px; - } -} - -.month-label { - font-size: 11px; - color: var(--ar-text-sub); - letter-spacing: 1px; -} - -.month-name { - font-size: 11px; - color: var(--ar-text-sub); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - width: 100%; -} - -// 热力图 -.heatmap-wrapper { - margin-top: 24px; - width: 100%; -} - -.heatmap-header { - display: grid; - grid-template-columns: 28px 1fr; - gap: 3px; - margin-bottom: 6px; - color: var(--ar-text-sub); - font-size: 10px; -} - -.time-labels { - display: grid; - grid-template-columns: repeat(24, 1fr); - gap: 3px; - - span { - text-align: center; - } -} - -.heatmap { - display: grid; - grid-template-columns: 28px 1fr; - gap: 3px; -} - -.heatmap-week-col { - display: grid; - grid-template-rows: repeat(7, 1fr); - gap: 3px; - font-size: 10px; - color: var(--ar-text-sub); -} - -.week-label { - display: flex; - align-items: center; -} - -.heatmap-grid { - display: grid; - grid-template-columns: repeat(24, 1fr); - gap: 3px; -} - -.h-cell { - aspect-ratio: 1; - border-radius: 2px; - min-height: 10px; - transition: transform 0.15s; - - &:hover { - transform: scale(1.3); - z-index: 1; - } -} - -// 好友列表 -.friend-list { - margin-top: 20px; -} - -.friend-item { - display: flex; - align-items: center; - gap: 14px; - padding: 14px; - background: var(--ar-card-bg); - border-radius: 10px; - margin-bottom: 10px; - transition: background 0.2s; - - &:hover { - background: var(--ar-card-bg-hover); - } - - .rank { - width: 26px; - height: 26px; - border-radius: 50%; - background: var(--ar-rank-bg); - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 600; - color: var(--ar-rank-color); - flex-shrink: 0; - - &.top { - background: linear-gradient(135deg, #ffd700, #ffb800); - color: #fff; + animation: loadingTextEnter 0.52s var(--ease-out) both; + animation-delay: 0.12s; } } - .avatar { - width: 40px; - height: 40px; - font-size: 14px; - } - - .info { - flex: 1; - min-width: 0; - } - - .name { - font-weight: 600; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .count { - font-size: 12px; - color: var(--ar-text-sub); - } - - .percent { - font-size: 13px; - color: var(--ar-primary); - font-weight: 600; - flex-shrink: 0; - } -} - -// 领奖台布局 -.podium { - display: flex; - align-items: flex-end; - justify-content: center; - gap: 12px; - margin-top: 40px; - padding: 0 20px; -} - -.podium-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - position: relative; - - .avatar { - width: 56px; - height: 56px; - border: 3px solid var(--ar-card-bg); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - } - - &.first { - .avatar { - width: 72px; - height: 72px; - border-color: #ffd700; - } - - .crown { - font-size: 28px; - margin-bottom: -8px; - animation: crownBounce 2s ease-in-out infinite; - } - - .podium-stand { - height: 100px; - background: linear-gradient(180deg, #ffd700, #ffb800); - } - } - - &.second { - .podium-stand { - height: 70px; - background: linear-gradient(180deg, #e0e0e0, #c0c0c0); - } - } - - &.third { - .podium-stand { - height: 50px; - background: linear-gradient(180deg, #cd9b6a, #b87333); - } - } -} - -.podium-name { - font-size: 13px; - font-weight: 600; - color: var(--ar-text-main); - max-width: 90px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: center; -} - -.podium-count { - font-size: 11px; - color: var(--ar-primary); - font-weight: 500; -} - -.podium-stand { - width: 90px; - border-radius: 8px 8px 0 0; - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: 12px; - margin-top: 8px; -} - -.podium-rank { - font-size: 24px; - font-weight: 700; - color: rgba(255, 255, 255, 0.9); - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -@keyframes crownBounce { - - 0%, - 100% { - transform: translateY(0); - } - - 50% { - transform: translateY(-4px); - } -} - -// 第4-5名列表 -.runner-up-list { - margin-top: 24px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.runner-up-item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: var(--ar-card-bg); - border-radius: 10px; - - .avatar { - width: 36px; - height: 36px; - } -} - -.runner-up-rank { - width: 22px; - height: 22px; - border-radius: 50%; - background: var(--ar-rank-bg); - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: 600; - color: var(--ar-rank-color); -} - -.runner-up-name { - flex: 1; - font-size: 13px; - font-weight: 500; - color: var(--ar-text-main); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.runner-up-count { - font-size: 12px; - color: var(--ar-primary); - font-weight: 500; -} - -// 结尾 -.ending { - text-align: center; - align-items: center; -} - -.ending-year { - font-size: 100px; - font-weight: 700; - color: var(--ar-primary); - opacity: 0.1; - margin-top: 30px; - user-select: none; -} - -.ending-brand { - font-size: 14px; - letter-spacing: 4px; - color: var(--ar-text-sub); - margin-top: 20px; - font-weight: 600; -} - -// 双向奔赴 - 新样式 -.mutual-visual { - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - margin: 40px 0 24px; -} - -.mutual-side { - display: flex; - align-items: center; - gap: 12px; - - &.friend { - flex-direction: row; - } -} - -.mutual-arrow { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - - .arrow-count { - font-size: 14px; - font-weight: 600; - color: var(--ar-primary); - } - - .arrow-line { - font-size: 20px; - color: var(--ar-text-sub); - opacity: 0.5; - } - - &.reverse { - .arrow-count { - color: var(--ar-accent); - } - } -} - -.mutual-center { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 0 20px; - - .mutual-icon { - font-size: 32px; - } - - .mutual-ratio { - font-size: 18px; - font-weight: 700; - color: var(--ar-accent); - } -} - -.mutual-name-tag { - font-size: 20px; - font-weight: 600; - color: var(--ar-text-main); - text-align: center; - margin-bottom: 12px; -} - -// 常用语列表 -.phrase-list { - margin-top: 24px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.phrase-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - background: var(--ar-card-bg); - border-radius: 12px; - transition: transform 0.2s; - - &:hover { - transform: translateX(4px); - } -} - -.phrase-text { - font-size: 16px; - font-weight: 500; - color: var(--ar-text-main); -} - -.phrase-count { - font-size: 14px; - color: var(--ar-primary); - font-weight: 600; -} - -// 加载动画 -.spin { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - - -// 顶部拖动区域 -.drag-region { - position: absolute; // Changed from fixed - top: 0; - left: 0; - right: 0; // Changed from right: 138px (since it's now inside the window container) - height: 32px; - -webkit-app-region: drag !important; - z-index: 100; -} - -// 浮动操作按钮 -.fab-container { - position: fixed; - bottom: 64px; - right: 40px; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - z-index: 99; - pointer-events: auto; -} - -.fab-main { - width: 56px; - height: 56px; - border-radius: 50%; - border: none; - background: var(--ar-primary); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-shadow: 0 4px 16px rgba(7, 193, 96, 0.4); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - order: 99; - - &:hover { - transform: scale(1.05); - box-shadow: 0 6px 24px rgba(7, 193, 96, 0.5); - } - - .fab-container.open & { - transform: rotate(45deg); - background: var(--ar-text-sub); - } -} - -.fab-item { - width: 56px; - height: 56px; - border-radius: 50%; - border: none; - background: var(--ar-card-bg); - color: var(--ar-text-main); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - opacity: 0; - transform: scale(0.5) translateY(20px); - pointer-events: none; - - .fab-container.open & { - opacity: 1; - transform: scale(1) translateY(0); - pointer-events: auto; - - &:nth-child(1) { - transition-delay: 0.05s; - } - - &:nth-child(2) { - transition-delay: 0.1s; - } - } - - &:hover { - background: var(--ar-primary); - color: #fff; - } -} - -// 导出遮罩和弹窗 -.export-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - animation: fadeIn 0.2s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -// 导出进度弹窗 -.export-progress-modal { - background: var(--bg-primary, #fff); - padding: 40px 48px; - border-radius: 20px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - animation: scaleIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -@keyframes scaleIn { - from { - opacity: 0; - transform: scale(0.9); - } - - to { - opacity: 1; - transform: scale(1); - } -} - -.export-spinner { - position: relative; - width: 72px; - height: 72px; - display: flex; - align-items: center; - justify-content: center; - - .spinner-ring { - position: absolute; - inset: 0; - border: 3px solid rgba(0, 0, 0, 0.08); - border-top-color: var(--ar-primary); - border-radius: 50%; - animation: spinRing 1s linear infinite; - } - - .spinner-icon { - color: var(--ar-primary); - animation: pulse 1.5s ease-in-out infinite; - } -} - -@keyframes spinRing { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 0.6; - transform: scale(1); - } - - 50% { - opacity: 1; - transform: scale(1.1); - } -} - -.export-title { - font-size: 18px; - font-weight: 600; - color: var(--ar-text-main); - margin: 0; -} - -.export-status { - font-size: 14px; - color: var(--ar-text-sub); - margin: 0; -} - -.export-modal { - background: var(--bg-primary, #fff); - padding: 0; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - min-width: 280px; - color: var(--ar-text-main); - overflow: hidden; - - p { - margin-top: 12px; - color: var(--ar-text-sub); - } - - &.section-selector { - width: 420px; - max-width: 90vw; - } -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); - - h3 { - font-size: 18px; - font-weight: 600; - margin: 0; - } - - .close-btn { - background: none; - border: none; - padding: 4px; - cursor: pointer; - color: var(--ar-text-sub); - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - } -} - -.section-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 10px; - padding: 20px 24px; - max-height: 320px; - overflow-y: auto; -} - -.section-card { - position: relative; - padding: 16px 12px; - border-radius: 10px; - background: var(--ar-card-bg); - border: 2px solid transparent; - cursor: pointer; - text-align: center; - font-size: 13px; - font-weight: 500; - transition: all 0.2s; - - &:hover { - background: var(--ar-card-bg-hover); - } - - &.selected { - border-color: var(--ar-primary); - background: rgba(7, 193, 96, 0.08); - } - - .card-check { - position: absolute; - top: 6px; - right: 6px; - width: 18px; - height: 18px; - border-radius: 50%; - background: var(--ar-primary); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transform: scale(0.5); - transition: all 0.2s; - } - - &.selected .card-check { - opacity: 1; - transform: scale(1); - } -} - -.modal-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 24px; - border-top: 1px solid rgba(0, 0, 0, 0.08); - background: rgba(0, 0, 0, 0.02); - - .select-all-btn { - background: none; - border: none; - padding: 8px 16px; - font-size: 14px; - color: var(--ar-text-sub); - cursor: pointer; - border-radius: 6px; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - } - - .confirm-btn { - background: var(--ar-primary); - border: none; - padding: 10px 24px; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - color: #fff; - cursor: pointer; - transition: all 0.2s; - - &:hover:not(:disabled) { - opacity: 0.9; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } -} - -// 词云样式 -.word-cloud-wrapper { - margin: 24px auto 0; - padding: 0; - max-width: 520px; - display: flex; - justify-content: center; - --cloud-scale: clamp(0.72, 80vw / 520, 1); -} - -.word-cloud-inner { - position: relative; - width: 520px; - height: 520px; - margin: 0; - border-radius: 50%; - transform: scale(var(--cloud-scale)); - transform-origin: center; - - &::before { - content: ""; - position: absolute; - inset: -6%; - background: - radial-gradient(circle at 35% 45%, rgba(7, 193, 96, 0.12), transparent 55%), - radial-gradient(circle at 65% 50%, rgba(242, 170, 0, 0.1), transparent 58%), - radial-gradient(circle at 50% 65%, rgba(0, 0, 0, 0.04), transparent 60%); - filter: blur(18px); - border-radius: 50%; - pointer-events: none; - z-index: 0; - } -} - -.word-tag { - display: inline-block; - padding: 0; - background: transparent; - border-radius: 0; - border: none; - line-height: 1.2; - white-space: nowrap; - transition: transform 0.2s ease, color 0.2s ease; - cursor: default; - color: var(--ar-text-main); - font-weight: 600; - opacity: 0; - animation: wordPopIn 0.55s ease forwards; - position: absolute; - z-index: 1; - transform: translate(-50%, -50%) scale(0.8); - - &:hover { - transform: translate(-50%, -50%) scale(1.08); - color: var(--ar-primary); - z-index: 2; - } -} - -@keyframes wordPopIn { - 0% { - opacity: 0; - transform: translate(-50%, -50%) scale(0.6); - } - - 100% { - opacity: var(--final-opacity, 1); - transform: translate(-50%, -50%) scale(1); - } -} - -.word-cloud-note { - margin-top: 24px; - font-size: 14px !important; - color: var(--ar-text-sub) !important; - text-align: center; -} - -// 曾经的好朋友 视觉效果 -.lost-friend-visual { - display: flex; - align-items: center; - justify-content: center; - gap: 32px; - margin: 64px auto 48px; - position: relative; - max-width: 480px; - - .avatar-group { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - z-index: 2; - - .avatar-label { - font-size: 13px; - color: var(--ar-text-sub); - font-weight: 500; - opacity: 0.6; - } - - &.sender { - animation: fadeInRight 1s ease-out backwards; - } - - &.receiver { - animation: fadeInLeft 1s ease-out backwards; - } - } - - .fading-line { - position: relative; - flex: 1; - height: 2px; - min-width: 120px; - display: flex; - align-items: center; - justify-content: center; - - .line-path { - width: 100%; - height: 100%; - background: linear-gradient(to right, - var(--ar-primary) 0%, - rgba(var(--ar-primary-rgb), 0.4) 50%, - rgba(var(--ar-primary-rgb), 0.05) 100%); - border-radius: 2px; - } - - .line-glow { - position: absolute; - inset: -4px 0; - background: linear-gradient(to right, - rgba(var(--ar-primary-rgb), 0.2) 0%, - transparent 100%); - filter: blur(8px); - pointer-events: none; - } - - .flow-particle { - position: absolute; - width: 40px; - height: 2px; - background: linear-gradient(to right, transparent, var(--ar-primary), transparent); - border-radius: 2px; + @keyframes loadingPageEnter { + from { opacity: 0; - animation: flowAcross 4s infinite linear; + filter: blur(12px); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes loadingRingEnter { + from { + opacity: 0; + transform: translateY(12px) scale(0.94); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes loadingTextEnter { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); } } } - -.hero-desc.fading { - opacity: 0.7; - font-style: italic; - font-size: 16px; - margin-top: 32px; - line-height: 1.8; - letter-spacing: 0.05em; - animation: fadeIn 1.5s ease-out 0.5s backwards; -} - -@keyframes flowAcross { - 0% { - left: -20%; - opacity: 0; - } - - 10% { - opacity: 0.8; - } - - 50% { - opacity: 0.4; - } - - 90% { - opacity: 0.1; - } - - 100% { - left: 120%; - opacity: 0; - } -} - -@keyframes fadeInRight { - from { - opacity: 0; - transform: translateX(-20px); - } - - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes fadeInLeft { - from { - opacity: 0; - transform: translateX(20px); - } - - to { - opacity: 1; - transform: translateX(0); - } -} \ No newline at end of file diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index 5b2d510..5274bb7 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -1,14 +1,12 @@ -import { useState, useEffect, useRef } from 'react' -import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react' -import html2canvas from 'html2canvas' -import { useThemeStore } from '../stores/themeStore' +import { useState, useEffect, useRef, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { X } from 'lucide-react' import { finishBackgroundTask, isBackgroundTaskCancelRequested, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' -import { drawPatternBackground } from '../utils/reportExport' import './AnnualReportWindow.scss' interface TopContact { @@ -39,7 +37,13 @@ interface AnnualReportData { midnightKing: { displayName: string; count: number; percentage: number } | null selfAvatarUrl?: string mutualFriend?: { displayName: string; avatarUrl?: string; sentCount: number; receivedCount: number; ratio: number } | null - socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null + socialInitiative?: { + initiatedChats: number + receivedChats: number + initiativeRate: number + topInitiatedFriend?: string + topInitiatedCount?: number + } | null responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null topPhrases?: { phrase: string; count: number }[] snsStats?: { @@ -58,67 +62,71 @@ interface AnnualReportData { } | null } -interface SectionInfo { - id: string - name: string - ref: React.RefObject +const DecodeText = ({ + value, + active +}: { + value: string | number + active: boolean +}) => { + const strVal = String(value) + const [display, setDisplay] = useState(strVal) + const decodedRef = useRef(false) + + useEffect(() => { + setDisplay(strVal) + }, [strVal]) + + useEffect(() => { + if (!active) { + decodedRef.current = false + return + } + if (decodedRef.current) return + decodedRef.current = true + + const chars = '018X-/#*' + let iter = 0 + const inv = setInterval(() => { + setDisplay(strVal.split('').map((c, i) => { + if (c === ',' || c === ' ' || c === ':') return c + if (i < iter) return strVal[i] + return chars[Math.floor(Math.random() * chars.length)] + }).join('')) + + if (iter >= strVal.length) { + clearInterval(inv) + setDisplay(strVal) + } + iter += 1 / 3 + }, 35) + + return () => clearInterval(inv) + }, [active, strVal]) + + return <>{display.length > 0 ? display : value} } -// 头像组件 -const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?: 'sm' | 'md' | 'lg' }) => { - const [imgError, setImgError] = useState(false) - const initial = name?.[0] || '友' - - return ( -
- {url && !imgError ? ( - setImgError(true)} crossOrigin="anonymous" /> - ) : ( - {initial} - )} -
- ) -} - -import Heatmap from '../components/ReportHeatmap' -import WordCloud from '../components/ReportWordCloud' - function AnnualReportWindow() { + const navigate = useNavigate() + const containerRef = useRef(null) const [reportData, setReportData] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState('') - const [showExportModal, setShowExportModal] = useState(false) - const [selectedSections, setSelectedSections] = useState>(new Set()) - const [fabOpen, setFabOpen] = useState(false) const [loadingProgress, setLoadingProgress] = useState(0) const [loadingStage, setLoadingStage] = useState('正在初始化...') - const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate') - const { currentTheme, themeMode } = useThemeStore() - - // Section refs - const sectionRefs = { - cover: useRef(null), - overview: useRef(null), - bestFriend: useRef(null), - monthlyFriends: useRef(null), - mutualFriend: useRef(null), - socialInitiative: useRef(null), - peakDay: useRef(null), - streak: useRef(null), - heatmap: useRef(null), - midnightKing: useRef(null), - responseSpeed: useRef(null), - topPhrases: useRef(null), - ranking: useRef(null), - sns: useRef(null), - lostFriend: useRef(null), - ending: useRef(null), - } - - const containerRef = useRef(null) + const TOTAL_SCENES = 11 + const [currentScene, setCurrentScene] = useState(0) + const [isAnimating, setIsAnimating] = useState(false) + const p0CanvasRef = useRef(null) + const s3LayoutRef = useRef(null) + const s3ListRef = useRef(null) + const [s3LineVars, setS3LineVars] = useState({}) + + // 提取长图逻辑变量 + const [buttonText, setButtonText] = useState('EXTRACT RECORD') + const [isExtracting, setIsExtracting] = useState(false) useEffect(() => { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') @@ -132,7 +140,7 @@ function AnnualReportWindow() { const taskId = registerBackgroundTask({ sourcePage: 'annualReport', title: '年度报告生成', - detail: `正在生成 ${formatYearLabel(year)} 年度报告`, + detail: `正在生成 ${year === 0 ? '历史以来' : year + '年'} 年度报告`, progressText: '初始化', cancelable: true }) @@ -188,401 +196,311 @@ function AnnualReportWindow() { } } - const formatNumber = (num: number) => num.toLocaleString() + // Handle Scroll and touch events + const goToScene = useCallback((index: number) => { + if (isAnimating || index === currentScene || index < 0 || index >= TOTAL_SCENES) return - const getMostActiveTime = (data: number[][]) => { - let maxHour = 0, maxWeekday = 0, maxVal = 0 - data.forEach((row, w) => { - row.forEach((val, h) => { - if (val > maxVal) { maxVal = val; maxHour = h; maxWeekday = w } - }) + setIsAnimating(true) + setCurrentScene(index) + + setTimeout(() => { + setIsAnimating(false) + }, 1500) + }, [currentScene, isAnimating, TOTAL_SCENES]) + + useEffect(() => { + if (isLoading || error || !reportData) return + + let touchStartY = 0 + let lastWheelTime = 0 + + const handleWheel = (e: WheelEvent) => { + const now = Date.now() + if (now - lastWheelTime < 1000) return // Throttle wheel events + + if (Math.abs(e.deltaY) > 30) { + lastWheelTime = now + goToScene(e.deltaY > 0 ? currentScene + 1 : currentScene - 1) + } + } + + const handleTouchStart = (e: TouchEvent) => { + touchStartY = e.touches[0].clientY + } + + const handleTouchMove = (e: TouchEvent) => { + e.preventDefault() // prevent native scroll + } + + const handleTouchEnd = (e: TouchEvent) => { + const deltaY = touchStartY - e.changedTouches[0].clientY + if (deltaY > 40) goToScene(currentScene + 1) + else if (deltaY < -40) goToScene(currentScene - 1) + } + + window.addEventListener('wheel', handleWheel, { passive: false }) + window.addEventListener('touchstart', handleTouchStart, { passive: false }) + window.addEventListener('touchmove', handleTouchMove, { passive: false }) + window.addEventListener('touchend', handleTouchEnd) + + return () => { + window.removeEventListener('wheel', handleWheel) + window.removeEventListener('touchstart', handleTouchStart) + window.removeEventListener('touchmove', handleTouchMove) + window.removeEventListener('touchend', handleTouchEnd) + } + }, [currentScene, isLoading, error, reportData, goToScene]) + + useEffect(() => { + if (isLoading || error || !reportData || currentScene !== 0) return + + const canvas = p0CanvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) return + + let rafId = 0 + let particles: Array<{ + x: number + y: number + vx: number + vy: number + size: number + alpha: number + }> = [] + + const buildParticle = () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + vx: (Math.random() - 0.5) * 0.3, + vy: (Math.random() - 0.5) * 0.3, + size: Math.random() * 1.5 + 0.5, + alpha: Math.random() * 0.5 + 0.1 }) - const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] - return { weekday: weekdayNames[maxWeekday], hour: maxHour } - } - const formatTime = (seconds: number) => { - if (seconds < 60) return `${seconds}秒` - if (seconds < 3600) return `${Math.round(seconds / 60)}分钟` - return `${Math.round(seconds / 3600)}小时` - } - - const formatYearLabel = (value: number, withSuffix: boolean = true) => { - if (value === 0) return '历史以来' - return withSuffix ? `${value}年` : `${value}` - } - - // 获取可用的板块列表 - const getAvailableSections = (): SectionInfo[] => { - if (!reportData) return [] - const sections: SectionInfo[] = [ - { id: 'cover', name: '封面', ref: sectionRefs.cover }, - { id: 'overview', name: '年度概览', ref: sectionRefs.overview }, - ] - if (reportData.coreFriends[0]) { - sections.push({ id: 'bestFriend', name: '年度挚友', ref: sectionRefs.bestFriend }) - } - sections.push({ id: 'monthlyFriends', name: '月度好友', ref: sectionRefs.monthlyFriends }) - if (reportData.mutualFriend) { - sections.push({ id: 'mutualFriend', name: '双向奔赴', ref: sectionRefs.mutualFriend }) - } - if (reportData.socialInitiative) { - sections.push({ id: 'socialInitiative', name: '社交主动性', ref: sectionRefs.socialInitiative }) - } - if (reportData.peakDay) { - sections.push({ id: 'peakDay', name: '巅峰时刻', ref: sectionRefs.peakDay }) - } - if (reportData.longestStreak) { - sections.push({ id: 'streak', name: '聊天火花', ref: sectionRefs.streak }) - } - sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap }) - if (reportData.midnightKing) { - sections.push({ id: 'midnightKing', name: '深夜好友', ref: sectionRefs.midnightKing }) - } - if (reportData.responseSpeed) { - sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed }) - } - if (reportData.lostFriend) { - sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend }) - } - if (reportData.topPhrases && reportData.topPhrases.length > 0) { - sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases }) - } - sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking }) - if (reportData.snsStats && reportData.snsStats.totalPosts > 0) { - sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns }) - } - sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) - return sections - } - - // 导出单个板块 - 统一 16:9 尺寸 - const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => { - const element = section.ref.current - if (!element) { - return null + const initParticles = () => { + const count = Math.max(36, Math.floor((canvas.width * canvas.height) / 15000)) + particles = Array.from({ length: count }, () => buildParticle()) } - // 固定输出尺寸 1920x1080 (16:9) - const OUTPUT_WIDTH = 1920 - const OUTPUT_HEIGHT = 1080 - - try { - const selection = window.getSelection() - if (selection && selection.rangeCount > 0) selection.removeAllRanges() - const activeEl = document.activeElement as HTMLElement | null - activeEl?.blur?.() - document.body.classList.add('exporting-snapshot') - document.documentElement.classList.add('exporting-snapshot') - - const originalStyle = element.style.cssText - element.style.minHeight = 'auto' - element.style.padding = '40px 20px' - element.style.background = 'transparent' - element.style.backgroundColor = 'transparent' - element.style.boxShadow = 'none' - - // 修复词云 - const wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement - const wordTags = element.querySelectorAll('.word-tag') as NodeListOf - let wordCloudOriginalStyle = '' - const wordTagOriginalStyles: string[] = [] - - if (wordCloudInner) { - wordCloudOriginalStyle = wordCloudInner.style.cssText - wordCloudInner.style.transform = 'none' - } - - wordTags.forEach((tag, i) => { - wordTagOriginalStyles[i] = tag.style.cssText - tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') - tag.style.animation = 'none' - }) - - await new Promise(r => setTimeout(r, 50)) - - const computedStyle = getComputedStyle(document.documentElement) - const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' - - const canvas = await html2canvas(element, { - backgroundColor: 'transparent', // 透明背景,让 SVG 图案显示 - scale: 2, - useCORS: true, - allowTaint: true, - logging: false, - onclone: (clonedDoc) => { - clonedDoc.body.classList.add('exporting-snapshot') - clonedDoc.documentElement.classList.add('exporting-snapshot') - clonedDoc.getSelection?.()?.removeAllRanges() - }, - }) - - // 恢复样式 - element.style.cssText = originalStyle - if (wordCloudInner) { - wordCloudInner.style.cssText = wordCloudOriginalStyle - } - wordTags.forEach((tag, i) => { - tag.style.cssText = wordTagOriginalStyles[i] - }) - document.body.classList.remove('exporting-snapshot') - document.documentElement.classList.remove('exporting-snapshot') - - // 创建固定 16:9 尺寸的画布 - const outputCanvas = document.createElement('canvas') - outputCanvas.width = OUTPUT_WIDTH - outputCanvas.height = OUTPUT_HEIGHT - const ctx = outputCanvas.getContext('2d')! - - // 绘制带 SVG 图案的背景 - const isDark = themeMode === 'dark' - await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark) - - // 边距 (留出更多空白) - const PADDING = 80 - const contentWidth = OUTPUT_WIDTH - PADDING * 2 - const contentHeight = OUTPUT_HEIGHT - PADDING * 2 - - // 计算缩放和居中位置 - const srcRatio = canvas.width / canvas.height - const dstRatio = contentWidth / contentHeight - let drawWidth: number, drawHeight: number, drawX: number, drawY: number - - if (srcRatio > dstRatio) { - // 源图更宽,以宽度为准 - drawWidth = contentWidth - drawHeight = contentWidth / srcRatio - drawX = PADDING - drawY = PADDING + (contentHeight - drawHeight) / 2 - } else { - // 源图更高,以高度为准 - drawHeight = contentHeight - drawWidth = contentHeight * srcRatio - drawX = PADDING + (contentWidth - drawWidth) / 2 - drawY = PADDING - } - - ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight) - - return { name: section.name, data: outputCanvas.toDataURL('image/png') } - } catch (e) { - document.body.classList.remove('exporting-snapshot') - return null + const resizeCanvas = () => { + canvas.width = window.innerWidth + canvas.height = window.innerHeight + initParticles() } - } - // 导出整个报告为长图 - const exportFullReport = async (filterIds?: Set) => { - if (!containerRef.current) { - return - } - setIsExporting(true) - setExportProgress('正在生成长图...') + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) - try { - const selection = window.getSelection() - if (selection && selection.rangeCount > 0) selection.removeAllRanges() - const activeEl = document.activeElement as HTMLElement | null - activeEl?.blur?.() - document.body.classList.add('exporting-snapshot') - document.documentElement.classList.add('exporting-snapshot') + for (let i = 0; i < particles.length; i++) { + const p = particles[i] + p.x += p.vx + p.y += p.vy - const container = containerRef.current - const sections = container.querySelectorAll('.section') - const originalStyles: string[] = [] + if (p.x < 0 || p.x > canvas.width) p.vx *= -1 + if (p.y < 0 || p.y > canvas.height) p.vy *= -1 - sections.forEach((section, i) => { - const el = section as HTMLElement - originalStyles[i] = el.style.cssText - el.style.minHeight = 'auto' - el.style.padding = '40px 0' - }) + ctx.beginPath() + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2) + const particleAlpha = p.alpha * (i % 4 === 0 ? 0.95 : 0.72) + ctx.fillStyle = i % 4 === 0 + ? `rgba(184, 148, 90, ${particleAlpha})` + : `rgba(255, 255, 255, ${particleAlpha})` + ctx.fill() - // 如果有筛选,隐藏未选中的板块 - if (filterIds) { - const available = getAvailableSections() - available.forEach(s => { - if (!filterIds.has(s.id) && s.ref.current) { - s.ref.current.style.display = 'none' + for (let j = i + 1; j < particles.length; j++) { + const q = particles[j] + const dx = p.x - q.x + const dy = p.y - q.y + const distance = Math.sqrt(dx * dx + dy * dy) + + if (distance < 150) { + const lineAlpha = (1 - distance / 150) * 0.15 + ctx.beginPath() + ctx.moveTo(p.x, p.y) + ctx.lineTo(q.x, q.y) + ctx.strokeStyle = i % 3 === 0 + ? `rgba(184, 148, 90, ${lineAlpha * 0.8})` + : `rgba(255, 255, 255, ${lineAlpha * 0.72})` + ctx.lineWidth = 0.5 + ctx.stroke() } - }) + } } - // 修复词云导出问题 - const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement - const wordTags = container.querySelectorAll('.word-tag') as NodeListOf - let wordCloudOriginalStyle = '' - const wordTagOriginalStyles: string[] = [] - - if (wordCloudInner) { - wordCloudOriginalStyle = wordCloudInner.style.cssText - wordCloudInner.style.transform = 'none' - } - - wordTags.forEach((tag, i) => { - wordTagOriginalStyles[i] = tag.style.cssText - tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') - tag.style.animation = 'none' - }) - - // 等待样式生效 - await new Promise(r => setTimeout(r, 100)) - - // 获取计算后的背景色 - const computedStyle = getComputedStyle(document.documentElement) - const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' - - const canvas = await html2canvas(container, { - backgroundColor: 'transparent', // 透明背景 - scale: 2, - useCORS: true, - allowTaint: true, - logging: false, - onclone: (clonedDoc) => { - clonedDoc.body.classList.add('exporting-snapshot') - clonedDoc.documentElement.classList.add('exporting-snapshot') - clonedDoc.getSelection?.()?.removeAllRanges() - }, - }) - - // 恢复原始样式 - sections.forEach((section, i) => { - const el = section as HTMLElement - el.style.cssText = originalStyles[i] - }) - - if (wordCloudInner) { - wordCloudInner.style.cssText = wordCloudOriginalStyle - } - - wordTags.forEach((tag, i) => { - tag.style.cssText = wordTagOriginalStyles[i] - }) - document.body.classList.remove('exporting-snapshot') - document.documentElement.classList.remove('exporting-snapshot') - - // 创建带 SVG 图案背景的输出画布 - const outputCanvas = document.createElement('canvas') - outputCanvas.width = canvas.width - outputCanvas.height = canvas.height - const ctx = outputCanvas.getContext('2d')! - - // 绘制 SVG 图案背景 - const isDark = themeMode === 'dark' - await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark) - - // 绘制内容 - ctx.drawImage(canvas, 0, 0) - - const dataUrl = outputCanvas.toDataURL('image/png') - const link = document.createElement('a') - const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : '' - link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png` - link.href = dataUrl - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - } catch (e) { - alert('导出失败: ' + String(e)) - } finally { - document.body.classList.remove('exporting-snapshot') - document.documentElement.classList.remove('exporting-snapshot') - setIsExporting(false) - setExportProgress('') + rafId = requestAnimationFrame(animate) } + + resizeCanvas() + window.addEventListener('resize', resizeCanvas) + animate() + + return () => { + window.removeEventListener('resize', resizeCanvas) + cancelAnimationFrame(rafId) + } + }, [isLoading, error, reportData, currentScene]) + + useEffect(() => { + if (isLoading || error || !reportData) return + + let rafId = 0 + + const updateS3Line = () => { + cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + const root = document.querySelector('.annual-report-window') as HTMLElement | null + const layout = s3LayoutRef.current + const list = s3ListRef.current + if (!root || !layout || !list) return + + const rootRect = root.getBoundingClientRect() + const layoutRect = layout.getBoundingClientRect() + const listRect = list.getBoundingClientRect() + if (listRect.height <= 0 || layoutRect.width <= 0) return + + const leftOffset = Math.max(8, Math.min(16, layoutRect.width * 0.018)) + const lineLeft = layoutRect.left - rootRect.left + leftOffset + const lineCenterTop = listRect.top - rootRect.top + listRect.height / 2 + + setS3LineVars({ + ['--s3-line-left' as '--s3-line-left']: `${lineLeft}px`, + ['--s3-line-top' as '--s3-line-top']: `${lineCenterTop}px`, + ['--s3-line-height' as '--s3-line-height']: `${listRect.height}px` + } as React.CSSProperties) + }) + } + + updateS3Line() + window.addEventListener('resize', updateS3Line) + + const resizeObserver = typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => updateS3Line()) + : null + + if (resizeObserver) { + if (s3LayoutRef.current) resizeObserver.observe(s3LayoutRef.current) + if (s3ListRef.current) resizeObserver.observe(s3ListRef.current) + } + + return () => { + cancelAnimationFrame(rafId) + window.removeEventListener('resize', updateS3Line) + resizeObserver?.disconnect() + } + }, [isLoading, error, reportData, currentScene]) + + const getSceneClass = (index: number) => { + if (index === currentScene) return 'scene active' + if (index < currentScene) return 'scene prev' + return 'scene next' } - // 导出选中的板块 - const exportSelectedSections = async () => { - const sections = getAvailableSections().filter(s => selectedSections.has(s.id)) - if (sections.length === 0) { - alert('请至少选择一个板块') - return + const handleClose = () => { + navigate('/home') + } + + const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year)) + + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const waitForNextPaint = () => new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()) + }) + }) + const captureSceneDataUrl = async (): Promise => { + const captureFn = window.electronAPI.annualReport.captureCurrentWindow + if (typeof captureFn !== 'function') { + throw new Error('当前版本未启用原生截图接口,请重启应用后重试') } - if (exportMode === 'long') { - setShowExportModal(false) - await exportFullReport(selectedSections) - setSelectedSections(new Set()) - return + const captureResult = await captureFn() + if (!captureResult.success || !captureResult.dataUrl) { + throw new Error(captureResult.error || '原生截图失败') } + return captureResult.dataUrl + } - setIsExporting(true) - setShowExportModal(false) - - const exportedImages: { name: string; data: string }[] = [] - - for (let i = 0; i < sections.length; i++) { - const section = sections[i] - setExportProgress(`正在导出: ${section.name} (${i + 1}/${sections.length})`) - - const result = await exportSection(section) - if (result) { - exportedImages.push(result) - } - } - - if (exportedImages.length === 0) { - alert('导出失败') - setIsExporting(false) - setExportProgress('') - return - } + const handleExtract = async () => { + if (isExtracting || !reportData || !containerRef.current) return const dirResult = await window.electronAPI.dialog.openDirectory({ title: '选择导出文件夹', properties: ['openDirectory', 'createDirectory'] }) - if (dirResult.canceled || !dirResult.filePaths?.[0]) { - setIsExporting(false) - setExportProgress('') - return - } + if (dirResult.canceled || !dirResult.filePaths?.[0]) return - setExportProgress('正在写入文件...') - const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : '' - const exportResult = await window.electronAPI.annualReport.exportImages({ - baseDir: dirResult.filePaths[0], - folderName: `${yearFilePrefix}年度报告_分模块`, - images: exportedImages.map((img) => ({ - name: `${yearFilePrefix}年度报告_${img.name}.png`, - dataUrl: img.data - })) - }) + const root = containerRef.current + const previousScene = currentScene + const sceneNames = [ + 'THE_ARCHIVE', + 'VOLUME', + 'NOCTURNE', + 'GRAVITY_CENTERS', + 'TIME_WAVEFORM', + 'MUTUAL_RESONANCE', + 'SOCIAL_KINETICS', + 'THE_SPARK', + 'FADING_SIGNALS', + 'LEXICON', + 'EXTRACTION' + ] - if (!exportResult.success) { - alert('导出失败: ' + (exportResult.error || '未知错误')) - } + setIsExtracting(true) + setButtonText('EXTRACTING...') - setIsExporting(false) - setExportProgress('') - setSelectedSections(new Set()) - } + try { + const images: Array<{ name: string; dataUrl: string }> = [] + root.classList.add('exporting-scenes') + await waitForNextPaint() + await wait(120) + // 预检:强制验证主进程已注册原生截图 handler,确保导出链路不是旧逻辑。 + await captureSceneDataUrl() - // 切换板块选择 - const toggleSection = (id: string) => { - const newSet = new Set(selectedSections) - if (newSet.has(id)) { - newSet.delete(id) - } else { - newSet.add(id) - } - setSelectedSections(newSet) - } + for (let i = 0; i < TOTAL_SCENES; i++) { + setCurrentScene(i) + setButtonText(`EXTRACTING ${i + 1}/${TOTAL_SCENES}`) + await waitForNextPaint() + await wait(1700) - // 全选/取消全选 - const toggleAll = () => { - const sections = getAvailableSections() - if (selectedSections.size === sections.length) { - setSelectedSections(new Set()) - } else { - setSelectedSections(new Set(sections.map(s => s.id))) + images.push({ + name: `P${String(i).padStart(2, '0')}_${sceneNames[i] || `SCENE_${i}`}.png`, + dataUrl: await captureSceneDataUrl() + }) + } + + const yearFilePrefix = formatFileYearLabel(reportData.year) + const exportResult = await window.electronAPI.annualReport.exportImages({ + baseDir: dirResult.filePaths[0], + folderName: `${yearFilePrefix}年度报告_分页面`, + images + }) + + if (!exportResult.success) { + throw new Error(exportResult.error || '导出失败') + } + + setButtonText('SAVED TO DEVICE') + } catch (e) { + alert(`导出失败: ${String(e)}`) + setButtonText('EXTRACT RECORD') + } finally { + root.classList.remove('exporting-scenes') + setCurrentScene(previousScene) + await wait(80) + + setTimeout(() => { + setButtonText('EXTRACT RECORD') + setIsExtracting(false) + }, 2200) } } if (isLoading) { return (
+
+ +
@@ -600,467 +518,516 @@ function AnnualReportWindow() { ) } - if (error) { + if (error || !reportData) { return (
-

生成报告失败: {error}

+
+ +
+

{error ? `生成报告失败: ${error}` : '暂无数据'}

) } - if (!reportData) { - return ( -
-

暂无数据

-
- ) - } - - const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData - const topFriend = coreFriends[0] - const mostActive = getMostActiveTime(activityHeatmap.data) - const socialStoryName = topFriend?.displayName || '好友' - const yearTitle = formatYearLabel(year, true) - const yearTitleShort = formatYearLabel(year, false) - const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友` - const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语` + const yearTitle = reportData.year === 0 ? '历史以来' : String(reportData.year) + const finalYearLabel = reportData.year === 0 ? 'ALL YEARS' : String(reportData.year) + const compactYearTitle = yearTitle.replace(/\s+/g, '') + const isNumericYearTitle = /^\d+$/.test(compactYearTitle) + const yearTitleVariantClass = isNumericYearTitle + ? 'title-year--numeric' + : compactYearTitle.length >= 5 + ? 'title-year--text-long' + : 'title-year--text' + const topFriends = reportData.coreFriends.slice(0, 3) + const endingPostCount = reportData.snsStats?.totalPosts ?? 0 + const endingReceivedChats = reportData.socialInitiative?.receivedChats ?? 0 + const endingTopPhrase = reportData.topPhrases?.[0]?.phrase || '' + const COLOR = { + accentGold: 'var(--c-gold-strong)', + accentSoft: 'rgba(var(--c-gold-rgb), 0.58)', + accentMuted: 'rgba(var(--c-gold-rgb), 0.48)', + textStrong: 'var(--c-text-bright)', + textSoft: 'var(--c-text-soft)', + textMuted: 'var(--c-text-muted)', + textFaint: 'var(--c-text-faint)', + paperInk: 'var(--c-paper-ink)', + paperMuted: 'var(--c-paper-muted)' + } as const return ( -
-
- - {/* 背景装饰 */} -
-
-
-
-
-
+
+
+
- {/* 浮动操作按钮 */} -
- - - - +
+ +
+
+ +
+ +
+ +
+ {Array.from({ length: TOTAL_SCENES }).map((_, i) => ( +
goToScene(i)} + /> + ))} +
+ +
向下滑动以继续
+ + {/* S0: THE ARCHIVE */} +
+
+
一切的起点
+
+
+
{yearTitle}
+
+
+
那些被岁月悄悄掩埋的对话
原来都在这里,等待一个春天。
+
- {/* 导出进度 */} - {isExporting && ( -
-
-
-
- -
-

正在导出

-

{exportProgress}

+ {/* S1: VOLUME */} +
+
+
消息报告
+
+
+
+
- )} +
+
+ 这一年,你说出了 {reportData.totalMessages.toLocaleString()} 句话。
无数个日夜的碎碎念,都是为了在茫茫人海中,刻下彼此来过的痕迹。 +
+
+
- {/* 模块选择弹窗 */} - {showExportModal && ( -
setShowExportModal(false)}> -
e.stopPropagation()}> -
-

{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}

- -
-
- {getAvailableSections().map(section => ( -
toggleSection(section.id)} - > -
- {selectedSections.has(section.id) && } + {/* S2: NOCTURNE */} +
+
+
深夜
+
+
+
+ {reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'} +
+
+
+
+
+ 在深夜陪你聊天最多的人 +
+
+
+
+ 梦境之外,你与{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}共同醒着度过了许多个夜晚
+ “曾有 + + 条消息在那些无人知晓的夜里,代替星光照亮了彼此” +
+
+
+ + {/* S3: GRAVITY CENTERS */} +
+
+
聊天排行
+
+ +
+
+
漫长的岁月里,是他们,让你的时间有了实实在在的重量。
+
+ +
+ {topFriends.map((f, i) => ( +
+
+
+
+ {f.displayName} +
+
TOP {i + 1}
+
+
+ {f.messageCount.toLocaleString()}
- {section.name}
- ))} +
+ ))} + {topFriends.length === 0 && ( +
+
+
+
暂无记录
+
+
+
+ )} +
+
+
+ + {/* S4: TIME WAVEFORM (Audio/Heartbeat timeline visual) */} +
+
+
时间的长河
+
+
+
十二个月的更迭,就像走过了一万个冬天
时间在变,但好在总有人陪在身边。
+
+ + {reportData.monthlyTopFriends.length > 0 ? ( +
+ {reportData.monthlyTopFriends.map((m, i) => { + const leftPos = (i / 11) * 100; // 0% to 100% + const isTop = i % 2 === 0; // Alternate up and down to prevent crowding + const isRightSide = i >= 6; // Center-focus alignment logic + + // Pseudo-random organic height variation for audio-wave feel (from 8vh to 18vh) + const heightVariation = 12 + (Math.sin(i * 1.5) * 6); + + const alignStyle = isRightSide ? { right: '10px', alignItems: 'flex-end', textAlign: 'right' as const } : { left: '10px', alignItems: 'flex-start', textAlign: 'left' as const }; + + return ( +
+ + {/* The connecting thread (gradient fades away from center line) */} +
+ + {/* Center Glowing Dot */} +
+ + {/* Text Payload */} +
+
+ {m.month.toString().padStart(2, '0')} +
+
+ {m.displayName} +
+
+ {m.messageCount.toLocaleString()} M +
+
+ +
+ ); + })} +
+ ) : ( +
+
暂无记忆声纹
+
+ )} +
+ + {/* S5: MUTUAL RESONANCE (Mutual friend) */} +
+
+
回应的艺术
+
+ {reportData.mutualFriend ? ( + <> +
+
+ {reportData.mutualFriend.displayName} +
-
- - + +
+
发出
+
+
+
+
收到
+
+
+ +
+
+ 你们之间收发的消息高达 {reportData.mutualFriend.ratio} 的平衡率 +
+ “你抛出的每一句话,都落在了对方的心里。
所谓重逢,就是我走向你的时候,你也在走向我。”
+
+
+ + ) : ( +
今年似乎独自咽下了很多话。
请相信,分别和孤独总会迎来终结,你终会遇到那个懂你的TA。
+ )} +
+ + {/* S6: SOCIAL KINETICS */} +
+
+
我的风格
+
+ {reportData.socialInitiative || reportData.responseSpeed ? ( +
+ {reportData.socialInitiative && ( +
+
我的主动性
+
+ {reportData.socialInitiative.initiativeRate}% +
+
+
+ 你的聊天开场大多由你发起。 +
+ {reportData.socialInitiative.topInitiatedFriend && (reportData.socialInitiative.topInitiatedCount || 0) > 0 ? ( +
+ 其中{reportData.socialInitiative.topInitiatedFriend}是你最常联系的人, + 有{(reportData.socialInitiative.topInitiatedCount || 0).toLocaleString()}次,是你先忍不住敲响了对方的门 +
+ ) : ( +
+ 你主动发起了{reportData.socialInitiative.initiatedChats.toLocaleString()}次联络。 +
+ )} + 想见一个人的心,总是走在时间的前面。 +
+
+ )} + {reportData.responseSpeed && ( +
+
回应速度
+
+ S +
+
+ {reportData.responseSpeed.fastestFriend} 回你的消息总是很快。
+ 这世上最让人安心的默契,莫过于一句 "我在"。 +
+
+ )} +
+ ) : ( +
暂无数据。
+ )} +
+ + {/* S7: THE SPARK */} +
+
+
聊天火花
+
+ + {reportData.longestStreak ? ( +
+
最长连续聊天
+
+ {reportData.longestStreak.friendName} +
+
+ 你们曾连续 天,聊到忘记了时间,
那些舍不得说再见的日夜,连成了最漫长的春天。 +
+
+ ) : null} + + {reportData.peakDay ? ( +
+
最热烈的一天
+
+ {reportData.peakDay.date} +
+
+ “这一天,你们留下了 {reportData.peakDay.messageCount} 句话。
好像要把积攒了很久的想念,一天全都说完。” +
+
+ ) : null} + + {!reportData.longestStreak && !reportData.peakDay && ( +
没有激起过火花。
+ )} +
+ + {/* S8: FADING SIGNALS */} +
+
+
曾经的好友
+
+ {reportData.lostFriend ? ( +
+
+
+
+ {reportData.lostFriend.displayName} +
+
+
+
+ 后来,你们的交集停留在{reportData.lostFriend.periodDesc}这短短的 + + + + 句话里。 +
+
+
+
+ “我一直相信我们能够再次相见,相信分别的日子总会迎来终结。” +
+
+
+
+
+ 所有的离散,或许都只是一场漫长的越冬。飞鸟要越过一万座雪山,才能带来春天的第一行回信;树木要褪去一万次枯叶,才能记住风的形状。如果时间注定要把我们推向不同的象限,那就在记忆的最深处建一座灯塔。哪怕要熬过几千个无法见面的黄昏,也要相信,总有一次日出的晨光,是为了照亮我们重逢的归途。 +
+
+
+ ) : ( +
+
+ 缘分温柔地眷顾着你。
+ 这一年,所有重要的人都在,没有一次无疾而终的告别。
+
+
+ )} +
+ + {/* S9: LEXICON & ARCHIVE */} +
+
+
我的词云
+
+ + {reportData.topPhrases && reportData.topPhrases.slice(0, 12).map((phrase, i) => { + // 12 precisely tuned absolute coordinates for the ultimate organic scatter without overlapping + const demoStyles = [ + { left: '25vw', top: '25vh', fontSize: 'clamp(3rem, 7vw, 5rem)', color: 'rgba(250,250,248,0.96)', delay: '0.1s', floatDelay: '0s', targetOp: 0.96 }, + { left: '72vw', top: '30vh', fontSize: 'clamp(2rem, 5vw, 4rem)', color: 'rgba(250,250,248,0.78)', delay: '0.2s', floatDelay: '-1s', targetOp: 0.78 }, + { left: '15vw', top: '55vh', fontSize: 'clamp(2.5rem, 6vw, 4.5rem)', color: 'rgba(200,170,120,0.72)', delay: '0.3s', floatDelay: '-2.5s', targetOp: 0.72 }, + { left: '78vw', top: '60vh', fontSize: 'clamp(1.5rem, 3.5vw, 3rem)', color: 'rgba(250,250,248,0.62)', delay: '0.4s', floatDelay: '-1.5s', targetOp: 0.62 }, + { left: '45vw', top: '75vh', fontSize: 'clamp(1.2rem, 3vw, 2.5rem)', color: 'rgba(200,170,120,0.58)', delay: '0.5s', floatDelay: '-3s', targetOp: 0.58 }, + { left: '55vw', top: '15vh', fontSize: 'clamp(1.5rem, 3vw, 2.5rem)', color: 'rgba(250,250,248,0.52)', delay: '0.6s', floatDelay: '-0.5s', targetOp: 0.52 }, + { left: '12vw', top: '80vh', fontSize: 'clamp(1rem, 2vw, 1.8rem)', color: 'rgba(250,250,248,0.42)', delay: '0.7s', floatDelay: '-1.2s', targetOp: 0.42 }, + { left: '35vw', top: '45vh', fontSize: 'clamp(2.2rem, 5vw, 4rem)', color: 'rgba(250,250,248,0.82)', delay: '0.8s', floatDelay: '-0.8s', targetOp: 0.82 }, + { left: '85vw', top: '82vh', fontSize: 'clamp(0.9rem, 1.5vw, 1.5rem)', color: 'rgba(200,170,120,0.34)', delay: '0.9s', floatDelay: '-2.1s', targetOp: 0.34 }, + { left: '60vw', top: '50vh', fontSize: 'clamp(1.8rem, 4vw, 3.5rem)', color: 'rgba(250,250,248,0.64)', delay: '1s', floatDelay: '-0.3s', targetOp: 0.64 }, + { left: '45vw', top: '35vh', fontSize: 'clamp(1rem, 2vw, 1.8rem)', color: 'rgba(250,250,248,0.38)', delay: '1.1s', floatDelay: '-1.8s', targetOp: 0.38 }, + { left: '30vw', top: '65vh', fontSize: 'clamp(1.4rem, 2.5vw, 2.2rem)', color: 'rgba(200,170,120,0.46)', delay: '1.2s', floatDelay: '-2.7s', targetOp: 0.46 }, + ]; + const st = demoStyles[i]; + + return ( +
+ {phrase.phrase} +
+ ) + })} + {(!reportData.topPhrases || reportData.topPhrases.length === 0) && ( +
词汇量太少,无法形成星云。
+ )} +
+ + {/* S10: EXTRACTION (白色反色结束页 / Data Receipt) */} +
+
+
旅程的终点
+
+ + {/* The Final Summary Receipt / Dashboard */} +
+
+
+ {finalYearLabel} +
+
+ TRANSMISSION COMPLETE +
+ + {/* Core Stats Row */} +
+
+
朋友圈发帖
+
{endingPostCount.toLocaleString()}
+
+
+
被动开场
+
{endingReceivedChats.toLocaleString()}
+
+
+
你最爱说
+
“{endingTopPhrase}”
+
+
+ +
+ “故事的最后,我们把这一切悄悄还给岁月
只要这些文字还在,所有的离别,就都只是一场短暂的缺席。”
- )} -
-
- - {/* 封面 */} -
-
WEFLOW · ANNUAL REPORT
-

{yearTitle}
微信聊天报告

-
-

每一条消息背后
都藏着一段独特的故事

-
- - {/* 年度概览 */} -
-
年度概览
-

你和你的朋友们
互相发过

-
- {formatNumber(totalMessages)} - 条消息 +
+
+
+ 数据数得清一万句落笔的寒暄,却度量不出一个默契的眼神。
在这片由数字构建的大海里,热烈的回应未必是感情的全部轮廓。
真正的爱与羁绊,从来都不在跳动的屏幕里,而在无法被量化的现实。
-

- 在这段时光里,你与 {formatNumber(totalFriends)} 位好友交换过喜怒哀乐。 -
每一个对话,都是一段故事的开始。 -

-
- - {/* 年度挚友 */} - {topFriend && ( -
-
年度挚友
-

{topFriend.displayName}

-
- {formatNumber(topFriend.messageCount)} - 条消息 -
-

- 你发出 {formatNumber(topFriend.sentCount)} 条 · - TA发来 {formatNumber(topFriend.receivedCount)} 条 -

-
-

- 在一起,就可以 -

-
- )} - - {/* 月度好友 */} -
-
月度好友
-

{monthlyTitle}

-

根据12个月的聊天习惯

-
- {monthlyTopFriends.map((m, i) => ( -
-
{m.month}月
- -
{m.displayName}
-
- ))} -
- -
-
-

你只管说
我一直在

-
- - {/* 双向奔赴 */} - {mutualFriend && ( -
-
双向奔赴
-

默契与平衡

-
-
- -
- {formatNumber(mutualFriend.sentCount)} -
-
-
-
-
🤝
-
{mutualFriend.ratio}
-
-
-
- {formatNumber(mutualFriend.receivedCount)} -
-
- -
-
-
{mutualFriend.displayName}
-

- 你们的互动比例接近 {mutualFriend.ratio}。 -
你来我往,势均力敌。 -

-
- )} - - {/* 社交主动性 */} - {socialInitiative && ( -
-
社交主动性
-

主动才有故事

-
- {socialInitiative.initiativeRate}% - 的对话由你发起 -
-

- 面对 {socialStoryName} 的时候,你总是那个先开口的人。 -

-
- )} - - {/* 巅峰时刻 */} - {peakDay && ( -
-
巅峰时刻
-

{peakDay.date}

-

一天里你一共发了

-
- {formatNumber(peakDay.messageCount)} - 条消息 -
-

- 在这个快节奏的世界,有人正陪在你身边听你慢慢地讲 -
那天,你和 {peakDay.topFriend || '好友'} 的 {formatNumber(peakDay.topFriendCount || 0)} 条消息见证着这一切 -
有些话,只想对你说 -

-
- )} - - {/* 聊天火花 */} - {longestStreak && ( -
-
持之以恒
-

聊天火花

-

{longestStreak.friendName} 持续了

-
- {longestStreak.days} - -
-

- 从 {longestStreak.startDate} 到 {longestStreak.endDate} -

-

陪伴,是最长情的告白

-
- )} - - {/* 作息规律 */} -
-
作息规律
-

时间的痕迹

-

- 在 {mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00 最活跃 -

- -
- - {/* 深夜好友 */} - {midnightKing && ( -
-
深夜好友
-

月光下的你

-

在这一年你留下了

-
- {midnightKing.count} - 条深夜的消息 -
-

- 其中 {midnightKing.displayName} 常常在深夜中陪着你胡思乱想。 -
你和Ta的对话占你深夜期间聊天的 {midnightKing.percentage}%。 -

-
- )} - - {/* 回应速度 */} - {responseSpeed && ( -
-
回应速度
-

念念不忘,必有回响

-
- {formatTime(responseSpeed.avgResponseTime)} - 是你的平均回复时间 -
-

- 你回复 {responseSpeed.fastestFriend} 最快 -
平均只需 {formatTime(responseSpeed.fastestTime)} -

-
- )} - - {/* 曾经的好朋友 */} - {lostFriend && ( -
-
曾经的好朋友
-

{lostFriend.displayName}

-
- {formatNumber(lostFriend.earlyCount)} - 条消息 -
-

- 在 {lostFriend.periodDesc} -
你们曾有聊不完的话题 -

-
-
- - TA -
-
-
-
-
-
-
- - -
-
-

- 人类发明后悔 -
来证明拥有的珍贵 -

-
- )} - - {/* 年度常用语 - 词云 */} - {topPhrases && topPhrases.length > 0 && ( -
-
年度常用语
-

{phrasesTitle}

-

- 这一年,你说得最多的是: -
- - {topPhrases.slice(0, 3).map(p => p.phrase).join('、')} - -

- -

颜色越深代表出现频率越高

-
- )} - - {/* 朋友圈 */} - {reportData.snsStats && reportData.snsStats.totalPosts > 0 && ( -
-
朋友圈
-

记录生活时刻

-

- 这一年,你发布了 -

-
- {reportData.snsStats.totalPosts} - 条朋友圈 -
- -
- {reportData.snsStats.topLikers.length > 0 && ( -
-

更关心你的Ta

-
- {reportData.snsStats.topLikers.slice(0, 3).map((u, i) => ( -
- -
- {u.displayName} -
- {u.count}赞 -
- ))} -
-
- )} - - {reportData.snsStats.topLiked.length > 0 && ( -
-

你最关心的Ta

-
- {reportData.snsStats.topLiked.slice(0, 3).map((u, i) => ( -
- -
- {u.displayName} -
- {u.count}赞 -
- ))} -
-
- )} -
-
- )} - - {/* 好友排行 */} -
-
好友排行
-

聊得最多的人

- - {/* 领奖台 - 前三名 */} -
- {/* 第二名 - 左边 */} - {coreFriends[1] && ( -
- -
{coreFriends[1].displayName}
-
{formatNumber(coreFriends[1].messageCount)} 条
-
- 2 -
-
- )} - - {/* 第一名 - 中间最高 */} - {coreFriends[0] && ( -
-
👑
- -
{coreFriends[0].displayName}
-
{formatNumber(coreFriends[0].messageCount)} 条
-
- 1 -
-
- )} - - {/* 第三名 - 右边 */} - {coreFriends[2] && ( -
- -
{coreFriends[2].displayName}
-
{formatNumber(coreFriends[2].messageCount)} 条
-
- 3 -
-
- )} -
-
- - {/* 结尾 */} -
-

尾声

-

- 我们总是在向前走 -
却很少有机会回头看看 -
如果这份报告让你有所触动,不妨把它分享给你在意的人 -
愿新的一年, -
所有期待,皆有回声。 -

-
{yearTitleShort}
-
WEFLOW
-
+
+
+ +
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 362960a..3554fcb 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1898,6 +1898,9 @@ const TaskCenterModal = memo(function TaskCenterModal({ const mediaCacheMetricLabel = mediaCacheTotal > 0 ? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}` : '' + const mediaMissMetricLabel = mediaCacheMissFiles > 0 + ? `未导出 ${mediaCacheMissFiles} 个文件/媒体` + : '' const mediaDedupMetricLabel = mediaDedupReuseFiles > 0 ? `复用 ${mediaDedupReuseFiles}` : '' @@ -1958,6 +1961,7 @@ const TaskCenterModal = memo(function TaskCenterModal({ {phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''} {mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''} {mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''} + {mediaMissMetricLabel ? ` · ${mediaMissMetricLabel}` : ''} {mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''} {task.status === 'running' && currentSessionRatio !== null ? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)` diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 753f2db..c65902d 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -75,6 +75,7 @@ interface WxidOption { type SessionFilterType = configService.MessagePushSessionType type SessionFilterTypeValue = 'all' | SessionFilterType type SessionFilterMode = 'all' | 'whitelist' | 'blacklist' +type InsightSessionFilterTypeValue = 'all' | 'private' | 'group' | 'official' interface SessionFilterOption { username: string @@ -91,6 +92,13 @@ const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: st { value: 'other', label: '其他/非好友' } ] +const insightFilterTypeOptions: Array<{ value: InsightSessionFilterTypeValue; label: string }> = [ + { value: 'all', label: '全部' }, + { value: 'private', label: '私聊' }, + { value: 'group', label: '群聊' }, + { value: 'official', label: '订阅号/服务号' } +] + interface SettingsPageProps { onClose?: () => void } @@ -194,6 +202,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) + const [insightFilterModeDropdownOpen, setInsightFilterModeDropdownOpen] = useState(false) const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState([]) const [excludeWordsInput, setExcludeWordsInput] = useState('') @@ -275,8 +284,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [showInsightApiKey, setShowInsightApiKey] = useState(false) const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false) const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null) - const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false) - const [aiInsightWhitelist, setAiInsightWhitelist] = useState>(new Set()) + const [aiInsightFilterMode, setAiInsightFilterMode] = useState('whitelist') + const [aiInsightFilterList, setAiInsightFilterList] = useState>(new Set()) + const [insightFilterType, setInsightFilterType] = useState('all') const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('') const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120) const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4) @@ -397,15 +407,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setPositionDropdownOpen(false) setCloseBehaviorDropdownOpen(false) setMessagePushFilterDropdownOpen(false) + setInsightFilterModeDropdownOpen(false) } } - if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) { + if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen || insightFilterModeDropdownOpen) { document.addEventListener('click', handleClickOutside) } return () => { document.removeEventListener('click', handleClickOutside) } - }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) + }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, insightFilterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) const loadConfig = async () => { @@ -531,8 +542,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() - const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled() - const savedAiInsightWhitelist = await configService.getAiInsightWhitelist() + const savedAiInsightFilterMode = await configService.getAiInsightFilterMode() + const savedAiInsightFilterList = await configService.getAiInsightFilterList() const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes() const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours() const savedAiInsightContextCount = await configService.getAiInsightContextCount() @@ -555,8 +566,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setAiModelApiMaxTokens(savedAiModelApiMaxTokens) setAiInsightSilenceDays(savedAiInsightSilenceDays) setAiInsightAllowContext(savedAiInsightAllowContext) - setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled) - setAiInsightWhitelist(new Set(savedAiInsightWhitelist)) + setAiInsightFilterMode(savedAiInsightFilterMode) + setAiInsightFilterList(new Set(savedAiInsightFilterList)) setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes) setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours) setAiInsightContextCount(savedAiInsightContextCount) @@ -3390,98 +3401,129 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- {/* 对话白名单 */} + {/* 对话过滤名单 */} {(() => { - const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) + const selectableSessions = sessionFilterOptions.filter((session) => + session.type === 'private' || session.type === 'group' || session.type === 'official' + ) const keyword = insightWhitelistSearch.trim().toLowerCase() - const filteredSessions = sortedSessions.filter((s) => { - const id = s.username?.trim() || '' - if (!id || id.endsWith('@chatroom') || id.toLowerCase().includes('placeholder')) return false + const filteredSessions = selectableSessions.filter((session) => { + if (insightFilterType !== 'all' && session.type !== insightFilterType) return false + const id = session.username?.trim() || '' + if (!id || id.toLowerCase().includes('placeholder')) return false if (!keyword) return true return ( - String(s.displayName || '').toLowerCase().includes(keyword) || + String(session.displayName || '').toLowerCase().includes(keyword) || id.toLowerCase().includes(keyword) ) }) - const filteredIds = filteredSessions.map((s) => s.username) - const selectedCount = aiInsightWhitelist.size - const selectedInFilteredCount = filteredIds.filter((id) => aiInsightWhitelist.has(id)).length + const filteredIds = filteredSessions.map((session) => session.username) + const selectedCount = aiInsightFilterList.size + const selectedInFilteredCount = filteredIds.filter((id) => aiInsightFilterList.has(id)).length const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length - const toggleSession = (id: string) => { - setAiInsightWhitelist((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) + const saveFilterList = async (next: Set) => { + await configService.setAiInsightFilterList(Array.from(next)) } - const saveWhitelist = async (next: Set) => { - await configService.setAiInsightWhitelist(Array.from(next)) + const saveFilterMode = async (mode: configService.AiInsightFilterMode) => { + setAiInsightFilterMode(mode) + setInsightFilterModeDropdownOpen(false) + await configService.setAiInsightFilterMode(mode) + showMessage(mode === 'whitelist' ? '已切换为白名单模式' : '已切换为黑名单模式', true) } const selectAllFiltered = () => { - setAiInsightWhitelist((prev) => { + setAiInsightFilterList((prev) => { const next = new Set(prev) for (const id of filteredIds) next.add(id) - void saveWhitelist(next) + void saveFilterList(next) return next }) } const clearSelection = () => { const next = new Set() - setAiInsightWhitelist(next) - void saveWhitelist(next) + setAiInsightFilterList(next) + void saveFilterList(next) } return (
-

对话白名单

+

对话黑白名单

- 开启后,AI 见解仅对勾选的私聊对话生效,未勾选的对话将被完全忽略。关闭时对所有私聊均生效。中间可填写微博 UID。 + 白名单模式下仅对已选会话触发见解;黑名单模式下会跳过已选会话。默认白名单且不选择任何会话。支持私聊、群聊、订阅号/服务号分类筛选后批量选择。

- 私聊总数 - {filteredIds.length + (keyword ? 0 : 0)} + 可选会话总数 + {selectableSessions.length}
- 已选中 + 已加入名单 {selectedCount}
-
- - {aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'} - - +
+
+ + {aiInsightFilterMode === 'whitelist' + ? '白名单模式(仅对名单内会话生效)' + : '黑名单模式(名单内会话将被忽略)'} + +
+
setInsightFilterModeDropdownOpen(!insightFilterModeDropdownOpen)} + > + + {aiInsightFilterMode === 'whitelist' ? '白名单模式' : '黑名单模式'} + + +
+
+ {[ + { value: 'whitelist', label: '白名单模式' }, + { value: 'blacklist', label: '黑名单模式' } + ].map(option => ( +
{ void saveFilterMode(option.value as configService.AiInsightFilterMode) }} + > + {option.label} + {aiInsightFilterMode === option.value && } +
+ ))} +
+
+
+
+ {insightFilterTypeOptions.map(option => ( + + ))} +
setInsightWhitelistSearch(e.target.value)} /> @@ -3517,7 +3559,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{filteredSessions.length === 0 ? (
- {insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'} + {insightWhitelistSearch || insightFilterType !== 'all' ? '没有匹配的对话' : '暂无可选对话'}
) : ( <> @@ -3527,7 +3569,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { 状态
{filteredSessions.map((session) => { - const isSelected = aiInsightWhitelist.has(session.username) + const isSelected = aiInsightFilterList.has(session.username) const weiboBinding = aiInsightWeiboBindings[session.username] const weiboDraftValue = getWeiboBindingDraftValue(session.username) const isBindingLoading = weiboBindingLoadingSessionId === session.username @@ -3543,11 +3585,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { type="checkbox" checked={isSelected} onChange={async () => { - setAiInsightWhitelist((prev) => { + setAiInsightFilterList((prev) => { const next = new Set(prev) if (next.has(session.username)) next.delete(session.username) else next.add(session.username) - void configService.setAiInsightWhitelist(Array.from(next)) + void configService.setAiInsightFilterList(Array.from(next)) return next }) }} @@ -3563,54 +3605,65 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { />
{session.displayName || session.username} + {getSessionFilterTypeLabel(session.type)}
-
- 微博 - updateWeiboBindingDraft(session.username, e.target.value)} - /> -
-
- - {weiboBinding && ( - - )} -
-
- {weiboBindingError ? ( - {weiboBindingError} - ) : weiboBinding?.screenName ? ( - @{weiboBinding.screenName} - ) : weiboBinding?.uid ? ( - 已绑定 UID:{weiboBinding.uid} - ) : ( - 仅支持手动填写数字 UID - )} -
+ {session.type === 'private' ? ( + <> +
+ 微博 + updateWeiboBindingDraft(session.username, e.target.value)} + /> +
+
+ + {weiboBinding && ( + + )} +
+
+ {weiboBindingError ? ( + {weiboBindingError} + ) : weiboBinding?.screenName ? ( + @{weiboBinding.screenName} + ) : weiboBinding?.uid ? ( + 已绑定 UID:{weiboBinding.uid} + ) : ( + 仅支持手动填写数字 UID + )} +
+ + ) : ( +
+ 仅私聊支持微博绑定 +
+ )}